Compare commits

..

98 Commits

Author SHA1 Message Date
2569718930@qq.com 27d4fc7c2b Remove unused Lock import from KMA station source 2026-04-10 07:41:09 +08:00
2569718930@qq.com fef37f6b0f Add KMA nearby weather support for Seoul and Busan 2026-04-09 20:19:07 +08:00
2569718930@qq.com 1fa5645c0d feat: add DetailPanel component for displaying city-specific weather data and charts 2026-04-09 14:24:03 +08:00
2569718930@qq.com 24ef8ee8be Add loading indicators for city detail sync 2026-04-09 08:29:02 +08:00
2569718930@qq.com 799abd71b0 Show syncing state while loading city snapshot 2026-04-08 20:22:32 +08:00
2569718930@qq.com 4316f42a68 Prevent dashboard loading overlay during city detail fetch 2026-04-08 20:09:02 +08:00
2569718930@qq.com d9faff1bc3 Fix dashboard auth and map marker cache updates 2026-04-08 18:52:59 +08:00
2569718930@qq.com f108c9f7df Restore guest city summaries and refetch missing Pro details 2026-04-08 18:25:25 +08:00
2569718930@qq.com 951eb2f261 Update lockfile for Solana kit dependencies 2026-04-08 17:39:44 +08:00
2569718930@qq.com 28ee268b35 Remove unused threading import from JMA AMEDAS sources 2026-04-08 17:24:43 +08:00
2569718930@qq.com 6c4f9f8203 Gate dashboard detail caches behind pro access 2026-04-08 17:17:51 +08:00
2569718930@qq.com 6f80d31852 Add JMA Haneda temps and fix stale detail loading 2026-04-08 16:20:02 +08:00
2569718930@qq.com 7b1f34db27 Refresh sparse model detail caches in forecast views 2026-04-08 15:23:34 +08:00
2569718930@qq.com 0af5a84449 Keep dashboard panel visible during city detail loading 2026-04-08 12:55:12 +08:00
2569718930@qq.com f0904dbcc5 Add Groq commentary config and simplify forecast modal 2026-04-08 11:41:34 +08:00
2569718930@qq.com 1d937728ee Refine future forecast modal trade signals and card styling 2026-04-08 11:16:51 +08:00
2569718930@qq.com 13f0713a98 Remove risk profile card from today analysis modal 2026-04-08 10:54:53 +08:00
2569718930@qq.com b16bfae991 Add intraday pace card to today's analysis 2026-04-08 10:33:58 +08:00
2569718930@qq.com cdccd4a21a Persist prewarm worker runtime to shared state 2026-04-08 07:13:54 +08:00
2569718930@qq.com c3da29c09c Add dashboard prewarm worker and cache visibility 2026-04-08 06:53:49 +08:00
2569718930@qq.com 0bb3b573e1 Filter invalid NMC wind placeholders from map labels 2026-04-07 13:02:45 +08:00
2569718930@qq.com 22621f5d9c Fix exact email grants and clarify cloud and wind labels 2026-04-07 10:17:17 +08:00
2569718930@qq.com eab6ec7cff Handle future subscriptions and trial overlap correctly 2026-04-07 09:54:21 +08:00
2569718930@qq.com 420aa32a39 Remove manual grant detail from subscription audit log 2026-04-07 09:39:33 +08:00
2569718930@qq.com 1b5b38e99d feat: add script to manually grant subscriptions by email 2026-04-07 09:35:33 +08:00
2569718930@qq.com 3f13257127 Show METAR labels for Wunderground realtime observations 2026-04-06 21:04:16 +08:00
2569718930@qq.com fbd1e4ab16 Persist recent payment recovery state across account sessions 2026-04-06 20:40:26 +08:00
2569718930@qq.com 7fe16bd584 Update frontend lockfile for proxy-from-env 2.1.0 2026-04-06 14:23:37 +08:00
2569718930@qq.com 08d7308486 Gate analytics and cache public API requests 2026-04-06 13:58:11 +08:00
2569718930@qq.com 9be12ad1d7 Clarify NMC nearby station labels on the map 2026-04-06 12:54:22 +08:00
2569718930@qq.com 26674cf2b7 Clarify NMC labels as regional observations 2026-04-06 12:47:52 +08:00
2569718930@qq.com 0f5c658ba8 Refine map marker offsets and remove duplicate airport sources 2026-04-06 11:32:03 +08:00
2569718930@qq.com 2e44b40b86 Prefer station labels in nearby map markers 2026-04-06 11:19:22 +08:00
2569718930@qq.com 5117a08057 Update docs and clarify OBS map labels 2026-04-06 07:45:42 +08:00
2569718930@qq.com 960a06672e Separate overlapping nearby observation markers on the map 2026-04-06 07:37:25 +08:00
2569718930@qq.com cc08839b51 Update Shenzhen NMC source URL and station code 2026-04-06 07:25:24 +08:00
2569718930@qq.com bdc300bdd2 Expose station network coverage and settlement station details 2026-04-06 07:21:59 +08:00
2569718930@qq.com e4a5c4c8d5 Remove duplicate TruthRecordRepository import 2026-04-06 05:43:08 +08:00
2569718930@qq.com f4335833b4 Promote Pro trial and rename settlement source to station 2026-04-06 05:38:41 +08:00
2569718930@qq.com e2c43351bd Make city history read from SQLite truth and feature records 2026-04-05 10:57:51 +08:00
2569718930@qq.com 92a1a7de12 Remove Wunderground settlement reference from extension charts 2026-04-05 10:26:28 +08:00
2569718930@qq.com b93a917f88 Remove Wunderground settlement reference from chart 2026-04-05 07:24:09 +08:00
2569718930@qq.com a5b5711863 Remove AI analysis and rank cities by recent DEB performance 2026-04-05 07:13:00 +08:00
2569718930@qq.com 343c5c9c2f Add new Wunderground-backed cities and source links 2026-04-05 01:23:10 +08:00
2569718930@qq.com 15a16f59fe Align browser extension with current market data sources 2026-04-03 02:21:23 +08:00
2569718930@qq.com 7f9e774548 Refine ops dashboards and align browser extension data views 2026-04-03 01:53:28 +08:00
2569718930@qq.com 3e265d2764 Refactor weather app logic and simplify related components 2026-04-03 01:47:03 +08:00
2569718930@qq.com e32eff6e31 Expand ops admin views for truth history and training data 2026-04-03 01:38:53 +08:00
2569718930@qq.com 3f82fd2855 Add ops truth history admin view and navigation 2026-04-03 01:32:45 +08:00
2569718930@qq.com bbdba2540c Add admin truth history dashboard and training data ops views 2026-04-03 01:27:54 +08:00
2569718930@qq.com 781c247952 Add ops dashboards for training data and model coverage 2026-04-03 00:57:19 +08:00
2569718930@qq.com 37cd8b8166 Update deep research report for monitoring progress 2026-04-02 23:35:13 +08:00
2569718930@qq.com 90791b6070 Refactor monitoring relay alert dispatch 2026-04-02 23:30:52 +08:00
2569718930@qq.com 95c0c452f1 Finalize SQLite defaults and refresh calibration artifacts 2026-04-02 23:24:38 +08:00
2569718930@qq.com 603546f2f9 Align Taipei and Shenzhen settlement sources with Wunderground 2026-04-02 21:46:41 +08:00
2569718930@qq.com f64d3aec83 Filter market pushes by local trading window 2026-04-01 22:01:24 +08:00
2569718930@qq.com 93f4a95e80 Add actionable counts to market digest logs 2026-04-01 21:31:17 +08:00
2569718930@qq.com 59fb1266b9 Broaden market monitor shortlist for tradable markets 2026-04-01 21:28:02 +08:00
2569718930@qq.com f8143f462f Push market focus digests in scan batches 2026-04-01 21:22:01 +08:00
2569718930@qq.com da0b4198ac Add market monitor digest skip diagnostics 2026-04-01 21:14:23 +08:00
2569718930@qq.com ee40045b70 Hardcode Telegram market channel URL and update bot tests 2026-04-01 20:34:41 +08:00
2569718930@qq.com 65688dffd9 Add Telegram market monitor channel link to account center 2026-04-01 20:27:49 +08:00
2569718930@qq.com fdcc57339b Disable Telegram link previews in market digest messages 2026-04-01 20:07:03 +08:00
2569718930@qq.com 9dc21a3088 Skip non-tradable markets in monitor digests 2026-04-01 19:43:57 +08:00
2569718930@qq.com 62cf8f2c4d Update market alert test for renamed Telegram copy 2026-04-01 19:35:07 +08:00
2569718930@qq.com c7c4f6c674 Fix digest push log after interval-based scheduling 2026-04-01 19:28:58 +08:00
2569718930@qq.com 149452dcc0 Switch market digests to interval-based pushes 2026-04-01 19:23:35 +08:00
2569718930@qq.com 9e9e5a56ba Make markets command generate digest asynchronously 2026-04-01 19:08:31 +08:00
2569718930@qq.com 4f017d501b Refine market alert wording and focus digest scheduling 2026-04-01 18:56:39 +08:00
2569718930@qq.com 5fa69556eb Pass config into bot handler registration 2026-04-01 18:49:42 +08:00
2569718930@qq.com 384fd7d1e4 Remove mispricing price cap from market alerts 2026-04-01 18:46:46 +08:00
2569718930@qq.com b1b8d3439c Restrict /markets to private chats 2026-04-01 18:34:58 +08:00
2569718930@qq.com c9719cf577 Add local peak timing and /markets bot digest 2026-04-01 18:24:50 +08:00
2569718930@qq.com 07b2770e9d Update tests for retired wallet monitor and trial timing 2026-04-01 18:02:08 +08:00
2569718930@qq.com 342ed77283 Document market monitor bot settings and retire wallet activity 2026-04-01 17:53:31 +08:00
2569718930@qq.com 400916b023 Move DBManager import into main for sync script 2026-04-01 15:33:42 +08:00
2569718930@qq.com 3bbd8774a4 Sync Telegram profile fields to Supabase and show user IDs 2026-04-01 02:13:17 +08:00
2569718930@qq.com a8462e188b Add external monitoring stack and alerting docs 2026-04-01 01:49:58 +08:00
2569718930@qq.com 6626b5472c Update docs for SQLite migration second-phase cleanup 2026-03-31 23:06:19 +08:00
2569718930@qq.com 79832dbc2b Add analytics funnel cards to the ops dashboard 2026-03-31 10:43:01 +08:00
2569718930@qq.com c29b560401 Add app analytics tracking for paywall and checkout events 2026-03-31 07:15:54 +08:00
2569718930@qq.com 8c8e242753 Refine Istanbul nearby station selection 2026-03-30 19:26:52 +08:00
2569718930@qq.com 2508e4164f Improve map nearby station selection for Istanbul 2026-03-30 19:21:39 +08:00
2569718930@qq.com e4db7d5dea Fix Istanbul MGM station and province matching 2026-03-30 19:14:00 +08:00
2569718930@qq.com 2ee30ce551 Add Moscow and broaden Turkish MGM support 2026-03-30 19:03:13 +08:00
2569718930@qq.com 3899012387 feat: initialize extension manifest with side panel and background service worker configuration 2026-03-30 02:56:11 +08:00
2569718930@qq.com 83824a772e Improve side panel city matching for market aliases 2026-03-30 01:40:37 +08:00
2569718930@qq.com bbda6da39a Remove unused type-checking imports from training script 2026-03-30 01:08:35 +08:00
2569718930@qq.com 4e399d5961 Remove unused weather aura layer from dashboard 2026-03-30 01:04:21 +08:00
2569718930@qq.com 538de5cdd8 Add subscription expiry reminders across account and dashboard 2026-03-30 00:58:43 +08:00
2569718930@qq.com 16404ccf71 Switch repository licensing to AGPL-3.0 2026-03-30 00:46:01 +08:00
2569718930@qq.com ee114fb5bf Add Polymarket market link and refresh side panel docs 2026-03-30 00:09:35 +08:00
2569718930@qq.com 6c7b6de8f9 feat: add manifest.json for side panel extension configuration 2026-03-29 23:54:07 +08:00
2569718930@qq.com c64fa87e05 refactor: consolidate docker-compose service configurations using a shared base anchor 2026-03-29 23:36:53 +08:00
2569718930@qq.com 0cc4f4f0d3 Add LightGBM daily high forecasting pipeline 2026-03-29 23:29:17 +08:00
2569718930@qq.com 4a2db4727b chore: add .dockerignore file to exclude build artifacts and environment files 2026-03-29 22:38:07 +08:00
2569718930@qq.com a5c667473e Remove remote TimesFM integration 2026-03-29 22:27:18 +08:00
2569718930@qq.com cdfc785402 Add remote TimesFM service integration 2026-03-29 22:13:10 +08:00
149 changed files with 17874 additions and 4681 deletions
+31
View File
@@ -0,0 +1,31 @@
.git
.github
.vscode
.agent
.env
.env.*
!.env.example
!.env.secrets.example
venv
.venv
.uv-cache
.uv-python
.pytest_cache
.ruff_cache
.mypy_cache
__pycache__
.npm-cache
frontend/node_modules
artifacts
notebooks
bot.log
*.log
extension.zip
tmp_*.js
tmp_*.html
+25 -3
View File
@@ -17,7 +17,13 @@ OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
# Windows / macOS can usually keep the defaults.
UID=1000
GID=1000
POLYWEATHER_STATE_STORAGE_MODE=dual
POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_PROMETHEUS_PORT=9090
POLYWEATHER_ALERTMANAGER_PORT=9093
POLYWEATHER_ALERT_RELAY_PORT=9099
POLYWEATHER_GRAFANA_PORT=3001
POLYWEATHER_GRAFANA_ADMIN_USER=admin
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
########################################
# 2) Telegram bot minimal
@@ -42,6 +48,10 @@ OPEN_METEO_RATE_CACHE_TTL_SEC=3600
OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
METAR_CACHE_TTL_SEC=600
METEOBLUE_CACHE_TTL_SEC=7200
POLYWEATHER_LGBM_ENABLED=false
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
########################################
# 4) Auth / entitlement
@@ -66,8 +76,13 @@ TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
########################################
# 6) Frontend-facing shared values
@@ -79,6 +94,13 @@ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# 7) Optional modules
########################################
# Optional Groq commentary rewrite for intraday structure cards
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
GROQ_API_KEY=
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
# Weekly reward / leaderboard
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
@@ -147,7 +169,7 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket wallet activity
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
+3 -1
View File
@@ -6,6 +6,8 @@ WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_ROOT_USER_ACTION=ignore \
TZ=UTC
# 安装系统依赖 (如果有必要的包可以取消注释)
@@ -15,7 +17,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --prefer-binary -r requirements.txt
# 复制项目代码
COPY . .
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 Yuanzhen Yang (yangyuan-zhen)
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+13 -8
View File
@@ -3,6 +3,7 @@
Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday signals, TAF, settlement sources, history, and extension).
@@ -24,7 +25,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Runtime state supports gradual SQLite migration (`file / dual / sqlite`).
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
- EMOS/CRPS pipeline is integrated in `shadow` mode with rollout gating.
- Intraday structural signal is now peak-window aware and bilingual (`zh-CN` / `en-US`).
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
@@ -32,14 +33,15 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
## Open-Core Boundary (Important)
## License & Commercial Boundary
This repository follows an **Open-Core** strategy:
This repository is licensed under **GNU AGPL-3.0 only** from `2026-03-30` onward.
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, standard payment flow.
- Private in production: commercial risk rules, operational thresholds, pricing strategy details, internal reconciliation policies, and growth operations tooling.
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, and standard payment flow.
- Not included in this repository: private production data, internal operating thresholds, commercial risk rules, pricing strategy details, and growth tooling.
- Trademark, brand, domain, production databases, and hosted-service operations are **not** granted by the code license.
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
## Core Capabilities
@@ -99,7 +101,8 @@ npm run dev
## Recent Highlights
- Taipei settlement is aligned to `NOAA RCTP` and rounded whole-degree Celsius logic.
- Taipei settlement is aligned to `Wunderground RCSS` with whole-degree Celsius resolution logic.
- Shenzhen settlement is aligned to `Wunderground ZGSZ`.
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
- Intraday analysis now separates:
- `Surface Structure`
@@ -115,6 +118,7 @@ Use external runtime storage to avoid SQLite/git conflicts:
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite
```
## Ops Verification
@@ -168,9 +172,10 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
+28 -9
View File
@@ -23,17 +23,19 @@
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 运行态状态与缓存已支持 SQLite 渐进迁移:`file / dual / sqlite`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`
## 开源边界(重要)
## 许可证与商用边界(重要)
项目采用 **Open-Core** 策略:
仓库自 `2026-03-30` 起采用 **GNU AGPL-3.0-only**
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、标准支付流程
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具
- 商标、品牌、域名、生产数据库与托管服务运营能力,不因代码许可证一并授权。
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
详细见:[AGPL-3.0 与商用边界](docs/OPEN_CORE_POLICY.md)
## 核心能力
@@ -62,7 +64,7 @@ flowchart LR
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> PM["Polymarket 只读层"]
API --> OBS["healthz / system status / metrics"]
ANA --> STATE["SQLite runtime state + dual fallback"]
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
```
## 监控城市(30
@@ -96,7 +98,7 @@ npm run dev
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=dual
POLYWEATHER_STATE_STORAGE_MODE=sqlite
```
## 运维验收
@@ -121,6 +123,22 @@ curl http://127.0.0.1:8000/metrics
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### 外部监控栈
```bash
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
```
- Prometheus`http://127.0.0.1:${POLYWEATHER_PROMETHEUS_PORT:-9090}`
- Alertmanager`http://127.0.0.1:${POLYWEATHER_ALERTMANAGER_PORT:-9093}`
- Grafana`http://127.0.0.1:${POLYWEATHER_GRAFANA_PORT:-3001}`
手动巡检:
```bash
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
```
### 支付运行态
```bash
@@ -158,7 +176,7 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- 英文总览:[README.md](README.md)
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
@@ -170,6 +188,7 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
- 发布流程:[RELEASE.md](RELEASE.md)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
{
"model_type": "LightGBMRegressor",
"target": "actual_high",
"horizon": "D0",
"feature_names": [
"actual_high_lag_1",
"actual_high_lag_2",
"actual_high_lag_3",
"actual_high_lag_7",
"actual_high_mean_7",
"actual_high_mean_14",
"actual_high_trend_3",
"open_meteo",
"ecmwf",
"gfs",
"gem",
"jma",
"icon",
"mgm",
"nws",
"deb_prediction",
"model_median",
"model_spread",
"current_temp",
"max_so_far",
"humidity",
"wind_speed_kt",
"visibility_mi",
"local_hour",
"month",
"weekday",
"peak_status_code"
],
"base_model_columns": [
"open_meteo",
"ecmwf",
"gfs",
"gem",
"jma",
"icon",
"mgm",
"nws"
],
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
"sample_count": 54,
"train_count": 42,
"validation_count": 12,
"metrics": {
"validation": {
"sample_count": 12,
"lgbm_mae": 1.349,
"deb_mae": 0.875,
"best_single_mae": 0.325,
"median_mae": 0.758
},
"full_sample": {
"sample_count": 54,
"lgbm_mae": 0.691,
"deb_mae": 6.287,
"best_single_mae": 5.431,
"median_mae": 6.265
}
},
"generated_at": "2026-04-02T16:27:44.816882Z",
"trained_at": "2026-04-02T16:27:44.816882Z"
}
+46 -130
View File
@@ -1,20 +1,20 @@
{
"version": "emos-20260320132525",
"trained_at": "2026-03-20T13:25:25.836021+00:00",
"version": "emos-20260402162744",
"trained_at": "2026-04-02T16:27:44.114836+00:00",
"global": {
"mu": {
"intercept": -1.57406048,
"raw_mu_coef": 2.80583627,
"deb_coef": -0.06819634,
"ens_median_coef": -1.81560215,
"max_so_far_gap_coef": 0.0
"intercept": 1.54512641,
"raw_mu_coef": 2.96105052,
"deb_coef": -1.53260815,
"ens_median_coef": -0.72849343,
"max_so_far_gap_coef": 9.52557689
},
"sigma": {
"intercept": 0.67509915,
"raw_sigma_coef": 0.14431833,
"spread_coef": 0.14431833,
"peak_flag_coef": 0.0,
"max_so_far_gap_coef": 0.0
"intercept": 0.67432479,
"raw_sigma_coef": 0.6936692,
"spread_coef": 0.08877484,
"peak_flag_coef": -0.58374835,
"max_so_far_gap_coef": -0.8172477
}
},
"sigma_constraints": {
@@ -30,144 +30,60 @@
},
"blending": {
"alpha_mu": 0.0,
"alpha_sigma": 0.0
"alpha_sigma": 0.05
},
"cities": {
"ankara": {
"samples": 7,
"mu_bias": 0.566273,
"sigma_scale": 2.0,
"confidence": 0.875
},
"london": {
"samples": 6,
"mu_bias": 0.489961,
"sigma_scale": 2.0,
"confidence": 0.75
},
"new york": {
"samples": 4,
"mu_bias": 1.852451,
"sigma_scale": 0.830444,
"confidence": 0.5
},
"paris": {
"samples": 7,
"mu_bias": 0.308599,
"sigma_scale": 2.0,
"confidence": 0.875
},
"seoul": {
"samples": 6,
"mu_bias": -1.486024,
"sigma_scale": 1.569585,
"confidence": 0.75
},
"toronto": {
"samples": 5,
"mu_bias": -0.734395,
"sigma_scale": 1.656751,
"confidence": 0.625
},
"buenos aires": {
"samples": 5,
"mu_bias": -1.753334,
"sigma_scale": 1.831739,
"confidence": 0.625
},
"wellington": {
"samples": 6,
"mu_bias": 0.350757,
"sigma_scale": 1.377974,
"confidence": 0.75
},
"chicago": {
"samples": 4,
"mu_bias": 3.01062,
"sigma_scale": 0.825575,
"confidence": 0.5
},
"sao paulo": {
"samples": 5,
"mu_bias": 1.632457,
"sigma_scale": 2.0,
"confidence": 0.625
},
"dallas": {
"samples": 4,
"mu_bias": 3.77714,
"sigma_scale": 0.796874,
"confidence": 0.5
},
"miami": {
"samples": 5,
"mu_bias": -4.868741,
"sigma_scale": 2.0,
"confidence": 0.625
},
"atlanta": {
"samples": 5,
"mu_bias": -7.648823,
"sigma_scale": 2.0,
"confidence": 0.625
},
"seattle": {
"samples": 4,
"mu_bias": 4.058619,
"sigma_scale": 2.0,
"confidence": 0.5
},
"lucknow": {
"samples": 4,
"mu_bias": 3.257609,
"sigma_scale": 2.0,
"confidence": 0.5
},
"munich": {
"samples": 6,
"mu_bias": -0.780811,
"sigma_scale": 2.0,
"confidence": 0.75
"samples": 3,
"mu_bias": 1.271844,
"sigma_scale": 1.477644,
"confidence": 0.375
},
"hong kong": {
"samples": 4,
"mu_bias": 1.32008,
"sigma_scale": 1.04023,
"confidence": 0.5
},
"milan": {
"samples": 3,
"mu_bias": -0.492675,
"mu_bias": -3.935178,
"sigma_scale": 2.0,
"confidence": 0.375
},
"shanghai": {
"samples": 3,
"mu_bias": 1.810495,
"sigma_scale": 2.0,
"confidence": 0.375
},
"taipei": {
"samples": 3,
"mu_bias": 1.462462,
"sigma_scale": 1.132834,
"confidence": 0.375
},
"milan": {
"samples": 3,
"mu_bias": -2.729203,
"mu_bias": 3.577828,
"sigma_scale": 2.0,
"confidence": 0.375
},
"warsaw": {
"samples": 3,
"mu_bias": 0.349319,
"sigma_scale": 1.337949,
"mu_bias": -0.625333,
"sigma_scale": 1.25968,
"confidence": 0.375
}
},
"metrics": {
"sample_count": 105,
"mean_crps": 2.923823,
"legacy_mean_crps": 2.793938,
"legacy_mean_mae": 2.721143,
"legacy_bucket_hit_rate": 0.695238,
"legacy_bucket_brier": 0.775463,
"selected_mean_crps": 2.700275,
"selected_mean_mae": 2.721143,
"selected_bucket_hit_rate": 0.695238,
"selected_bucket_brier": 0.765459,
"selected_score": 4.003626,
"legacy_score": 4.104792,
"filled_actual_from_history": 2,
"sample_count": 54,
"mean_crps": 3.792563,
"legacy_mean_crps": 4.308029,
"legacy_mean_mae": 4.51037,
"legacy_bucket_hit_rate": 0.537037,
"legacy_bucket_brier": 0.833294,
"selected_mean_crps": 4.249828,
"selected_mean_mae": 4.51037,
"selected_bucket_hit_rate": 0.555556,
"selected_bucket_brier": 0.831872,
"selected_score": 5.991436,
"legacy_score": 6.078481,
"filled_actual_from_history": 0,
"settlement_history_city_count": 30
},
"source": "artifacts\\probability_calibration\\default.json"
@@ -1,190 +1,199 @@
{
"summary": {
"sample_count": 105,
"sample_count": 54,
"filled_actual_from_history": 2,
"legacy": {
"mean_crps": 2.793938,
"mean_mae": 2.721143,
"bucket_hit_rate": 0.695238
"mean_crps": 4.300621,
"mean_mae": 4.502963,
"bucket_hit_rate": 0.537037
},
"emos": {
"mean_crps": 2.700275,
"mean_mae": 2.721143,
"bucket_hit_rate": 0.695238
"mean_crps": 4.213889,
"mean_mae": 4.502963,
"bucket_hit_rate": 0.537037
},
"delta": {
"crps": -0.093663,
"crps": -0.086732,
"mae": 0.0,
"bucket_hit_rate": 0.0
}
},
"by_city": {
"ankara": {
"samples": 7,
"legacy_mean_crps": 2.023242,
"emos_mean_crps": 2.023242,
"legacy_mean_mae": 1.984286,
"emos_mean_mae": 1.984286,
"legacy_bucket_hit_rate": 0.714286,
"emos_bucket_hit_rate": 0.714286
"samples": 3,
"legacy_mean_crps": 0.327701,
"emos_mean_crps": 0.439705,
"legacy_mean_mae": 0.066667,
"emos_mean_mae": 0.066667,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"atlanta": {
"samples": 5,
"legacy_mean_crps": 12.792034,
"emos_mean_crps": 12.694543,
"legacy_mean_mae": 12.806,
"emos_mean_mae": 12.806,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 30.449382,
"emos_mean_crps": 30.578432,
"legacy_mean_mae": 32.015,
"emos_mean_mae": 32.015,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"buenos aires": {
"samples": 5,
"legacy_mean_crps": 3.846144,
"emos_mean_crps": 3.846144,
"legacy_mean_mae": 4.168,
"emos_mean_mae": 4.168,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 9.113412,
"emos_mean_crps": 8.759954,
"legacy_mean_mae": 10.27,
"emos_mean_mae": 10.27,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"chicago": {
"samples": 4,
"legacy_mean_crps": 1.346667,
"emos_mean_crps": 0.601765,
"samples": 1,
"legacy_mean_crps": 1.250268,
"emos_mean_crps": 0.701085,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"dallas": {
"samples": 4,
"legacy_mean_crps": 1.256111,
"emos_mean_crps": 0.651425,
"samples": 1,
"legacy_mean_crps": 2.173363,
"emos_mean_crps": 0.701085,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"hong kong": {
"samples": 3,
"legacy_mean_crps": 0.261027,
"emos_mean_crps": 0.261027,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"samples": 4,
"legacy_mean_crps": 0.29509,
"emos_mean_crps": 0.387946,
"legacy_mean_mae": 0.075,
"emos_mean_mae": 0.075,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.666667
"emos_bucket_hit_rate": 0.75
},
"london": {
"samples": 6,
"legacy_mean_crps": 2.079624,
"emos_mean_crps": 2.079624,
"legacy_mean_mae": 2.451667,
"emos_mean_mae": 2.451667,
"legacy_bucket_hit_rate": 0.166667,
"emos_bucket_hit_rate": 0.166667
"samples": 2,
"legacy_mean_crps": 3.885033,
"emos_mean_crps": 3.866915,
"legacy_mean_mae": 4.135,
"emos_mean_mae": 4.135,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"lucknow": {
"samples": 4,
"legacy_mean_crps": 1.468528,
"emos_mean_crps": 1.468528,
"legacy_mean_mae": 1.6025,
"emos_mean_mae": 1.6025,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 0.5
"samples": 2,
"legacy_mean_crps": 2.487193,
"emos_mean_crps": 2.342342,
"legacy_mean_mae": 3.205,
"emos_mean_mae": 3.205,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"madrid": {
"samples": 2,
"legacy_mean_crps": 6.27726,
"emos_mean_crps": 6.27726,
"emos_mean_crps": 5.967277,
"legacy_mean_mae": 7.33,
"emos_mean_mae": 7.33,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"miami": {
"samples": 5,
"legacy_mean_crps": 11.665378,
"emos_mean_crps": 11.665378,
"legacy_mean_mae": 12.07,
"emos_mean_mae": 12.07,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 28.637631,
"emos_mean_crps": 28.482516,
"legacy_mean_mae": 30.175,
"emos_mean_mae": 30.175,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"milan": {
"samples": 3,
"legacy_mean_crps": 4.401392,
"emos_mean_crps": 3.928883,
"emos_mean_crps": 3.858031,
"legacy_mean_mae": 4.06,
"emos_mean_mae": 4.06,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.666667
},
"munich": {
"samples": 6,
"legacy_mean_crps": 2.988583,
"emos_mean_crps": 2.988583,
"legacy_mean_mae": 3.143333,
"emos_mean_mae": 3.143333,
"samples": 2,
"legacy_mean_crps": 3.145192,
"emos_mean_crps": 3.011312,
"legacy_mean_mae": 3.64,
"emos_mean_mae": 3.64,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"new york": {
"samples": 1,
"legacy_mean_crps": 3.692845,
"emos_mean_crps": 3.407357,
"legacy_mean_mae": 4.94,
"emos_mean_mae": 4.94,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"paris": {
"samples": 2,
"legacy_mean_crps": 4.013782,
"emos_mean_crps": 3.979293,
"legacy_mean_mae": 4.265,
"emos_mean_mae": 4.265,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 0.5
},
"new york": {
"samples": 4,
"legacy_mean_crps": 1.861101,
"emos_mean_crps": 1.409393,
"legacy_mean_mae": 1.3725,
"emos_mean_mae": 1.3725,
"legacy_bucket_hit_rate": 0.75,
"emos_bucket_hit_rate": 0.75
},
"paris": {
"samples": 7,
"legacy_mean_crps": 2.430082,
"emos_mean_crps": 2.430082,
"legacy_mean_mae": 2.518571,
"emos_mean_mae": 2.518571,
"legacy_bucket_hit_rate": 0.571429,
"emos_bucket_hit_rate": 0.571429
},
"sao paulo": {
"samples": 5,
"legacy_mean_crps": 2.454756,
"emos_mean_crps": 2.454756,
"legacy_mean_mae": 2.628,
"emos_mean_mae": 2.628,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 5.540967,
"emos_mean_crps": 5.272063,
"legacy_mean_mae": 6.57,
"emos_mean_mae": 6.57,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"seattle": {
"samples": 4,
"legacy_mean_crps": 0.531656,
"emos_mean_crps": 0.452784,
"samples": 1,
"legacy_mean_crps": 0.315488,
"emos_mean_crps": 0.425909,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"seoul": {
"samples": 6,
"legacy_mean_crps": 0.328088,
"emos_mean_crps": 0.328088,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 0.2,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"shanghai": {
"samples": 2,
"legacy_mean_crps": 0.250034,
"emos_mean_crps": 0.250034,
"legacy_mean_crps": 0.313754,
"emos_mean_crps": 0.412831,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"shanghai": {
"samples": 3,
"legacy_mean_crps": 0.299116,
"emos_mean_crps": 0.394855,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"shenzhen": {
"samples": 1,
"legacy_mean_crps": 0.798351,
"emos_mean_crps": 0.762787,
"legacy_mean_mae": 0.9,
"emos_mean_mae": 0.9,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"singapore": {
"samples": 2,
"legacy_mean_crps": 0.281993,
"emos_mean_crps": 0.281993,
"emos_mean_crps": 0.37264,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"legacy_bucket_hit_rate": 1.0,
@@ -193,7 +202,7 @@
"taipei": {
"samples": 3,
"legacy_mean_crps": 0.356996,
"emos_mean_crps": 0.356996,
"emos_mean_crps": 0.472738,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
@@ -202,7 +211,7 @@
"tel aviv": {
"samples": 2,
"legacy_mean_crps": 0.446758,
"emos_mean_crps": 0.446758,
"emos_mean_crps": 0.578006,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 0.3,
"legacy_bucket_hit_rate": 1.0,
@@ -211,36 +220,36 @@
"tokyo": {
"samples": 2,
"legacy_mean_crps": 0.450128,
"emos_mean_crps": 0.450128,
"emos_mean_crps": 0.582151,
"legacy_mean_mae": 0.25,
"emos_mean_mae": 0.25,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 1.0
},
"toronto": {
"samples": 5,
"legacy_mean_crps": 2.647861,
"emos_mean_crps": 2.566068,
"legacy_mean_mae": 2.532,
"emos_mean_mae": 2.532,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 5.497916,
"emos_mean_crps": 5.240552,
"legacy_mean_mae": 6.33,
"emos_mean_mae": 6.33,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"warsaw": {
"samples": 3,
"legacy_mean_crps": 1.618875,
"emos_mean_crps": 1.618875,
"emos_mean_crps": 1.553232,
"legacy_mean_mae": 2.056667,
"emos_mean_mae": 2.056667,
"legacy_bucket_hit_rate": 0.333333,
"emos_bucket_hit_rate": 0.333333
},
"wellington": {
"samples": 6,
"legacy_mean_crps": 0.266349,
"emos_mean_crps": 0.266349,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 0.2,
"samples": 2,
"legacy_mean_crps": 0.364919,
"emos_mean_crps": 0.475875,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
}
@@ -17,55 +17,57 @@
"max_delta_bucket_brier_observe": 0.15
},
"evaluation": {
"sample_count": 105,
"delta_crps": -0.093663,
"sample_count": 54,
"delta_crps": -0.086732,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0
},
"shadow": {
"sample_count": 103,
"delta_mae": 0.012708,
"delta_bucket_hit_rate": 0.009709,
"delta_bucket_brier": 0.293835
"sample_count": 48,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.041666,
"delta_bucket_brier": 0.123252
},
"blocking_reasons": [
"shadow bucket brier 退化超限:delta=0.293835"
"离线评估样本不足:54 < 80",
"shadow 样本不足:48 < 50",
"shadow bucket brier 退化超限:delta=0.123252"
],
"worst_shadow_regressions": [
{
"city": "dallas",
"samples": 4,
"delta_mae": 0.114807,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.778678
"delta_bucket_brier": 0.792585
},
{
"city": "chicago",
"samples": 4,
"delta_mae": 0.075265,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.746156
"delta_bucket_brier": 0.791878
},
{
"city": "seattle",
"samples": 4,
"delta_mae": 0.11262,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.692003
"delta_bucket_brier": 0.61609
},
{
"city": "atlanta",
"samples": 4,
"delta_mae": 0.293028,
"delta_bucket_hit_rate": -0.25,
"delta_bucket_brier": 0.601425
"city": "wellington",
"samples": 2,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.509203
},
{
"city": "miami",
"samples": 4,
"delta_mae": 0.241559,
"delta_bucket_hit_rate": -0.5,
"delta_bucket_brier": 0.478245
"city": "tel aviv",
"samples": 2,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.439879
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.24;
interface IERC20 {
+1 -1
View File
@@ -1,4 +1,4 @@
// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.24;
interface IERC20 {
@@ -121,3 +121,8 @@
{"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T10:02:35+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.15911458333333342, "deb_prediction": 28.2, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.5, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.5, "GEM": 30.7, "JMA": 26.2}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "ankara", "timestamp": "2026-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897}
{"city": "chengdu", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 0.7283767361111106, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 22.9, "p90": 24.2}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 22.5, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 16.133333333333333}, "peak_status": "in_window", "prob_snapshot": [{"v": 24, "p": 0.547}, {"v": 25, "p": 0.397}, {"v": 26, "p": 0.056}], "shadow_prob_snapshot": [{"v": 24, "p": 0.521}, {"v": 25, "p": 0.399}, {"v": 26, "p": 0.08}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 0.8140063140878262}
{"city": "tokyo", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 17.810000000000002, "raw_sigma": 0.23200683593750038, "deb_prediction": 17.1, "ensemble": {"p10": 17.4, "median": 18.3, "p90": 19.1}, "multi_model": {"Open-Meteo": 16.1, "ECMWF": 16.3, "GFS": 17.6, "ICON": 18.2, "GEM": 18.3, "JMA": 16.1}, "max_so_far": 17.0, "observation": {"current_temp": 16.0, "humidity": null, "wind_speed_kt": 17.0, "visibility_mi": null, "local_hour": 17.133333333333333}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 0.909}, {"v": 17, "p": 0.091}], "shadow_prob_snapshot": [{"v": 18, "p": 0.892}, {"v": 17, "p": 0.108}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 17.810000000000002, "calibrated_sigma": 0.25}
+85 -6
View File
@@ -1,10 +1,14 @@
x-polyweather-base: &polyweather-base
build: .
image: polyweather-app:latest
env_file:
- .env
services:
polyweather:
build: .
<<: *polyweather-base
container_name: polyweather_bot
restart: unless-stopped
env_file:
- .env
volumes:
# Persist runtime data outside git workspace.
# Host path defaults to /var/lib/polyweather and can be overridden in .env.
@@ -17,12 +21,10 @@ services:
user: "${UID:-1000}:${GID:-1000}"
polyweather_web:
build: .
<<: *polyweather-base
container_name: polyweather_web
restart: unless-stopped
command: python web/app.py
env_file:
- .env
volumes:
# Web service shares the same runtime data directory as bot/state tasks.
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
@@ -31,3 +33,80 @@ services:
- "8000:8000"
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
user: "${UID:-1000}:${GID:-1000}"
polyweather_prewarm:
<<: *polyweather-base
container_name: polyweather_prewarm
restart: unless-stopped
profiles: ["workers"]
command: python scripts/prewarm_dashboard_worker.py --include-detail --include-market
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
user: "${UID:-1000}:${GID:-1000}"
polyweather_prometheus:
image: prom/prometheus:v3.4.1
container_name: polyweather_prometheus
restart: unless-stopped
profiles: ["monitoring"]
depends_on:
- polyweather_web
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
- "--web.enable-lifecycle"
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/prometheus:/prometheus
ports:
- "${POLYWEATHER_PROMETHEUS_PORT:-9090}:9090"
polyweather_alertmanager:
image: prom/alertmanager:v0.28.1
container_name: polyweather_alertmanager
restart: unless-stopped
profiles: ["monitoring"]
depends_on:
- polyweather_alert_relay
command:
- "--config.file=/etc/alertmanager/alertmanager.yml"
- "--storage.path=/alertmanager"
volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/alertmanager:/alertmanager
ports:
- "${POLYWEATHER_ALERTMANAGER_PORT:-9093}:9093"
polyweather_alert_relay:
<<: *polyweather-base
container_name: polyweather_alert_relay
restart: unless-stopped
profiles: ["monitoring"]
command: python scripts/alertmanager_telegram_relay.py
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
ports:
- "${POLYWEATHER_ALERT_RELAY_PORT:-9099}:9099"
user: "${UID:-1000}:${GID:-1000}"
polyweather_grafana:
image: grafana/grafana-oss:12.0.2
container_name: polyweather_grafana
restart: unless-stopped
profiles: ["monitoring"]
depends_on:
- polyweather_prometheus
environment:
GF_SECURITY_ADMIN_USER: ${POLYWEATHER_GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${POLYWEATHER_GRAFANA_ADMIN_PASSWORD:-polyweather}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/grafana:/var/lib/grafana
ports:
- "${POLYWEATHER_GRAFANA_PORT:-3001}:3000"
+3 -3
View File
@@ -235,8 +235,8 @@ curl -s http://127.0.0.1:8000/api/system/status | python3 -m json.tool
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
## 10. 开口径说明
## 10. AGPL 与公开口径说明
对外公开文档仅覆盖通用 API 契约生产商业策略参数不在公开文档披露。
本仓库代码自 `2026-03-30` 起采用 `AGPL-3.0-only`对外公开文档仅覆盖通用 API 契约生产商业策略参数、私有运营阈值与托管服务能力不在公开文档披露。
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
详见:[AGPL-3.0 与商用边界](OPEN_CORE_POLICY.md)
+5 -5
View File
@@ -41,14 +41,14 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
## 5. 建议的开源边界
## 5. 许可证与商用边界
请按 Open-Core 执行
当前仓库代码采用 `AGPL-3.0-only`
- 开:基础能力与通用支付流程。
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
- 开:基础能力与通用支付流程源码
- 不随代码许可证授权:商业风控、营销策略、关键运营参数、内部审计策略、品牌与托管服务资产
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
详见:[AGPL-3.0 与商用边界](OPEN_CORE_POLICY.md)
## 6. 上线检查清单(收费前)
+55 -3
View File
@@ -107,7 +107,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYMARKET_MARKET_SCAN_ENABLED`
- `POLYGON_WALLET_WATCH_ENABLED`
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`
### 4.3 L3:运行调优项
@@ -119,6 +121,12 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- 各类 `*_TIMEOUT_SEC`
- 各类 `*_COOLDOWN_SEC`
- 各类 `*_INTERVAL_SEC`
- `TELEGRAM_ALERT_MIN_TRIGGER_COUNT`
- `TELEGRAM_ALERT_MIN_SEVERITY`
- `TELEGRAM_ALERT_MISPRICING_ONLY`
- `TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS`
- `TAF_CACHE_TTL_SEC`
@@ -204,7 +212,7 @@ TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=dual
POLYWEATHER_STATE_STORAGE_MODE=sqlite
UID=1000
GID=1000
POLYWEATHER_AUTH_ENABLED=true
@@ -215,6 +223,17 @@ SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
TELEGRAM_ALERT_PUSH_ENABLED=true
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
```
说明:
@@ -223,8 +242,41 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
- Windows / macOS 一般可以直接保留默认值。
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。
- `POLYWEATHER_STATE_STORAGE_MODE` 当前推荐先用 `dual`,验证后再切 `sqlite`
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
### 6.3 机器人市场监控建议配置
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
- `关键提醒`:实时错价/触发条件满足时发送
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
推荐值:
```env
TELEGRAM_ALERT_PUSH_ENABLED=true
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
```
说明:
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮机会清单。
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
## 7. 当前建议的运维规则
+653
View File
@@ -0,0 +1,653 @@
# EMOS + LGBM 系统说明(中文)
本文档用于完整说明 PolyWeather 当前的两条统计/机器学习链路:
- `EMOS`:概率后处理与校准链路
- `LGBM`:日最高温点预测辅助模型
重点不只是“模型怎么训练”,还包括:
- 这些模型依赖什么历史数据
- 真值和训练特征现在如何长期保存
- 为什么过去样本一直不够
- 当前线上到底运行在哪个模式
- 现在能做什么,不能做什么
本文档基于仓库当前实现与最近一轮重建结果,适合作为:
- 项目内部模型说明
- 运维与数据治理说明
- 未来继续扩展 EMOS/LGBM 的基线文档
---
## 1. 总览
PolyWeather 当前不是“用一个模型替代所有东西”,而是多层结构:
1. 多源天气采集层
2. `DEB` 业务主预测层
3. `LGBM` 轻量点预测辅助层
4. `EMOS` 概率校准层
5. 市场概率/桶命中评估层
可以简化理解为:
```text
天气源 / 观测 / 历史真值
DEB 主预测
LGBM 辅助点预测
EMOS 对概率分布做后处理
市场概率 / shadow / rollout 门禁
```
其中:
- `DEB` 仍然是当前业务主路径
- `LGBM` 是辅助预测源,不是主路径
- `EMOS` 是概率后处理,不是基础天气模型
---
## 2. 两条链路各自负责什么
### 2.1 EMOS 负责什么
`EMOS` 的全称通常指 Ensemble Model Output Statistics。
在本项目里,它的角色不是重新预测温度,而是:
- 把已有的预测结果做概率后处理
- 让输出分布更“可校准”
- 让桶概率和市场评估更稳定
EMOS 关注的是:
- `raw_mu`
- `raw_sigma`
- `deb_prediction`
- `ens_median`
- `ensemble_spread`
- `max_so_far_gap`
- `peak_flag`
- 最终真实 `actual_high`
它最终输出的是一套“经过校准的概率分布”,而不是单一温度值。
所以 EMOS 的核心衡量指标不是单纯 MAE,而更看重:
- `CRPS`
- `bucket_hit_rate`
- `bucket_brier`
### 2.2 LGBM 负责什么
`LGBM` 是一个轻量级的回归模型,用来预测:
- `actual_high`(日最高温)
它吃的是:
- 历史真值 lag 特征
- 多模型 forecast
- `deb_prediction`
- 当前观测特征
- 时间特征
它输出的是:
- 一个点预测 `actual_high`
然后这个点预测可以作为:
- 额外 forecast 源
- 供 DEB / 运营 / 研究参考
所以它和 EMOS 的区别非常重要:
- `LGBM`:做点预测
- `EMOS`:做概率校准
---
## 3. 当前代码结构
### 3.1 EMOS 相关
核心文件:
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
- [probability_rollout.py](/E:/web/PolyWeather/src/analysis/probability_rollout.py)
- [fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
- [evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
- [build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
- [judge_probability_rollout.py](/E:/web/PolyWeather/scripts/judge_probability_rollout.py)
核心产物:
- [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
- [rollout_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/rollout_report.json)
- [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
### 3.2 LGBM 相关
核心文件:
- [lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
- [lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
- [train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
- [report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
核心产物:
- [lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
- [lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
---
## 4. 为什么之前样本总是上不去
这件事是理解当前状态的关键。
过去项目里有一个结构性问题:
- `daily_records_store` 同时承担了
- 运行态缓存
- 历史训练数据来源
但运行态层会把 `daily_records` 硬裁成最近 14 天。
这意味着:
- 对线上运行来说没问题
- 对训练来说,历史监督样本会不断被删掉
结果就是:
- 城市越来越多
- 训练历史反而越来越稀
- `LGBM` 很容易只有二十几条样本
- `EMOS` 也只能靠有限 snapshot/daily_record 拼起来
这不是“模型太差”,而是“数据主存设计不对”。
---
## 5. 这次历史真值治理做了什么
现在已经把“运行态缓存”和“长期训练主存”拆开了。
### 5.1 `daily_records_store`
继续保留,但只作为:
- 最近 14 天运行态缓存
它不再承担长期训练历史职责。
### 5.2 `truth_records_store`
新增永久真值表,作为长期训练真值主存。
当前核心字段包括:
- `city`
- `target_date`
- `actual_high`
- `settlement_source`
- `settlement_station_code`
- `settlement_station_label`
- `truth_version`
- `updated_by`
- `updated_at`
- `source_payload_json`
- `is_final`
这张表的意义是:
- 长期保存监督真值
- 不再被 14 天缓存裁剪
- 真值来源变得可追溯
### 5.3 `truth_revisions_store`
新增真值修订审计表。
它记录:
- 老值是什么
- 新值是什么
- 来源怎么变了
- 谁改的
- 为什么改
- 什么时候改
所以现在回填不会再是“静默覆盖”。
### 5.4 `training_feature_records_store`
新增长期训练特征表。
它长期留存:
- forecasts
- deb_prediction
- mu
- probability_features
- prob_snapshot
- shadow_prob_snapshot
- calibration 摘要
它的作用是:
- 从现在开始,不再继续丢失历史训练特征
- 让未来 EMOS/LGBM 样本自然累积
---
## 6. 训练数据现在怎么来
### 6.1 EMOS 训练样本
EMOS 训练不只是需要真值,还要有“当时那一刻的预测快照”。
所以一条 EMOS 样本,本质上需要两部分:
1. 历史预测特征
2. 对应日期最终真值
当前导出的 EMOS 样本里,核心字段包括:
- `city`
- `date`
- `actual_high`
- `raw_mu`
- `raw_sigma`
- `deb_prediction`
- `ens_median`
- `ensemble_spread`
- `max_so_far_gap`
- `peak_flag`
- `sample_source`
- `settlement_source`
- `settlement_station_code`
- `truth_version`
- `truth_updated_by`
- `truth_updated_at`
也就是说,EMOS 训练样本现在已经带了真值 provenance。
### 6.2 LGBM 训练样本
LGBM 训练样本会优先从:
1. 永久真值表取监督目标
2. 长期训练特征表取历史特征
3. 再回退到必要的运行态/快照补充
当前 LGBM 样本会用到:
- 历史 `actual_high` lag
- 历史均值/趋势
- 多模型 forecast
- `deb_prediction`
- 当前观测
- 时间特征
---
## 7. Wunderground 历史回填为什么重要
这次治理里一个重点是:
- `Taipei`
- `Shenzhen`
这两个城市已经切到了市场指定的 `Wunderground` 结算口径。
之前的问题是:
- 城市注册表已经写成 `wunderground`
- 但历史回填链路还没有真正支持按指定历史日期抓 WU 历史页
所以过去它们的 `actual_high` 可能:
- 没有被正确回填
- 或者被错误来源污染
现在已经补了正式历史回填函数:
- [wunderground_sources.py](/E:/web/PolyWeather/src/data_collection/wunderground_sources.py)
它会:
1. 按 `city + target_date` 拼出对应历史页
2. 解析该日观测序列
3. 取当日最高温
4. 按市场规则做整度结算
5. 写入永久真值表
6. 记录来源与审计信息
这一步对 `Taipei/Shenzhen` 尤其关键,因为它们不是 NOAA/HKO 口径。
---
## 8. 当前线上/离线运行模式
### 8.1 概率引擎模式
当前项目仍然应该保持:
- `emos_shadow`
而不是:
- `emos_primary`
原因不是工程没接好,而是门禁还没过。
### 8.2 LGBM 角色
当前 `LGBM` 仍然只能算:
- 辅助预测源
- 研究/观测链路
不适合替代 `DEB` 主路径。
---
## 9. 当前最新状态
以下状态来自最近一轮恢复、回填和重训产物。
### 9.1 永久真值
当前永久真值表已恢复到长期历史:
- `truth_records_store`
- 最早:`2023-01-01`
- 最晚:`2026-04-02`
- 行数:约 `35138`
- 城市数:`30`
运行态缓存仍然只有近 14 天:
- `daily_records_store`
- 仍然是近两周范围
这说明:
- 长期真值主存已经从运行态缓存里分离出来了
### 9.2 真值修订
当前已有 revision 审计记录:
- `truth_revisions_store`
- 行数:`2`
这说明审计链路已经在工作。
### 9.3 Wunderground 回填
`Taipei``Shenzhen` 已按 WU 历史页完成回填。
当前这两城已经补到:
- `2026-04-02`
### 9.4 长期训练特征
当前 `training_feature_records_store` 已经接通,但历史上真正留存下来的特征仍然很少。
这意味着:
- 从现在开始不会继续丢
- 但过去没留下的那部分特征,不会凭空恢复
这也是为什么:
- 真值恢复了
- `EMOS` 样本量却没有同步大幅增长
---
## 10. 当前 EMOS 结果怎么理解
最近一轮离线评估大致是:
- `sample_count = 54`
- `delta_crps ≈ -0.0867`
- `delta_mae = 0`
- `delta_bucket_hit_rate = 0`
这说明:
- 从 `CRPS` 看,EMOS 有改善
- 但从 `MAE``top bucket hit` 看,没有明显进步
shadow 报告里更关键的问题是:
- `shadow sample_count = 48`
- `delta_bucket_brier` 仍然明显偏坏
所以 rollout 结论仍然是:
- `hold`
这不是“EMOS 无效”,而是:
- 它还没有稳定到能切主路径
### 10.1 当前阻塞点
主要阻塞仍然是:
- 样本数不够
- shadow bucket brier 退化
也就是说,当前 EMOS 状态可以总结成:
- 工程链路完整
- 数据治理大幅改善
- 发布门禁仍未通过
---
## 11. 当前 LGBM 结果怎么理解
最近一轮 LGBM 训练后,样本数已经从以前更少的状态提升到:
- `sample_count = 54`
- `train_count = 42`
- `validation_count = 12`
验证集指标大致为:
- `lgbm_mae = 1.349`
- `deb_mae = 0.875`
这说明:
- LGBM 比以前样本更充足了
- 但在验证集上仍然不如 DEB
所以当前它的定位仍然应该是:
- 辅助参考
- 不替代 DEB
---
## 12. 为什么现在 EMOS 没有像 LGBM 那样明显涨样本
这点很容易误解。
答案不是“恢复失败”,而是两条链路对数据要求不一样。
### 12.1 LGBM
LGBM 更依赖:
- 长期真值
- 基础 forecast 特征
这部分通过:
- `truth_records_store`
- `training_feature_records_store`
已经改善很多。
### 12.2 EMOS
EMOS 更依赖:
- 某一时刻的概率快照/分布特征
如果过去那些 snapshot 没有长期保存下来,那么即使今天把真值补齐了:
- 也无法凭空重建完整 EMOS 样本
所以当前现实是:
- 真值问题已经大幅改善
- 未来特征不会再继续丢
- 但过去缺失的 EMOS 快照历史仍然限制样本增长
---
## 13. 当前最重要的工程判断
### 13.1 已经完成的
这些现在可以认为已经完成:
- 真值主存从运行态缓存里拆出
- 真值 provenance 落库
- revision 审计表落地
- Wunderground 历史回填接通
- `Taipei/Shenzhen` 真值口径修正
- 长期训练特征表接通
- `/ops` 已能可视化 truth / feature / EMOS / LGBM 覆盖情况
### 13.2 还没完成的
这些仍然是后续重点:
- EMOS 样本继续自然积累
- shadow bucket brier 稳定下来
- LGBM 验证效果超过 DEB
- 让更多城市开始持续积累训练特征
---
## 14. 运维怎么看当前状态
现在最直接的入口是:
- `/ops`
这页已经能看到:
- 历史真值主表统计
- 真值来源分布
- 真值修订数量
- 长期训练特征统计
- `Taipei/Shenzhen` 的 WU 回填状态
- 城市覆盖缺口
- 模型城市覆盖
- 城市覆盖矩阵
因此,运维现在可以快速回答:
- 哪些城市真值已经长期化
- 哪些城市还没有特征积累
- 哪些城市已经能支撑 EMOS/LGBM
- 哪些城市目前仍然只能主要依赖 DEB
---
## 15. 推荐工作流
### 15.1 日常
1. 查看 `/ops`
2. 看 `truth / feature / EMOS / LGBM` 覆盖有没有继续增长
3. 看 `Taipei/Shenzhen` 的 WU 行数是否继续更新
4. 看 rollout 仍然是 `hold` 还是有改善
### 15.2 周期性重训
建议周期性执行:
```bash
./venv/Scripts/python.exe scripts/export_probability_training_dataset.py
./venv/Scripts/python.exe scripts/fit_probability_calibration.py
./venv/Scripts/python.exe scripts/evaluate_probability_calibration.py
./venv/Scripts/python.exe scripts/build_probability_shadow_report.py
./venv/Scripts/python.exe scripts/judge_probability_rollout.py
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
```
### 15.3 真值恢复/补数
当有新的历史真值补数或回填需要时:
```bash
./venv/Scripts/python.exe scripts/restore_training_truth_history.py
./venv/Scripts/python.exe scripts/restore_training_feature_history.py
./venv/Scripts/python.exe scripts/backfill_recent_daily_actuals_from_metar.py --cities taipei shenzhen --lookback-days 14
```
说明:
- 脚本名里虽然还保留 `from_metar`
- 但当前实现已经会按 `settlement_source` 自动分发
- `wunderground` 会走 WU 历史回填分支
---
## 16. 当前最务实的结论
如果只用一句话概括当前状态:
**EMOS 和 LGBM 的工程基础已经补齐,但数据积累还在恢复期;当前最正确的策略仍然是继续以 `DEB` 为主路径,让长期真值和训练特征继续沉淀,再观察 EMOS/LGBM 是否自然变强。**
更具体一点:
- `EMOS`
- 已接好
- 可训练
- 可评估
- 可 shadow
- 但暂时不能切主路径
- `LGBM`
- 已接好
- 样本比以前更多
- 但验证集还不如 DEB
- 目前只能做辅助参考
- 数据层
- 这次治理的真正价值,是防止未来继续丢历史
- 这对两条模型链路都比继续“微调参数”更关键
---
## 17. 相关文档
若需要看更细分的历史说明,可继续参考:
- [EMOS_TRAINING_REPORT_ZH.md](/E:/web/PolyWeather/docs/EMOS_TRAINING_REPORT_ZH.md)
- [LGBM_DAILY_HIGH_ZH.md](/E:/web/PolyWeather/docs/LGBM_DAILY_HIGH_ZH.md)
- [PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](/E:/web/PolyWeather/docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
- [deep-research-report.md](/E:/web/PolyWeather/docs/deep-research-report.md)
+310
View File
@@ -0,0 +1,310 @@
# LightGBM 日最高温模型(中文)
## 1. 目标
这套 `LightGBM` 模型是给 PolyWeather 增加一个轻量级的统计学习预测源。
它的定位不是替代:
- `DEB`
- `EMOS`
- `ECMWF / GFS / GEM / JMA / ICON / Open-Meteo / MGM / NWS`
而是作为一个新的点预测源:
`现有模型 + 观测特征 -> LGBM -> 并入 current_forecasts -> DEB -> EMOS`
第一版只做:
- `D0` 当日最高温预测
不做:
- `D1-D3`
- 小时级曲线
- 概率分布
- 独立结算源
## 2. 适用场景
这条链路是为低资源 VPS 准备的。
当前项目线上环境只有 `2GB RAM` 时,不适合引入 `TimesFM` 这类大模型,但适合用 `LightGBM` 做轻量推理。
当前方案是:
1. 训练离线完成
2. 训练产物直接提交到仓库
3. VPS 线上只加载模型文件并推理
4. VPS 不训练,不起额外服务
## 3. 文件结构
核心文件如下:
- 运行时推理:
- [src/models/lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
- 特征构建:
- [src/models/lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
- 训练脚本:
- [scripts/train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
- 训练报告脚本:
- [scripts/report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
- 模型文件:
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
- 模型 schema / 指标:
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
接入链路位置:
- Web API 聚合:
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
- 共享趋势引擎:
- [src/analysis/trend_engine.py](/E:/web/PolyWeather/src/analysis/trend_engine.py)
## 4. 特征说明
第一版特征固定为以下几组。
### 4.1 历史日高温特征
- `actual_high_lag_1`
- `actual_high_lag_2`
- `actual_high_lag_3`
- `actual_high_lag_7`
- `actual_high_mean_7`
- `actual_high_mean_14`
- `actual_high_trend_3`
### 4.2 当天模型特征
- `Open-Meteo`
- `ECMWF`
- `GFS`
- `GEM`
- `JMA`
- `ICON`
- `MGM`
- `NWS`
- `deb_prediction`
- `model_median`
- `model_spread`
### 4.3 当前观测特征
- `current_temp`
- `max_so_far`
- `humidity`
- `wind_speed_kt`
- `visibility_mi`
### 4.4 时间与状态特征
- `local_hour`
- `month`
- `weekday`
- `peak_status_code`
其中:
- `before = 0`
- `in_window = 1`
- `past = 2`
## 5. 训练数据来源
训练数据主要来自两份运行时历史文件:
- [data/daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
- [data/probability_training_snapshots.jsonl](/E:/web/PolyWeather/data/probability_training_snapshots.jsonl)
作用分工:
- `daily_records.json`
- 提供 `actual_high`
- 提供当天各模型 forecast
- 提供历史 `deb_prediction`
- `probability_training_snapshots.jsonl`
- 提供 `max_so_far`
- 提供 `peak_status`
- 提供观测特征快照
为后续重训,概率快照归档现在还会额外写入:
- `current_temp`
- `humidity`
- `wind_speed_kt`
- `visibility_mi`
- `local_hour`
对应代码:
- [src/analysis/probability_snapshot_archive.py](/E:/web/PolyWeather/src/analysis/probability_snapshot_archive.py)
## 6. 训练流程
训练脚本:
```bash
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
```
训练流程如下:
1. 从历史文件构造监督样本
2. 目标值固定为 `actual_high`
3. 按日期做简单的时间顺序切分
4. 最后约 20% 做验证集
5. 先训练并评估验证集
6. 再用全量样本训练最终模型
7. 输出模型文件和 schema 文件
输出产物:
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
## 7. 如何看训练结果
查看训练报告:
```bash
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
```
这个脚本会读取 schema,并打印:
- `Sample Count`
- `Train Count`
- `Valid Count`
- `LGBM MAE`
- `DEB MAE`
- `Best Single MAE`
- `Median MAE`
- `Winner`
当前这版训练结果是:
- `sample_count = 29`
- `validation_count = 12`
- `validation.lgbm_mae = 2.975`
- `validation.deb_mae = 2.267`
- `validation.best_single_mae = 1.167`
这说明:
- 当前 `LGBM` 链路已经可用
- 但现阶段验证集表现还没有超过 `DEB`
- 所以默认配置仍建议保持关闭
## 8. 线上运行逻辑
运行时推理逻辑不是“直接替代 DEB”,而是:
1. 先收集现有模型 forecast
2. 先算一版基线 `DEB`
3. 把这版 `DEB` 当作 `LGBM` 的一个输入特征
4. 输出 `LGBM` 点预测
5. 把 `LGBM` 注入 `current_forecasts`
6. 重新计算最终 `DEB`
这样做的原因是:
- `LGBM` 需要吃到 `deb_prediction` 特征
- 但最终 `DEB` 又要把 `LGBM` 当成一个新的输入模型
## 9. 环境变量
示例配置见:
- [.env.example](/E:/web/PolyWeather/.env.example)
相关变量:
```env
POLYWEATHER_LGBM_ENABLED=false
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
```
说明:
- `POLYWEATHER_LGBM_ENABLED`
- 是否启用运行时推理
- `POLYWEATHER_LGBM_MODEL_PATH`
- 模型文件路径
- `POLYWEATHER_LGBM_SCHEMA_PATH`
- schema 文件路径
- `POLYWEATHER_LGBM_MIN_HISTORY_POINTS`
- 某城市最低历史样本门槛
默认是 `3`,原因不是最理想,而是当前整体样本仍然偏少。
如果门槛设太高,很多城市现在根本不会触发 `LGBM`
## 10. VPS 部署建议
如果你的 VPS 只有 `2GB RAM`
- 可以跑这套 `LightGBM`
- 不要在 VPS 上训练
- 不要起额外模型服务
推荐方式:
1. 在本地或开发环境训练
2. 提交模型产物
3. VPS 拉代码
4. 开启 `POLYWEATHER_LGBM_ENABLED=true`
5. 重启主服务
不推荐:
- 在 VPS 上跑训练脚本
- 把 `LightGBM` 当成长任务服务单独部署
- 同时引入大模型推理
## 11. 当前结论
这条链路已经完成了:
- 离线训练
- 模型产物固化
- 运行时懒加载
- Web / 共享分析链路注入
- 前端模型类型兼容
但当前样本量仍偏少,所以建议运营策略是:
1. 先继续积累历史 `actual_high`
2. 继续积累概率快照观测字段
3. 定期重训
4. 只有当验证集 `MAE` 持续接近或优于 `DEB` 时,再考虑默认线上开启
## 12. 常用命令
### 训练
```bash
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
```
### 查看训练报告
```bash
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
```
### 本地测试
```bash
./venv/Scripts/python.exe -m pytest tests/test_lgbm_features.py tests/test_lgbm_daily_high.py
```
### 编译检查
```bash
./venv/Scripts/python.exe -m compileall src web scripts tests
```
+123
View File
@@ -0,0 +1,123 @@
# 外部监控与告警说明
最后更新:`2026-04-01`
## 1. 目标
在现有轻量可观测性基础上,把 PolyWeather 补成最小可用的外部监控链路:
- Prometheus 抓取 `/metrics`
- Alertmanager 根据规则聚合告警
- Relay 把告警推到运营频道
- Grafana 展示趋势面板
- 巡检脚本补健康检查
## 2. 组件
本仓库现在内置 4 个监控组件:
- `polyweather_prometheus`
- `polyweather_alertmanager`
- `polyweather_alert_relay`
- `polyweather_grafana`
对应配置目录:
- [monitoring/prometheus/prometheus.yml](../monitoring/prometheus/prometheus.yml)
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
- [monitoring/alertmanager/alertmanager.yml](../monitoring/alertmanager/alertmanager.yml)
- [monitoring/grafana/dashboards/polyweather-overview.json](../monitoring/grafana/dashboards/polyweather-overview.json)
## 3. 启动
```bash
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
```
默认端口:
- Prometheus: `9090`
- Alertmanager: `9093`
- Grafana: `3001`
- Alert relay: `9099`
## 4. 环境变量
在 [.env.example](../.env.example) 里新增了这些配置:
```env
POLYWEATHER_PROMETHEUS_PORT=9090
POLYWEATHER_ALERTMANAGER_PORT=9093
POLYWEATHER_ALERT_RELAY_PORT=9099
POLYWEATHER_GRAFANA_PORT=3001
POLYWEATHER_GRAFANA_ADMIN_USER=admin
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
```
说明:
- `POLYWEATHER_MONITORING_ALERT_CHAT_IDS` 为空时,relay 会自动回退到:
- `TELEGRAM_CHAT_IDS`
- `TELEGRAM_CHAT_ID`
- 告警发送仍复用现有 `TELEGRAM_BOT_TOKEN`
## 5. 当前告警规则
当前默认规则:
- `PolyWeatherWebDown`
- `PolyWeatherHttp5xxBurst`
- `PolyWeatherHighSourceErrorRate`
- `PolyWeatherOpenMeteoCooldownLoop`
- `PolyWeatherSlowHttpAverage`
规则文件:
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
## 6. 当前 Grafana 面板
预置了一个最小仪表板:
- `PolyWeather Overview`
包含这些图:
- HTTP Requests by Status
- HTTP Latency
- Source Requests by Outcome
- Source Error Rate (15m)
## 7. 巡检脚本
手动巡检:
```bash
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
```
这个脚本会检查:
- `/healthz`
- `/api/system/status`
- `/metrics`
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
## 8. 备注
这套监控现在已经具备:
- 外部抓取
- 告警规则
- Telegram 推送
- 趋势面板
- 巡检脚本
但它仍是“最小可用版”,还没有覆盖:
- 节点级 CPU / 内存 / 磁盘
- 数据库体积趋势
- 更细粒度支付指标
- 按城市/来源拆分的业务 SLA
+30 -48
View File
@@ -1,63 +1,45 @@
# Open-Core 与商用边界
# AGPL-3.0 与商用边界
最后更新:`2026-03-14`
最后更新:`2026-03-30`
## 1. 目标
## 1. 当前许可证
在保持社区可用性的前提下,保护商业化阶段的核心经营资产
- 本仓库代码自 `2026-03-30` 起采用 **GNU Affero General Public License v3.0 only**`AGPL-3.0-only`
- 该许可证适用于仓库中未另行声明许可证的源代码与文档。
- 如果你修改本项目并通过网络向用户提供服务,需按 AGPL 第 13 条向用户提供对应源码。
## 2. 仓库公开范围(可开源)
## 2. 旧版本说明
- 天气数据采集与标准化(METAR / Open-Meteo / MGM 接口层)
- DEB 与基础趋势分析、概率桶计算
- Dashboard 基础体验与 API/BFF 结构。
- Telegram Bot 基础命令与基础积分机制。
- 合约支付标准流程(钱包绑定、intent、提交、确认、补单)。
- 在本次切换前已经发布的 MIT 版本,仍按其原始许可证生效
- 本次变更不会追溯撤销既往已发布版本的 MIT 授权
## 3. 生产私有范围(建议不公开)
## 3. 仓库公开范围
- 商业风控参数与规则库:
- 错价信号阈值组合、推送阈值、异常检测策略
- 运营策略资产:
- 用户分层规则、促销规则、留存策略、活动模板。
- 付费系统敏感细节:
- 实时对账容错阈值、退款审计策略、内部财务映射规则。
- 私有运维资产:
- 生产告警路由、内部频道映射、应急脚本与排障手册。
- 天气数据采集与标准化(METAR / Open-Meteo / MGM / 官方结算源接口层)。
- DEB、基础趋势分析、概率桶、历史对账、前端看板与 Bot 基础能力
- 标准支付流程、链上收款合约与公开 API/BFF 结构。
## 4. 配置与数据安全红线
## 4. 不在仓库许可证授权范围内的资产
- 不提交:`.env`、私钥、API key、机器人 token
- 不提交:生产数据库、运行时状态文件、支付流水快照
- 不提交:用户身份信息、钱包映射、订阅原始审计日志
- 商标、品牌名、域名、Logo、商店素材与市场宣传文案
- 生产数据库、用户资料、钱包映射、订阅审计日志、内部报表
- 私有运营脚本、增长工具、内部风控参数、收费策略细节与内部阈值
- 托管服务本身、SLA、客服、运维值守与内部告警路由。
## 5. 推荐发布模式
## 5. 配置与数据安全红线
### 5.1 Community Edition(开源)
- 不提交:`.env`、私钥、API key、机器人 token、第三方 service role key。
- 不提交:生产数据库、运行态快照、支付流水快照、用户身份信息。
- 不提交:仅用于线上商业判断的私有规则库与内部操作手册。
- 提供基础分析与基础看板。
- 可选保留只读市场扫描。
- 默认关闭商业化运营规则。
## 6. 对部署者的要求
### 5.2 Production Edition(私有)
- 若你提供公开网络服务,应在产品界面中提供清晰可访问的源码入口。
- 若你修改了本项目再对外提供网络服务,应公开与你实际运行版本对应的源码。
- 若你使用了仓库外的私有数据、商标或运营资产,这些额外资产不因 AGPL 自动获得授权。
- 启用收费、订阅、积分抵扣、风控、私有监控。
- 仅在私有仓库维护运营策略与敏感参数。
## 7. 法务与运营建议
## 6. 文档口径规范
对外文档仅描述:
- 能力边界与使用方式。
- 可公开的技术架构。
- 不包含可被直接复刻的商业参数。
不对外文档描述:
- 具体策略阈值、用户分层细则、收益归因规则。
## 7. 许可证与法务建议(简版)
- 建议保持仓库代码许可证与商标/品牌授权分离。
- 若提供商业服务,建议在官网补充服务条款与隐私政策。
- 对“订阅权益”与“可用性”做明确 SLA 与免责边界。
- 代码许可证与商标/品牌授权应继续分离管理。
- 官网应补充服务条款、隐私政策与付费权益说明。
- 若后续接受外部贡献,再次调整许可证前应先确认贡献者版权归属与再许可条件。
+6 -1
View File
@@ -1,6 +1,6 @@
# Ops 运营后台说明
最后更新:`2026-03-21`
最后更新:`2026-04-01`
## 1. 入口
@@ -28,6 +28,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
- 当前会员
- 周榜
- 支付异常单
- 漏斗转化面板
### 写能力
@@ -102,3 +103,7 @@ python scripts/reconcile_subscription_by_email.py --email <user_email>
- 让会员、积分、支付事故、系统状态可查
- 让常见人工操作不必再直接写 SQL
外部监控与告警栈说明见:
- [MONITORING_ZH.md](./MONITORING_ZH.md)
+6 -9
View File
@@ -1,6 +1,6 @@
# 技术债与工程待办(v1.5.1
最后更新:`2026-03-20`
最后更新:`2026-03-31`
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
@@ -29,8 +29,7 @@ flowchart TD
end
subgraph S["状态与概率"]
S1["SQLite dual -> sqlite 切换验收"]
S2["EMOS shadow -> primary 门禁稳定化"]
S1["EMOS shadow -> primary 门禁稳定化"]
end
A --> P
@@ -48,7 +47,7 @@ flowchart TD
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存已支持 SQLite 渐进迁移
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已上线 shadow 模式。
@@ -56,7 +55,6 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
@@ -78,7 +76,6 @@ flowchart TD
## 6. 下阶段里程碑
1. 完成 SQLite 从 `dual``sqlite` 的主读切换
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
3. 补外部监控抓取与告警阈值
4. 评估并推进支付合约 V2 升级。
1. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
2. 补外部监控抓取与告警阈值
3. 评估并推进支付合约 V2 升级
+6 -9
View File
@@ -1,6 +1,6 @@
# 技术债与工程待办(v1.5.1
最后更新:`2026-03-20`
最后更新:`2026-03-31`
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
@@ -29,8 +29,7 @@ flowchart TD
end
subgraph S["状态与概率"]
S1["SQLite dual -> sqlite 切换验收"]
S2["EMOS shadow -> primary 门禁稳定化"]
S1["EMOS shadow -> primary 门禁稳定化"]
end
A --> P
@@ -48,7 +47,7 @@ flowchart TD
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存已支持 SQLite 渐进迁移
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已上线 shadow 模式。
@@ -56,7 +55,6 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
@@ -78,7 +76,6 @@ flowchart TD
## 6. 下阶段里程碑
1. 完成 SQLite 从 `dual``sqlite` 的主读切换
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
3. 补外部监控抓取与告警阈值
4. 评估并推进支付合约 V2 升级。
1. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
2. 补外部监控抓取与告警阈值
3. 评估并推进支付合约 V2 升级
+27 -23
View File
@@ -2,23 +2,23 @@
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等
从工程实现看,截至 `2026-03-21`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存 SQLite 的渐进迁移,以及基础可观测性接口`/healthz``/api/system/status``/metrics`
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py``web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段。
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已收敛为三类:第一,**SQLite 迁移仍处于推荐的 dual 过渡模式**,线上真正切主读路径前仍需跑一段时间验证;第二,**可观测性只完成了轻量级指标层**,还没有形成完整的外部监控、阈值告警与趋势面板;第三,**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单、按邮箱恢复脚本都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**迁移验收、可观测性补全、EMOS 上线门禁稳定化**这条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外
从工程实现看,截至 `2026-04-03`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存迁移到 SQLite 主路径,以及最小外部监控链路`/healthz``/api/system/status``/metrics` + Prometheus + Alertmanager + Grafana + Telegram relay)。除此之外,项目还补上了**历史真值治理**:`daily_records` 继续只保留近 14 天运行态缓存,但新增了永久真值表、真值 revision 审计表和长期训练特征表,并开始把监督真值与训练特征从“短期缓存”正式拆到“长期可追溯存储”
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py``web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段;更重要的是,监督真值与训练特征不再只能附着在 14 天运行态缓存上
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已进一步收敛:**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化,同时历史长期特征仍处在“刚开始积累”的阶段。SQLite 迁移方面,运行态主读切换和核心离线训练/回填链路已经完成验收:在移除 `data/*.json` / `data/*.jsonl` 后,训练、评估、shadow report 与关键 backfill 脚本仍可仅依赖运行时数据库正常执行;当前保留的 legacy 文件路径主要用于迁移、导出、校验和显式回退输入。历史真值治理方面,新增的永久真值表、revision 审计表与长期训练特征表已经落地,`Taipei` / `Shenzhen``Wunderground` 历史回填也已接通,因此当前缺口已从“历史真值是否会继续丢失”转为“历史特征是否能持续增长并支撑 EMOS/LGBM 评估”。可观测性方面,最小外部监控链路已经补齐:Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram 告警 relay 与巡检脚本均已落地;当前剩余缺口已从“有没有外部监控”转为“监控覆盖深度是否足够”,例如节点级资源、数据库体积趋势、支付细粒度指标、按城市/来源拆分的业务 SLA。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**EMOS 上线门禁稳定化、长期训练特征持续积累、监控覆盖深挖、支付合约防护升级**这条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为四层:
**天气层(数据源/采集)**:聚合 20 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 CWA)等。
**天气层(数据源/采集)**:聚合 39 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 RCSS/Wunderground、深圳 ZGSZ/Wunderground)等。
**分析层(DEB/趋势/概率/结算口径)**:
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;同时维护 `daily_records.json` 做历史对账、命中率/MAE 统计,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB`py-clob-client` 或 REST 回退)读取价格/盘口并计算 edge(模型概率 − 市场概率)生成信号标签。
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 为 MIT。 同时 README 强调 Open-Core 策略与生产私有组件边界,意味着“可复现/可审计”的范围以公开部分为准
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
![PolyWeather demo map](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/docs/images/demo_map.png)
@@ -37,7 +37,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
| Python 域模块 | `src/auth/*``docs/SUPABASE_SETUP_ZH.md``scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态与缓存仓储 | 已接入 `daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 持久缓存。 |
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态、永久真值与训练特征仓储 | 已接入 `daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 持久缓存,并新增永久真值表、真值修订审计表、长期训练特征表。 |
| 工程与运维 | `docker-compose.yml``Dockerfile``.github/workflows/ci.yml``scripts/*` | 部署/验证脚本 | 现已具备 CI 门禁、迁移脚本、状态校验脚本、配置校验脚本与 rollout 报告脚本。 |
### 参考架构与关键工作流
@@ -58,7 +58,8 @@ flowchart TB
WX[WeatherDataCollector]
CITY[CITY_REGISTRY]
HIST[(SQLite runtime state<br/>daily_records / cache / snapshots)]
JSON[Legacy JSON files<br/>dual-mode fallback]
TRUTH[(SQLite truth tables<br/>truth_records / revisions / features)]
JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
end
subgraph ExternalAPIs
@@ -102,8 +103,10 @@ flowchart TB
RPC --> SOL
WX --> HIST
WX --> TRUTH
WX --> JSON
FAST --> HIST
FAST --> TRUTH
WX --> CITY
```
@@ -122,7 +125,7 @@ flowchart TB
**推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
aSQLite 运行态存储(推荐主路径);(blegacy JSON/JSONL 文件(迁移期回退路径)。当前设计 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`建议先以 `dual` 运行,再切到 `sqlite`
aSQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`
### 测试、CI/CD 与运维验证
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。
@@ -142,12 +145,13 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**核心文件过大问题已明显缓解,但边界仍需继续稳定**:`WeatherDataCollector``web/app.py` 的超大文件问题已完成第一阶段拆分;当前风险已从“文件过大”转为“跨模块兼容与边界稳定性”,例如旧调用路径、兼容导出、跨层 helper 仍需持续清理。
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example``.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
**运行态状态/缓存迁移仍处于过渡期**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持 SQLite并有迁移/校验脚本;但在正式切到 `sqlite` 主读路径前,仍需经历一段 dual 双写验证期。这是当前最需要谨慎处理的工程性风险之一
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入
**历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了 `Wunderground` 历史回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。
**可观测性已起步,但仍不构成完整监控体系**:项目现在已有 `/healthz``/api/system/status``/metrics`,并为 HTTP 与关键第三方源增加了轻量指标;但仍缺少 Prometheus/Grafana 级别的外部抓取、告警阈值、趋势面板和运日报。这部分现在属于“已开始,不算完成”。
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz``/api/system/status``/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势和运日报。
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
**许可证/商业使用的潜在冲突点**:仓库自身是 MIT,但如果未来尝试引入外部 AI 预报模型,需要非常谨慎GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部 AI 预报模型,仍需单独核验第三方代码与权重的商用条件GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
## 对标分析
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
@@ -158,7 +162,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;覆盖 20 城市。 | 主要依赖外部 APIDocker Compose 一键启动。 | 仓库 MITOpen-Core(部分生产规则私有)。 |
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;当前覆盖 39 城市,并已补齐真值治理与后台运维视图。 | 主要依赖外部 APIDocker Compose 一键启动。 | 仓库 `AGPL-3.0-only`;品牌、生产私有数据与运营规则不随代码许可证授权。 |
| **GraphCast**google-deepmind/graphcast | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
| **FourCastNet**NVlabs/FourCastNet | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
| **Pangu-Weather**198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
@@ -169,12 +173,12 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**对标结论**PolyWeather 与这类“全球 AI 预报模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
## 优先级改进建议
下表按截至 `2026-03-21` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
下表按截至 `2026-04-03` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 高 | **完成 SQLite 迁移切换与验收**:从 `dual` 过渡到 `sqlite` 主读路径 | 25 天 | 真正关闭 JSON/JSONL 并发一致性风险;状态/缓存统一入库 | 迁移校验不充分会导致线上行为漂移 | 1) 线上部署新代码 → 2) 执行迁移与校验脚本 → 3) `dual` 运行至少 24–48 小时 → 4) 校验 `/api/history`、bot 告警、snapshot、缓存都正常 → 5) 再切 `POLYWEATHER_STATE_STORAGE_MODE=sqlite` |
| 高 | **把轻量可观测性接入外部监控与告警**:围绕 `/metrics` 建立抓取、阈值与巡检 | 3–7 天 | 不再只靠日志定位问题;可以监控第三方源错误率、缓存命中与 HTTP 延迟 | 指标不分层会导致噪音高、告警无用 | 1) 抓取 `/metrics` → 2) 先围绕 HTTP、Open-Meteo、MGM、METAR 建立最小仪表板 → 3) 为 429/403/error/stale_cache 设阈值 → 4) 增加巡检脚本或告警通道 |
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
| 高 | **持续积累长期训练特征,验证 SQLite 真值治理后的样本增长** | 12 周 | 让 EMOS/LGBM 的重训真正建立在长期可信样本上,而不是继续被短期特征缺口卡住 | 当前真值已长期化,但历史长期特征仍偏少,EMOS/LGBM 样本增长会滞后 | 1) 持续写入 `training_feature_records_store` → 2) 每日检查 `/ops` 训练数据与 `/ops/truth-history` → 3) 定期对 `Taipei` / `Shenzhen` 的 Wunderground 回填做抽查 → 4) 观察样本是否自然增长后再重训 |
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
@@ -193,8 +197,8 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
### 气象预测与概率校准基准
**数据集**(建议从现有生产数据演进)
1`daily_records.json` 的历史快照:已包含多模型预报、`actual_high``deb_prediction``mu` 与概率快照字段,天然可转成评测数据(建议迁移到 DB 后做版本化导出)
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港/台北等按结算源(HKO/CWA作为真值,和项目当前逻辑一致。
1`truth_records_store + training_feature_records_store` 的长期样本:当前长期评测主源应优先来自永久真值表与长期训练特征表;legacy 的 `daily_records.json``settlement_history.json` 更适合作为迁移恢复与对照来源,而不是长期主输入
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港按 HKO、对台北/深圳按 `Wunderground RCSS/ZGSZ` 等结算源作为真值,和项目当前逻辑一致。
**指标**
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
@@ -210,7 +214,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
**算力**:以上评测全部可在 CPU 上完成;数据量按“20 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
**算力**:以上评测全部可在 CPU 上完成;数据量按“39 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
### 错价信号与市场有效性基准
**数据集**
@@ -234,7 +238,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1–2 周 | 工程地基:CI + 规范 + 配置可复现 | GitHub Actionsruff/eslintpytest 可一键跑;`.env.example`;敏感项分级说明(尤其 Supabase service role key 不可暴露)。 | 后端为主;前端补 eslint/typecheck |
| 第 3–5 周 | 核心模块解耦:采集 Provider 化 + API 分层 | provider 接口与实现;`web/app.py` 拆分路由与服务;核心 schema(Pydantic) | 风险:行为漂移;用回放测试压住 |
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 迁移 DB;指标(请求量/429/延迟/命中率);告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 主读 SQLite;离线脚本也切到 SQLite 优先;永久真值表 / revision / 长期训练特征表落地;指标(请求量/429/延迟/命中率);Prometheus/Alertmanager/Grafana 最小链路与告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
| 第 9–10 周 | 评测体系上线 | 离线评测脚本(MAE/RMSE/WU-hit/Brier/CRPS);日报/周报自动生成 | 直接基于项目现有字段扩展 |
| 第 11–12 周 | 概率引擎升级(可选)+ 市场层健壮性增强 | EMOS/CRPS 拟合的 shadow 输出;Gamma/CLOB 客户端增强(async、重试、分页) | 以“小步可回滚”为原则,避免一次性替换 |
@@ -244,7 +248,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
**引入外部 AI 预报模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
## 参考链接
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
+28 -22
View File
@@ -1,27 +1,32 @@
# PolyWeather 侧边栏插件(MVP
# PolyWeather Side Panel
这是一个 Chrome / Edge 侧边栏扩展的 MVP,用于把 PolyWeather 右侧城市卡片移植到浏览器侧边栏
`PolyWeather Side Panel` 是一个面向天气交易场景的 Chrome / Edge 浏览器侧边栏工具
## 功能
- 侧边栏展示:
- 城市选择
- 风险徽章
- 城市档案(结算源 / 距离 / 观测更新时间 / 周边站点)
- 今日日内走势(简版 Canvas)
- 多日预报(`DEB` 优先)
- 基础判断卡(方向 / 置信度 / 原因)
- 快捷按钮:
- 今日日内分析
- 历史对账
- 打开网站查看更多
- 自动识别城市:
- 监听当前激活标签页 URL(例如 Polymarket `.../event/highest-temperature-in-ankara-...`
- 自动将侧边栏城市切换为 URL 对应城市
- 设置页可配置:
- 网站基础地址
- API 基础地址
- Bearer Token(可选)
1. 自动识别当前 Polymarket 页面中的城市,也支持手动切换。
2. 展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。
3. 展示今日日内走势(简版):`DEB` 走势与官方观测(`METAR / HKO / CWA / NOAA`)对照,可悬停查看时间与温度。
4. 展示多日最高温预报(简版),当前以 `DEB` 优先。
5. 支持一键刷新,强制拉取最新温度数据。
6. 支持本地缓存,提升打开速度;刷新时自动更新缓存。
7. 支持一键跳转到完整网站分析页面。
## 数据说明
- 香港使用 `HKO`(香港天文台)结算源。
- 其他城市按配置使用 `METAR / NOAA / 官方数据源`
- 城市展示名以主站返回值为准,例如 `aurora` 市场在插件中会显示为 `Denver`
## 权限说明
- `tabs`:用于识别当前活动标签页 URL 并自动匹配城市。
- `storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。
- `sidePanel`:用于在浏览器侧边栏展示界面。
## 隐私说明
本扩展不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据以完成展示功能。
## 本地安装(开发者模式)
@@ -43,7 +48,8 @@
## 说明
- 当前版本仍是轻量 MVP,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
- 当前版本仍是轻量产品,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
- 台北现在按 `NOAA RCTP` 结算参考展示
- 插件走势图与主站保持一致:`Wunderground` 结算城市不再单独绘制结算参考线,统一显示机场 `METAR` / 官方观测点位
- 点击“打开网站查看更多”会回到主站继续查看完整分析。
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "PolyWeather Side Panel",
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
"version": "0.1.5",
"description": "Weather side panel for Polymarket.",
"version": "0.1.9",
"icons": {
"16": "icon-16.png",
"32": "icon-32.png",
+62 -14
View File
@@ -4,7 +4,7 @@ const DEFAULT_CONFIG = {
selectedCity: "",
siteBase: "https://polyweather-pro.vercel.app"
};
const CACHE_VERSION = "v1";
const CACHE_VERSION = "v2";
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
? "zh"
: "en";
@@ -20,7 +20,8 @@ const I18N = {
settlementAirport: "结算机场",
hko: "香港天文台 (HKO)",
cwa: "交通部中央气象署 (CWA)",
noaa: "NOAA RCTP(台湾桃园国际机场)",
noaa: "NOAA 官方时序",
wunderground: "Wunderground 结算站",
city: "城市",
refresh: "刷新数据",
cityProfile: "城市档案",
@@ -38,9 +39,9 @@ const I18N = {
nearbyMonitoringSuffix: "个参与监控",
today: "今天",
omSeries: "OM预测",
noaaSettlementRef: "NOAA RCTP 结算参考",
noaaSettlementRef: "NOAA 结算参考",
noaaSettlementLegend:
"台北按 NOAA RCTP 最终完成质控后的最高整度摄氏值结算;图中曲线仅作结算参考。",
"该城市按 NOAA 最终完成质控后的最高整度读数结算;图中曲线仅作结算参考。",
loadCityDetailFailed: "加载城市详情失败",
refreshFailed: "刷新温度数据失败",
initFailed: "初始化失败",
@@ -81,7 +82,8 @@ const I18N = {
settlementAirport: "Settlement Airport",
hko: "Hong Kong Observatory (HKO)",
cwa: "Central Weather Administration (CWA)",
noaa: "NOAA RCTP (Taiwan Taoyuan International Airport)",
noaa: "NOAA official timeseries",
wunderground: "Wunderground settlement station",
city: "City",
refresh: "Refresh data",
cityProfile: "City Profile",
@@ -99,9 +101,9 @@ const I18N = {
nearbyMonitoringSuffix: " stations monitored",
today: "Today",
omSeries: "OM Forecast",
noaaSettlementRef: "NOAA RCTP Settlement Reference",
noaaSettlementRef: "NOAA Settlement Reference",
noaaSettlementLegend:
"Taipei settles on NOAA RCTP using the finalized highest rounded whole-degree Celsius reading; the plotted line is only a settlement reference.",
"This city settles on NOAA using the finalized highest rounded reading; the plotted line is only a settlement reference.",
loadCityDetailFailed: "Failed to load city detail",
refreshFailed: "Failed to refresh weather data",
initFailed: "Initialization failed",
@@ -254,6 +256,37 @@ function getCityAliasTokens(rawCityName) {
aliases.add("buenos-aires");
aliases.add("buenosaires");
}
if (normalized === "aurora") {
aliases.add("denver");
aliases.add("denver-co");
aliases.add("buckley");
aliases.add("kbkf");
}
if (normalized === "los angeles") {
aliases.add("los-angeles");
aliases.add("lax");
aliases.add("klax");
}
if (normalized === "san francisco") {
aliases.add("san-francisco");
aliases.add("sfo");
aliases.add("ksfo");
}
if (normalized === "austin") {
aliases.add("aus");
aliases.add("kaus");
}
if (normalized === "houston") {
aliases.add("hou");
aliases.add("hobby");
aliases.add("khou");
}
if (normalized === "mexico city") {
aliases.add("mexicocity");
aliases.add("ciudad-de-mexico");
aliases.add("cdmx");
aliases.add("mmmx");
}
return [...aliases].filter((item) => item && item.length >= 2);
}
@@ -440,6 +473,7 @@ function riskText(level) {
function getSettlementSourceDisplay(detail) {
const source = String(detail?.current?.settlement_source || "").toLowerCase();
const sourceLabel = String(detail?.current?.settlement_source_label || "").trim();
if (source === "hko") {
return {
label: t("settlementSource"),
@@ -458,6 +492,14 @@ function getSettlementSourceDisplay(detail) {
value: t("noaa")
};
}
if (source === "wunderground") {
const stationLabel = sourceLabel || t("wunderground");
const station = String(detail?.current?.station_code || detail?.risk?.icao || "").trim();
return {
label: t("settlementSource"),
value: station ? `${stationLabel} (${station})` : stationLabel
};
}
const airport = detail?.risk?.airport || "--";
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
return {
@@ -530,7 +572,9 @@ function parseTimeToMinute(value) {
}
function getObservationRows(detail) {
const obsSource = Array.isArray(detail?.settlement_today_obs) && detail.settlement_today_obs.length
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
const useSettlementSource = sourceCode && sourceCode !== "wunderground";
const obsSource = useSettlementSource && Array.isArray(detail?.settlement_today_obs) && detail.settlement_today_obs.length
? detail.settlement_today_obs
: Array.isArray(detail?.metar_today_obs)
? detail.metar_today_obs
@@ -956,6 +1000,7 @@ function renderDetail(detail) {
state.detail = detail;
renderRiskBadge(detail);
renderFreshness(detail);
const tempSymbol = detail?.temp_symbol || "°C";
const profile = getSettlementSourceDisplay(detail);
els.settlementLabel.textContent = profile.label;
@@ -983,8 +1028,8 @@ function renderDetail(detail) {
const last = obs[obs.length - 1];
els.chartLegend.textContent =
sourceCode === "noaa"
? `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time} | ${t("noaaSettlementLegend")}`
: `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time}`;
? `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time} | ${t("noaaSettlementLegend")}`
: `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time}`;
} else {
els.chartLegend.textContent =
sourceCode === "noaa"
@@ -1148,11 +1193,14 @@ async function loadCities(options = {}) {
}
}
function getActiveTabUrl() {
function getActiveTabInfo() {
return new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const first = Array.isArray(tabs) && tabs.length ? tabs[0] : null;
resolve(String(first?.url || ""));
resolve({
url: String(first?.url || ""),
title: String(first?.title || "")
});
});
});
}
@@ -1161,12 +1209,12 @@ async function syncCityFromActiveUrl() {
if (state.syncBusy || !state.cities.length) return;
state.syncBusy = true;
try {
const url = await getActiveTabUrl();
const { url, title } = await getActiveTabInfo();
if (!url) return;
if (url === state.lastActiveUrl) return;
state.lastActiveUrl = url;
const inferred = inferCityFromUrl(url);
const inferred = inferCityFromUrl(url) || matchCityInText(title);
if (!inferred) return;
if (inferred === state.config.selectedCity) return;
await setSelectedCity(inferred, { persist: true, reloadDetail: true });
+3 -3
View File
@@ -170,10 +170,10 @@ Ops
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
## 开源边界说明
## AGPL 与商用边界说明
此前端仓库包含通用产品界面和标准支付体验
商业策略调优、私有运营流程和敏感生产参数不在公开文档范围内。
此前端代码随仓库一起采用 `AGPL-3.0-only`
生产私有运营流程、商业策略调优、敏感生产参数、品牌与托管服务能力不在代码许可证授权范围内。
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
export async function POST(req: NextRequest) {
if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to track analytics event", detail: String(error) },
{ status: 500 },
);
}
}
+8 -3
View File
@@ -18,10 +18,15 @@ export async function GET(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/cities`, {
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const fetchOptions = {
headers: auth.headers,
cache: "no-store",
next: { revalidate: 300 },
} as const;
const res = await fetch(`${API_BASE}/api/cities`, {
...fetchOptions,
});
if (!res.ok) {
const raw = await res.text();
+3 -1
View File
@@ -23,7 +23,9 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(url, {
headers: auth.headers,
cache: "no-store",
+14 -3
View File
@@ -25,10 +25,21 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const fetchOptions =
bypassCache
? {
headers: auth.headers,
cache: "no-store" as const,
}
: {
headers: auth.headers,
next: { revalidate: 20 },
};
const res = await fetch(url, {
headers: auth.headers,
cache: "no-store",
...fetchOptions,
});
if (!res.ok) {
const raw = await res.text();
+3 -1
View File
@@ -15,7 +15,9 @@ export async function GET(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(`${API_BASE}/healthz`, {
headers: auth.headers,
cache: "no-store",
+5 -2
View File
@@ -24,9 +24,12 @@ export async function GET(
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
const fetchOptions = {
headers: auth.headers,
cache: "no-store",
next: { revalidate: 60 },
} as const;
const res = await fetch(url, {
...fetchOptions,
});
if (!res.ok) {
const raw = await res.text();
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days");
if (days) {
url.searchParams.set("days", days);
}
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch analytics funnel", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) url.searchParams.set(key, value);
}
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch truth history", detail: String(error) },
{ status: 500 },
);
}
}
+17
View File
@@ -5,6 +5,9 @@ import {
recordVitalsSample,
} from "@/lib/vitals-store";
const WEB_VITALS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_WEB_VITALS === "true";
type VitalsPayload = {
id?: string;
metric?: string;
@@ -15,6 +18,10 @@ type VitalsPayload = {
};
export async function POST(request: Request) {
if (!WEB_VITALS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
try {
const payload = (await request.json()) as VitalsPayload;
const metric = normalizeMetricName(payload.metric);
@@ -56,6 +63,16 @@ export async function POST(request: Request) {
}
export async function GET(request: Request) {
if (!WEB_VITALS_ENABLED) {
return NextResponse.json({
ok: true,
disabled: true,
generatedAt: Date.now(),
sampleCount: 0,
routes: {},
});
}
const { searchParams } = new URL(request.url);
const targetRoute = String(searchParams.get("route") || "").trim();
const summary = getVitalsSummary();
+1 -5
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/react";
import "./globals.css";
export const metadata: Metadata = {
@@ -31,10 +30,7 @@ export default function RootLayout({
rel="stylesheet"
/>
</head>
<body className="min-h-screen font-sans antialiased">
{children}
<Analytics />
</body>
<body className="min-h-screen font-sans antialiased">{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
export default function Loading() {
return <DashboardShellSkeleton />;
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { TruthHistoryDashboard } from "@/components/ops/TruthHistoryDashboard";
import { requireOpsAdmin } from "@/lib/ops-admin";
export const metadata: Metadata = {
title: "PolyWeather Truth History",
description: "Admin truth history viewer for PolyWeather.",
};
export default async function TruthHistoryPage() {
await requireOpsAdmin("/ops/truth-history");
return <TruthHistoryDashboard />;
}
+230 -17
View File
@@ -46,6 +46,7 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import { trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
const UnlockProOverlay = dynamic(
@@ -204,6 +205,13 @@ type ConnectBindOptions = {
openOverlayAfterBind?: boolean;
};
type PaymentRecoveryState = {
intentId: string;
txHash: string;
userId: string;
createdAt: number;
};
const WALLETCONNECT_PROJECT_ID = String(
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "",
).trim();
@@ -218,7 +226,10 @@ const TELEGRAM_GROUP_URL = String(
const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
let walletConnectProviderCache: EvmProvider | null = null;
let walletConnectProviderChainId: number | null = null;
@@ -301,12 +312,31 @@ function formatTime(value: string | undefined | null, locale: string) {
}
}
function parseSubscriptionExpiry(value: string | undefined | null) {
const raw = String(value || "").trim();
if (!raw) return null;
const dt = new Date(raw);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
return {
raw,
date: dt,
expired: diffMs <= 0,
daysLeft: Math.ceil(diffMs / 86_400_000),
};
}
function shortAddress(address: string) {
const text = String(address || "");
if (!text.startsWith("0x") || text.length < 12) return text || "--";
return `${text.slice(0, 8)}...${text.slice(-6)}`;
}
function clearStoredPaymentRecovery() {
if (typeof window === "undefined") return;
window.sessionStorage.removeItem(PAYMENT_RECOVERY_STORAGE_KEY);
}
function getEvmProvider(): EvmProvider | null {
return listInjectedProviders()[0]?.provider || null;
}
@@ -692,6 +722,9 @@ export function AccountCenter() {
? "Open Bot (@WeatherQuant_bot)"
: "打开机器人 (@WeatherQuant_bot)",
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
telegramMarketChannelLink: isEn
? "Join Market Monitor Channel"
: "加入市场监控频道",
copyCommand: isEn ? "Copy command" : "复制命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理",
paymentToken: isEn ? "Payment Token" : "支付币种",
@@ -724,8 +757,8 @@ export function AccountCenter() {
? "Pro entitlement recovered."
: "Pro 权限已恢复。",
walletRecoveryFailed: isEn
? "Paid detected but entitlement is still pending. Please refresh in a minute or contact support."
: "检测到支付,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
? "A recent on-chain payment is still syncing to your subscription. Please refresh in a minute or contact support."
: "检测到最近的链上支付流程,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
unbind: isEn ? "Unbind" : "解绑",
unbindConfirm: isEn
? "Unbind wallet {address}? You can bind it again later."
@@ -768,6 +801,25 @@ export function AccountCenter() {
freeTier: "FREE TIER",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
trialEndsSoonTitle: isEn ? "Trial ending soon" : "试用即将结束",
trialEndsSoonBody: isEn
? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and history."
: "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和历史对账。",
trialExpiredTitle: isEn ? "Trial ended" : "试用已结束",
trialExpiredBody: isEn
? "Your trial access has ended. Renew with Pro to restore full access."
: "试用权限已结束。开通 Pro 后可恢复完整权限。",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
proEndsSoonBody: isEn
? "Your Pro membership will expire soon. Renew now to avoid interruption."
: "你的 Pro 会员即将到期。现在续费可避免权限中断。",
proExpiredTitle: isEn ? "Pro expired" : "Pro 已到期",
proExpiredBody: isEn
? "Your Pro membership has expired. Renew now to restore premium access."
: "你的 Pro 会员已到期。立即续费可恢复高级权限。",
renewNow: isEn ? "Renew Now" : "立即续费",
trialBadge: isEn ? "TRIAL" : "试用中",
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
}),
[isEn],
);
@@ -800,6 +852,7 @@ export function AccountCenter() {
const [paymentError, setPaymentError] = useState("");
const [lastIntentId, setLastIntentId] = useState("");
const [lastTxHash, setLastTxHash] = useState("");
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
const [showSecondarySections, setShowSecondarySections] = useState(false);
const [reconcileBusy, setReconcileBusy] = useState(false);
@@ -810,6 +863,9 @@ export function AccountCenter() {
const paymentReadyForRecovery = Boolean(
paymentConfig?.enabled && paymentConfig?.configured,
);
const hasRecentPaymentRecovery =
Boolean(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt) &&
Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS;
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
const paymentHostAllowed = useMemo(
@@ -1245,22 +1301,61 @@ export function AccountCenter() {
useEffect(() => {
if (typeof window === "undefined") return;
if (!lastIntentId) return;
if (!(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt)) {
clearStoredPaymentRecovery();
return;
}
const payload: PaymentRecoveryState = {
intentId: lastIntentId,
txHash: lastTxHash,
userId: authUserId,
createdAt: lastPaymentStartedAt,
};
window.sessionStorage.setItem(
"polyweather:lastPaymentIntentId",
lastIntentId,
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(payload),
);
}, [lastIntentId]);
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
useEffect(() => {
if (typeof window === "undefined") return;
const storedIntentId = window.sessionStorage.getItem(
"polyweather:lastPaymentIntentId",
);
if (storedIntentId && !lastIntentId) {
setLastIntentId(storedIntentId);
if (!authUserId) return;
if (lastIntentId && lastTxHash && lastPaymentStartedAt) return;
const raw = window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as PaymentRecoveryState;
const userId = String(parsed?.userId || "").trim();
const intentId = String(parsed?.intentId || "").trim();
const txHash = String(parsed?.txHash || "").trim().toLowerCase();
const createdAt = Number(parsed?.createdAt || 0);
const expired =
!createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
if (
expired ||
!intentId ||
!txHash ||
!userId ||
userId !== authUserId
) {
clearStoredPaymentRecovery();
return;
}
setLastIntentId(intentId);
setLastTxHash(txHash);
setLastPaymentStartedAt(createdAt);
} catch {
clearStoredPaymentRecovery();
}
}, [lastIntentId]);
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
useEffect(() => {
if (!backend?.subscription_active) return;
setLastIntentId("");
setLastTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
}, [backend?.subscription_active]);
const onRefresh = async () => {
setRefreshing(true);
@@ -1312,7 +1407,7 @@ export function AccountCenter() {
if (!authIsAuthenticated) return;
if (backend?.subscription_active) return;
if (!paymentReadyForRecovery) return;
if (!lastIntentId) return;
if (!hasRecentPaymentRecovery) return;
let cancelled = false;
const run = async () => {
setPaymentInfo(copy.walletRecoveryBusy);
@@ -1332,12 +1427,16 @@ export function AccountCenter() {
authIsAuthenticated,
copy.walletRecoveryBusy,
copy.walletRecoveryFailed,
lastIntentId,
hasRecentPaymentRecovery,
paymentReadyForRecovery,
reconcileLatestPayment,
]);
const onSignOut = async () => {
setLastIntentId("");
setLastTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
if (walletConnectProviderCache?.disconnect) {
try {
await walletConnectProviderCache.disconnect();
@@ -1368,15 +1467,61 @@ export function AccountCenter() {
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active);
const planCode = String(backend?.subscription_plan_code || "").trim();
const isTrialPlan = /trial/i.test(planCode);
const expiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim();
const expiryInfo = parseSubscriptionExpiry(expiryRaw);
const expiryFormatted = formatTime(expiryRaw, locale);
const proExpiry = isSubscribed
? expiryFormatted !== "--"
? expiryFormatted
: expiryRaw || copy.proPendingSync
: copy.noProSubscription;
const showExpiringSoon =
Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3);
const showExpiredReminder = Boolean(!isSubscribed && expiryInfo && expiryInfo.expired);
const subscriptionStatusTitle = showExpiredReminder
? isTrialPlan
? copy.trialExpiredTitle
: copy.proExpiredTitle
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonTitle
: copy.proEndsSoonTitle
: "";
const subscriptionStatusBody = showExpiredReminder
? isTrialPlan
? copy.trialExpiredBody
: copy.proExpiredBody
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonBody
: copy.proEndsSoonBody
: "";
const subscriptionStatusMeta =
expiryInfo && (showExpiringSoon || showExpiredReminder)
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
: "";
useEffect(() => {
if (!showOverlay || isSubscribed) return;
trackAppEvent("paywall_viewed", {
entry: "account_center",
user_state: isAuthenticated ? "logged_in" : "guest",
expired: showExpiredReminder,
expiring_soon: showExpiringSoon,
subscription_plan_code: planCode || null,
});
}, [
isAuthenticated,
isSubscribed,
planCode,
showExpiredReminder,
showExpiringSoon,
showOverlay,
]);
// Points Logic
const backendPointsRaw = Number(backend?.points);
@@ -1609,6 +1754,12 @@ export function AccountCenter() {
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHash || null,
});
await loadSnapshot();
await loadPaymentSnapshot();
return;
@@ -1627,7 +1778,7 @@ export function AccountCenter() {
}
throw new Error("payment pending timeout");
},
[loadPaymentSnapshot, loadSnapshot],
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
);
const signBindMessage = async (
@@ -1877,7 +2028,10 @@ export function AccountCenter() {
const createIntentAndPay = async () => {
setPaymentError("");
setPaymentInfo("");
setLastIntentId("");
setLastTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
@@ -2001,6 +2155,13 @@ export function AccountCenter() {
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data)
throw new Error("intent payload invalid");
trackAppEvent("checkout_started", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
const intentReceiver = String(txPayload.to || "").toLowerCase();
if (intentReceiver !== expectedReceiver) {
throw new Error(
@@ -2090,6 +2251,7 @@ export function AccountCenter() {
})) as string;
const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now());
const submitRes = await fetch(
`/api/payments/intents/${intentId}/submit`,
@@ -2136,6 +2298,12 @@ export function AccountCenter() {
}
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
@@ -2234,7 +2402,8 @@ export function AccountCenter() {
onClick={() => setShowOverlay(true)}
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
>
<Crown size={16} /> {copy.upgradePro}
<Crown size={16} />{" "}
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
</button>
)}
<button
@@ -2269,6 +2438,35 @@ export function AccountCenter() {
</header>
<main className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6 z-10 relative">
{(showExpiringSoon || showExpiredReminder) && (
<div className="lg:col-span-12 rounded-[2rem] border border-amber-400/30 bg-amber-500/10 px-6 py-5 shadow-xl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-bold text-amber-300">
<Crown size={16} />
<span>{subscriptionStatusTitle}</span>
</div>
<p className="mt-1 text-sm text-amber-50/90">
{subscriptionStatusBody}
</p>
{subscriptionStatusMeta ? (
<p className="mt-1 text-xs text-amber-200/80">
{subscriptionStatusMeta}
</p>
) : null}
</div>
<button
type="button"
onClick={() => setShowOverlay(true)}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-amber-300/35 bg-amber-300/12 px-4 py-2 text-sm font-bold text-amber-100 transition-all hover:bg-amber-300/20"
>
<Crown size={16} />
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
</button>
</div>
</div>
)}
{/* User Card */}
<div className="lg:col-span-8 bg-white/5 backdrop-blur-xl border border-white/10 rounded-[2.5rem] p-8 shadow-2xl flex flex-col md:flex-row items-center gap-8">
<div className="relative">
@@ -2287,7 +2485,11 @@ export function AccountCenter() {
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
>
{isSubscribed ? copy.proMember : copy.freeTier}
{isSubscribed
? isTrialPlan
? copy.trialBadge
: copy.proMember
: copy.freeTier}
</span>
</div>
<p className="text-slate-500 font-mono text-sm mb-4">
@@ -2527,6 +2729,17 @@ export function AccountCenter() {
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_MARKET_CHANNEL_URL ? (
<Link
href={TELEGRAM_MARKET_CHANNEL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
>
{copy.telegramMarketChannelLink}
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_GROUP_URL ? (
<Link
href={TELEGRAM_GROUP_URL}
+6
View File
@@ -71,6 +71,9 @@ export function LoginClient({ nextPath }: LoginClientProps) {
signupCheckEmail: isEn
? "Sign-up successful. Please verify your email before signing in."
: "注册成功,请检查邮箱并完成验证后登录。",
trialPromo: isEn
? "New users unlock a free 3-day Pro trial after sign-up."
: "新用户注册后可免费体验 3 天 Pro。",
} as const;
useEffect(() => {
@@ -190,6 +193,9 @@ export function LoginClient({ nextPath }: LoginClientProps) {
</div>
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
<p className="mt-2 text-sm text-slate-400">{copy.subtitle}</p>
<div className="mt-4 inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1.5 text-xs font-semibold text-cyan-200 shadow-[0_0_20px_rgba(34,211,238,0.08)]">
{copy.trialPromo}
</div>
</div>
<button
+49 -8
View File
@@ -4,7 +4,7 @@ import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityListItem } from "@/lib/dashboard-types";
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
@@ -21,6 +21,10 @@ function toRiskGroup(level?: string): RiskGroupKey {
return "other";
}
function toPerformanceGroup(city: CityListItem): RiskGroupKey {
return toRiskGroup(city.deb_recent_tier);
}
function normalizeExpandedGroups(
value: unknown,
): Record<RiskGroupKey, boolean> {
@@ -50,7 +54,7 @@ function normalizeExpandedGroups(
export function CitySidebar() {
const store = useDashboardStore();
const { t } = useI18n();
const { locale, t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<
@@ -60,11 +64,17 @@ export function CitySidebar() {
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
const aGroup = toPerformanceGroup(a);
const bGroup = toPerformanceGroup(b);
const aHitRate = Number(a.deb_recent_hit_rate ?? -1);
const bHitRate = Number(b.deb_recent_hit_rate ?? -1);
const aSamples = Number(a.deb_recent_sample_count ?? 0);
const bSamples = Number(b.deb_recent_sample_count ?? 0);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
bHitRate - aHitRate ||
bSamples - aSamples ||
a.display_name.localeCompare(b.display_name)
);
}),
@@ -79,7 +89,7 @@ export function CitySidebar() {
other: [],
};
sortedCities.forEach((city) => {
groups[toRiskGroup(city.risk_level)].push(city);
groups[toPerformanceGroup(city)].push(city);
});
return groups;
}, [sortedCities]);
@@ -88,7 +98,7 @@ export function CitySidebar() {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toRiskGroup(selected.risk_level);
const groupKey = toPerformanceGroup(selected);
setExpandedGroups((current) =>
current[groupKey] ? current : { ...current, [groupKey]: true },
);
@@ -114,6 +124,16 @@ export function CitySidebar() {
} catch {}
}, [expandedGroups]);
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
if (!monitor?.available) return "";
const label =
locale === "en-US" ? monitor.label_en : monitor.label_zh;
const trendLabel =
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
if (!label) return "";
return trendLabel ? `${label} · ${trendLabel}` : label;
};
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
@@ -172,6 +192,9 @@ export function CitySidebar() {
temp: `${snapshot.current.temp}${tempSymbol}`,
})
: t("common.na");
const deviationText = formatDeviationText(
snapshot?.deviation_monitor,
);
const peakTempText =
detail?.current?.max_so_far != null &&
detail.current.max_temp_time
@@ -182,6 +205,12 @@ export function CitySidebar() {
: detail?.current?.max_temp_time
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
: "";
const deviationDirection =
snapshot?.deviation_monitor?.direction || "normal";
const deviationSeverity =
snapshot?.deviation_monitor?.severity || "normal";
const secondaryText = deviationText || peakTempText;
const performanceTier = toPerformanceGroup(city);
return (
<button
@@ -195,7 +224,7 @@ export function CitySidebar() {
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
<span className={clsx("risk-dot", performanceTier)} />
<span className="city-name-text">{city.display_name}</span>
<span
className={clsx(
@@ -211,7 +240,19 @@ export function CitySidebar() {
<span className="city-local-time">
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
</span>
<span className="city-max-info">{peakTempText}</span>
<span
className={clsx(
"city-max-info",
deviationText && "city-deviation-info",
deviationText &&
`city-deviation-${deviationDirection}`,
deviationText &&
deviationSeverity === "strong" &&
"strong",
)}
>
{secondaryText}
</span>
</div>
</button>
);
@@ -488,6 +488,30 @@
font-weight: 500;
}
.root :global(.city-item .city-deviation-info) {
font-weight: 600;
}
.root :global(.city-item .city-deviation-cold) {
color: #38bdf8;
}
.root :global(.city-item .city-deviation-hot) {
color: #f59e0b;
}
.root :global(.city-item .city-deviation-normal) {
color: #22d3ee;
}
.root :global(.city-item .city-deviation-info.strong) {
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
}
.root :global(.city-item .city-deviation-hot.strong) {
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
}
.root :global(.city-item .risk-dot) {
width: 10px;
height: 10px;
@@ -582,6 +606,30 @@
margin-bottom: 6px;
}
.root :global(.panel-loading-hint) {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.18);
background: rgba(12, 24, 42, 0.78);
color: var(--accent-cyan);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
}
.root :global(.panel-loading-spinner) {
width: 10px;
height: 10px;
border-radius: 999px;
border: 2px solid rgba(34, 211, 238, 0.22);
border-top-color: rgba(34, 211, 238, 0.92);
animation: loading-spin 0.8s linear infinite;
}
.root :global(.panel-meta) {
display: flex;
align-items: center;
@@ -589,6 +637,46 @@
flex-wrap: wrap;
}
.root :global(.city-loading-toast) {
position: fixed;
top: 78px;
left: 50%;
transform: translateX(-50%);
z-index: 1200;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.18);
background: linear-gradient(
180deg,
rgba(10, 18, 34, 0.94),
rgba(10, 18, 34, 0.82)
);
box-shadow:
0 18px 40px rgba(2, 6, 23, 0.38),
0 0 0 1px rgba(34, 211, 238, 0.04) inset;
backdrop-filter: blur(18px);
pointer-events: none;
}
.root :global(.city-loading-dot) {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--accent-cyan);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.5);
animation: city-loading-pulse 1.35s ease-out infinite;
}
.root :global(.city-loading-copy) {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.25px;
}
.root :global(.pro-locked) {
filter: grayscale(0.8) opacity(0.7);
position: relative;
@@ -937,6 +1025,10 @@
gap: 8px;
}
.root :global(.forecast-inline-note) {
line-height: 1.45;
}
.root :global(.forecast-day) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
@@ -976,6 +1068,31 @@
color: var(--accent-cyan);
}
@media (max-width: 720px) {
.root :global(.forecast-table) {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.root :global(.forecast-table::-webkit-scrollbar) {
height: 6px;
}
.root :global(.forecast-table::-webkit-scrollbar-thumb) {
background: rgba(148, 163, 184, 0.32);
border-radius: 999px;
}
.root :global(.forecast-day) {
flex: 0 0 112px;
min-width: 112px;
scroll-snap-align: start;
}
}
.root :global(.sun-info) {
margin-top: 10px;
font-size: 12px;
@@ -1543,6 +1660,21 @@
}
}
@keyframes city-loading-pulse {
0% {
transform: scale(0.92);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
}
100% {
transform: scale(0.92);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
}
}
@keyframes radar-sweep {
from {
transform: rotate(0deg);
@@ -1641,6 +1773,11 @@
white-space: nowrap;
}
.root :global(.nearby-marker-shell) {
display: inline-block;
will-change: transform;
}
@keyframes nearby-fade-in {
from {
opacity: 0;
@@ -2630,6 +2767,58 @@
transform: translateY(-1px);
}
.root :global(.account-renew-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-renew-badge:hover) {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.6);
color: #fff6d5;
transform: translateY(-1px);
}
.root :global(.account-renew-badge.expired) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
.root :global(.trial-promo-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.28);
background: rgba(34, 211, 238, 0.1);
color: #a5f3fc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.trial-promo-badge:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.45);
color: #ecfeff;
transform: translateY(-1px);
}
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
@@ -2691,6 +2880,36 @@
padding: 14px;
}
.root :global(.future-v2-card-head) {
display: grid;
gap: 6px;
}
.root :global(.future-v2-card-kicker) {
color: var(--text-muted);
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.root :global(.future-v2-focus-card) {
background:
linear-gradient(
180deg,
rgba(34, 211, 238, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.future-v2-support-card) {
background:
linear-gradient(
180deg,
rgba(148, 163, 184, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.future-v2-hero-card) {
background: linear-gradient(
180deg,
@@ -2938,6 +3157,131 @@
background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
}
.root :global(.future-v2-pace-card) {
background:
radial-gradient(
circle at top right,
rgba(34, 211, 238, 0.12) 0%,
rgba(15, 23, 42, 0) 42%
),
linear-gradient(
180deg,
rgba(8, 15, 28, 0.96) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.future-v2-pace-head) {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.root :global(.future-v2-pace-kicker) {
color: var(--text-muted);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.root :global(.future-v2-pace-delta) {
margin-top: 10px;
font-size: 34px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.05;
}
.root :global(.future-v2-pace-delta.warm) {
color: #fbbf24;
}
.root :global(.future-v2-pace-delta.cold) {
color: #67e8f9;
}
.root :global(.future-v2-pace-delta.neutral) {
color: #e2e8f0;
}
.root :global(.future-v2-pace-summary) {
margin-top: 8px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.root :global(.future-v2-pace-signal-grid) {
margin-top: 12px;
display: grid;
gap: 10px;
}
.root :global(.future-v2-pace-signal-card) {
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.025);
padding: 10px;
display: grid;
gap: 8px;
}
.root :global(.future-v2-pace-signal-note) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.root :global(.future-v2-pace-meter) {
position: relative;
height: 10px;
margin-top: 12px;
border-radius: 999px;
overflow: hidden;
background:
linear-gradient(
90deg,
rgba(34, 211, 238, 0.12) 0%,
rgba(255, 255, 255, 0.06) 46%,
rgba(255, 255, 255, 0.06) 54%,
rgba(251, 191, 36, 0.12) 100%
);
}
.root :global(.future-v2-pace-meter-midline) {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.2);
transform: translateX(-50%);
z-index: 1;
}
.root :global(.future-v2-pace-meter-fill) {
position: absolute;
top: 0;
left: var(--pace-left, 46%);
width: var(--pace-width, 8%);
bottom: 0;
border-radius: inherit;
}
.root :global(.future-v2-pace-meter-fill.warm) {
background: linear-gradient(90deg, #b45309 0%, #fbbf24 100%);
}
.root :global(.future-v2-pace-meter-fill.cold) {
background: linear-gradient(90deg, #0f766e 0%, #67e8f9 100%);
}
.root :global(.future-v2-pace-meter-fill.neutral) {
background: linear-gradient(90deg, #475569 0%, #cbd5e1 100%);
}
.root :global(.intraday-scene-shell) {
margin-top: 12px;
margin-bottom: 10px;
@@ -3043,6 +3387,20 @@
position: relative;
}
.root :global(.future-v2-market-card) {
background:
radial-gradient(
circle at top right,
rgba(34, 197, 94, 0.08) 0%,
rgba(15, 23, 42, 0) 40%
),
linear-gradient(
180deg,
rgba(7, 16, 26, 0.96) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.market-layer-loading-overlay) {
position: absolute;
top: 0;
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const PolyWeatherDashboard = dynamic(
() =>
@@ -9,6 +10,7 @@ const PolyWeatherDashboard = dynamic(
),
{
ssr: false,
loading: () => <DashboardShellSkeleton />,
},
);
@@ -0,0 +1,105 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardShellSkeleton() {
return (
<div
style={{
background:
"radial-gradient(circle at top, rgba(30,41,59,0.45), rgba(2,6,23,0.98) 55%)",
height: "100vh",
overflow: "hidden",
position: "relative",
width: "100vw",
}}
>
<div
style={{
alignItems: "center",
backdropFilter: "blur(16px)",
background: "rgba(10,14,26,0.78)",
borderBottom: "1px solid rgba(99,102,241,0.15)",
display: "flex",
height: 56,
justifyContent: "space-between",
left: 0,
padding: "0 24px",
position: "fixed",
right: 0,
top: 0,
zIndex: 20,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton className="h-6 w-40 bg-zinc-700/60" />
<Skeleton className="h-3 w-28 bg-zinc-800/70" />
</div>
<div style={{ display: "flex", gap: 10 }}>
<Skeleton className="h-8 w-20 rounded-full bg-zinc-800/70" />
<Skeleton className="h-8 w-28 rounded-full bg-zinc-800/70" />
</div>
</div>
<div
style={{
bottom: 24,
display: "flex",
gap: 24,
left: 24,
position: "absolute",
right: 24,
top: 80,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
maxWidth: 280,
width: "22vw",
}}
>
<Skeleton className="h-10 w-40 rounded-xl bg-zinc-800/80" />
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={index}
className="h-14 w-full rounded-2xl bg-zinc-900/75"
/>
))}
</div>
<div style={{ flex: 1, position: "relative" }}>
<Skeleton className="h-full w-full rounded-[28px] bg-zinc-950/55" />
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={index}
className="absolute rounded-full bg-cyan-500/20"
style={{
height: 18,
left: `${10 + index * 10}%`,
top: `${20 + ((index * 9) % 45)}%`,
width: 18,
}}
/>
))}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
maxWidth: 420,
width: "30vw",
}}
>
<Skeleton className="h-16 w-full rounded-3xl bg-zinc-900/80" />
<Skeleton className="h-48 w-full rounded-3xl bg-zinc-900/70" />
<Skeleton className="h-32 w-full rounded-3xl bg-zinc-900/70" />
</div>
</div>
</div>
);
}
+159 -4
View File
@@ -11,6 +11,8 @@ import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import {
getCityProfileStats,
getRiskBadgeLabel,
@@ -140,8 +142,16 @@ export function DetailPanel() {
: null,
[store.cities, store.selectedCity],
);
const selectedSummary = useMemo(
() =>
store.selectedCity
? store.citySummariesByName[store.selectedCity] || null
: null,
[store.citySummariesByName, store.selectedCity],
);
const isPro = store.proAccess.subscriptionActive;
const isAuthenticated = store.proAccess.authenticated;
const isProStateLoading = store.proAccess.loading;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
@@ -150,15 +160,21 @@ export function DetailPanel() {
const isVisible =
store.isPanelOpen &&
Boolean(store.selectedCity) &&
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const hasBasicPanelContent = Boolean(
detail || selectedSummary || selectedCityItem,
);
const panelDisplayName =
detail?.display_name ||
selectedSummary?.display_name ||
selectedCityItem?.display_name ||
store.selectedCity ||
"...";
const panelRiskLevel = detail?.risk?.level || selectedCityItem?.risk_level || "low";
const panelRiskLevel =
detail?.risk?.level ||
selectedSummary?.risk?.level ||
selectedCityItem?.risk_level ||
"low";
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
@@ -167,7 +183,28 @@ export function DetailPanel() {
() => (detail ? getOfficialSourceLinks(detail) : []),
[detail],
);
const marketUrl = useMemo(
() => getTodayPolymarketUrl(detail, locale),
[detail, locale],
);
const scenery = getCityScenery(detail?.name);
const basicCurrentTemp = selectedSummary?.current?.temp;
const basicObsTime = selectedSummary?.current?.obs_time;
const basicDeb = selectedSummary?.deb?.prediction;
const basicSettlementLabel =
selectedSummary?.current?.settlement_source_label ||
selectedCityItem?.settlement_source_label ||
selectedCityItem?.settlement_source ||
(locale === "en-US" ? "Settlement source pending" : "结算口径待确认");
const basicAirportLabel =
selectedCityItem?.airport ||
selectedSummary?.icao ||
(locale === "en-US" ? "Airport pending" : "机场待确认");
const isBasicSummaryLoading =
!detail && !selectedSummary && store.loadingState.cityDetail;
const shouldShowSyncCard =
!detail &&
(store.loadingState.cityDetail || isProStateLoading || isAuthenticated);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
const active = document.activeElement;
@@ -178,6 +215,15 @@ export function DetailPanel() {
const handleFeatureAccess = (feature: "today" | "history") => {
blurActiveElement();
if (!isPro) {
trackAppEvent("paywall_feature_clicked", {
entry: "detail_panel",
feature,
city: store.selectedCity,
user_state: isAuthenticated ? "logged_in" : "guest",
});
}
if (isPro) {
if (feature === "today") {
void store.openTodayModal();
@@ -275,11 +321,36 @@ export function DetailPanel() {
</button>
<div className="panel-title-area">
<h2>{panelDisplayName.toUpperCase()}</h2>
{store.loadingState.cityDetail && (
<div className="panel-loading-hint" role="status" aria-live="polite">
<span className="panel-loading-spinner" aria-hidden="true" />
<span>
{locale === "en-US"
? `Syncing ${panelDisplayName}...`
: `正在同步 ${panelDisplayName}...`}
</span>
</div>
)}
<div className="panel-meta">
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
</span>
<div className="relative group">
{marketUrl ? (
<a
className="history-btn"
href={marketUrl}
target="_blank"
rel="noreferrer"
title={
locale === "en-US"
? "Open today's Polymarket market"
: "打开今日 Polymarket 题目页"
}
>
{locale === "en-US" ? "Open Market" : "打开 Polymarket"}
</a>
) : null}
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
@@ -312,7 +383,7 @@ export function DetailPanel() {
</div>
<div className="panel-body">
{!detail ? (
{!hasBasicPanelContent ? (
<section>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
@@ -320,6 +391,90 @@ export function DetailPanel() {
: t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<>
<section className="detail-section">
<h3>{locale === "en-US" ? "City Snapshot" : "城市概览"}</h3>
{isBasicSummaryLoading ? (
<div className="detail-mini-meta">
{locale === "en-US"
? "Syncing public city snapshot..."
: "正在同步城市基础信息..."}
</div>
) : null}
<div className="detail-grid">
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Current temp" : "当前温度"}
</span>
<span className="detail-value">
{basicCurrentTemp != null
? `${basicCurrentTemp}${selectedSummary?.temp_symbol || ""}`
: locale === "en-US"
? "--"
: "--"}
</span>
</div>
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Observed" : "观测时间"}
</span>
<span className="detail-value">
{basicObsTime || (locale === "en-US" ? "Pending" : "待更新")}
</span>
</div>
<div className="detail-card">
<span className="detail-label">DEB</span>
<span className="detail-value">
{basicDeb != null
? `${basicDeb}${selectedSummary?.temp_symbol || ""}`
: locale === "en-US"
? isBasicSummaryLoading
? "Syncing..."
: "Pending"
: isBasicSummaryLoading
? "同步中..."
: "待更新"}
</span>
</div>
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Settlement" : "结算口径"}
</span>
<span className="detail-value">{basicSettlementLabel}</span>
</div>
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Airport" : "结算机场"}
</span>
<span className="detail-value">{basicAirportLabel}</span>
</div>
</div>
</section>
<section className="detail-section">
<div className="detail-card">
<span className="detail-label">
{shouldShowSyncCard
? locale === "en-US"
? "Detail sync"
: "详情同步"
: locale === "en-US"
? "Pro features"
: "Pro 功能"}
</span>
<span className="detail-value" style={{ fontSize: "15px" }}>
{shouldShowSyncCard
? locale === "en-US"
? "Full city detail is still syncing. The deeper panel will appear automatically."
: "完整城市详情仍在同步中,深度面板会自动补齐。"
: locale === "en-US"
? "Intraday analysis, history reconciliation, and deeper structure signals require Pro."
: "今日日内分析、历史对账和更深入的结构信号需要 Pro。"}
</span>
</div>
</section>
</>
) : (
<>
<section className="detail-scenery-card">
File diff suppressed because it is too large Load Diff
+70 -35
View File
@@ -1,53 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { LogIn, UserRound } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
function parseExpiryInfo(raw?: string | null) {
const text = String(raw || "").trim();
if (!text) return null;
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
const daysLeft = Math.ceil(diffMs / 86_400_000);
return {
date: dt,
daysLeft,
expired: diffMs <= 0,
};
}
export function HeaderBar() {
const store = useDashboardStore();
const { locale, setLocale, t } = useI18n();
const pathname = usePathname();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const isAuthenticated = store.proAccess.authenticated;
const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs");
useEffect(() => {
let mounted = true;
if (!supabaseReady) {
setIsAuthenticated(false);
return;
}
const supabase = getSupabaseBrowserClient();
void supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setIsAuthenticated(Boolean(data.session?.user?.id));
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setIsAuthenticated(Boolean(session?.user?.id));
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [supabaseReady]);
const trialPromoLabel =
locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro";
const accountHref = isAuthenticated
? "/account"
@@ -56,6 +38,36 @@ export function HeaderBar() {
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
const expiryInfo = parseExpiryInfo(store.proAccess.subscriptionExpiresAt);
const isTrialPlan = /trial/i.test(
String(store.proAccess.subscriptionPlanCode || ""),
);
const showRenewReminder =
isAuthenticated &&
!store.proAccess.loading &&
(
(store.proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo))
);
const renewReminderLabel = !showRenewReminder
? ""
: !store.proAccess.subscriptionActive
? isTrialPlan
? locale === "en-US"
? "Trial ended"
: "试用已结束"
: locale === "en-US"
? "Pro expired"
: "Pro 已到期"
: isTrialPlan
? locale === "en-US"
? `Trial ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `试用剩余 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`
: locale === "en-US"
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`;
return (
<header className="header">
@@ -91,6 +103,15 @@ export function HeaderBar() {
{t("header.docs")}
</Link>
<Link
href="/account"
className="trial-promo-badge"
title={trialPromoLabel}
aria-label={trialPromoLabel}
>
<span>{trialPromoLabel}</span>
</Link>
<Link
href={accountHref}
className="account-btn"
@@ -101,6 +122,20 @@ export function HeaderBar() {
<span>{accountLabel}</span>
</Link>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!store.proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
+29 -30
View File
@@ -19,7 +19,6 @@ import {
getRiskBadgeLabel,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
} from "@/lib/dashboard-utils";
function EmptyState({ text }: { text: string }) {
@@ -610,6 +609,7 @@ export function ModelForecast({
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
const hasSingleModelOnly = modelEntries.length === 1;
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
@@ -638,6 +638,19 @@ export function ModelForecast({
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
{hasSingleModelOnly && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "8px",
}}
>
{locale === "en-US"
? "Single-model fallback: waiting for the rest of the model cluster."
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
@@ -711,6 +724,7 @@ export function ForecastTable() {
if (!data) return null;
const daily = data.forecast?.daily || [];
const isSparseDaily = daily.length <= 1;
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => {
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
return debPrediction ?? fallback ?? null;
@@ -718,6 +732,20 @@ export function ForecastTable() {
return (
<section className="forecast-section">
<h3>{t("forecast.title")}</h3>
{isSparseDaily && (
<div
className="forecast-inline-note"
style={{
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "10px",
}}
>
{store.loadingState.cityDetail
? "多日预报同步中,正在刷新完整日序列。"
: "当前只收到当日预报,其他日期结果暂未回传。"}
</div>
)}
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} />
@@ -760,35 +788,6 @@ export function ForecastTable() {
);
}
export function AiAnalysis() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const ai = parseAiAnalysis(data.ai_analysis);
return (
<section className="ai-section">
<h3>{t("section.ai")}</h3>
<div className="ai-box">
{!ai.summary && ai.bullets.length === 0 ? (
<span className="ai-placeholder">{t("section.aiEmpty")}</span>
) : (
<>
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
{ai.bullets.length > 0 && (
<ul className="ai-list">
{ai.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</>
)}
</div>
</section>
);
}
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
@@ -1,7 +1,6 @@
"use client";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import { useEffect } from "react";
import styles from "./Dashboard.module.css";
import {
DashboardStoreProvider,
@@ -21,17 +20,6 @@ const MapCanvas = dynamic(
},
);
const WeatherAuraLayer = dynamic(
() =>
import("@/components/dashboard/WeatherAuraLayer").then(
(module) => module.WeatherAuraLayer,
),
{
ssr: false,
loading: () => null,
},
);
const HistoryModal = dynamic(
() =>
import("@/components/dashboard/HistoryModal").then(
@@ -57,11 +45,11 @@ const FutureForecastModal = dynamic(
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
useEffect(() => {
void import("@/components/dashboard/HistoryModal");
void import("@/components/dashboard/FutureForecastModal");
}, []);
const activeCityName =
store.selectedDetail?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)?.display_name ||
store.selectedCity ||
"";
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -88,16 +76,22 @@ function DashboardScreen() {
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
const showLoading =
store.loadingState.cities ||
store.loadingState.cityDetail ||
store.loadingState.refresh;
return (
<div className={styles.root}>
<MapCanvas />
<WeatherAuraLayer />
<HeaderBar />
<CitySidebar />
<DetailPanel />
{store.loadingState.cityDetail && activeCityName ? (
<div className="city-loading-toast" role="status" aria-live="polite">
<span className="city-loading-dot" aria-hidden="true" />
<span className="city-loading-copy">
{t("dashboard.loading")} {activeCityName}
</span>
</div>
) : null}
{store.historyState.isOpen && <HistoryModal />}
{store.futureModalDate && <FutureForecastModal />}
{showLoading && (
@@ -1,10 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useI18n } from "@/hooks/useI18n";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import { trackAppEvent } from "@/lib/app-analytics";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
@@ -63,6 +64,14 @@ export function ProFeaturePaywall({
? "Sign In to Unlock Pro"
: "先登录再开通 Pro";
useEffect(() => {
trackAppEvent("paywall_viewed", {
entry: "feature_gate",
feature,
user_state: isAuthenticated ? "logged_in" : "guest",
});
}, [feature, isAuthenticated]);
return (
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10 z-30 p-4">
<UnlockProOverlay
@@ -4,10 +4,16 @@ import { usePathname } from "next/navigation";
import { useReportWebVitals } from "next/web-vitals";
const TRACKED_METRICS = new Set(["INP", "LCP", "FCP"]);
const WEB_VITALS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_WEB_VITALS === "true";
export function WebVitalsReporter() {
const pathname = usePathname();
if (!WEB_VITALS_ENABLED) {
return null;
}
useReportWebVitals((metric) => {
if (!TRACKED_METRICS.has(metric.name)) {
return;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type TruthHistoryItem = {
city: string;
display_name?: string;
target_date: string;
actual_high?: number | null;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
truth_version?: string | null;
updated_by?: string | null;
truth_updated_at?: number | null;
is_final?: boolean | null;
};
type TruthHistoryPayload = {
items?: TruthHistoryItem[];
available_cities?: Array<{ city: string; name?: string }>;
filters?: {
city?: string | null;
date_from?: string | null;
date_to?: string | null;
limit?: number;
};
filtered_count?: number;
};
function formatUnixDateTime(value?: number | null) {
if (!value) return "-";
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { hour12: false });
}
async function readJson<T>(url: string): Promise<T> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
const raw = await response.text();
throw new Error(`${url} -> HTTP ${response.status} ${raw.slice(0, 180)}`);
}
return response.json() as Promise<T>;
}
export function TruthHistoryDashboard() {
const [city, setCity] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [limit, setLimit] = useState("200");
const [payload, setPayload] = useState<TruthHistoryPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const url = new URL("/api/ops/truth-history", window.location.origin);
if (city.trim()) url.searchParams.set("city", city.trim());
if (dateFrom.trim()) url.searchParams.set("date_from", dateFrom.trim());
if (dateTo.trim()) url.searchParams.set("date_to", dateTo.trim());
if (limit.trim()) url.searchParams.set("limit", limit.trim());
const data = await readJson<TruthHistoryPayload>(url.toString());
setPayload(data);
} catch (loadError) {
setError(String(loadError));
} finally {
setLoading(false);
}
}, [city, dateFrom, dateTo, limit]);
useEffect(() => {
void load();
}, [load]);
const items = payload?.items || [];
const availableCities = payload?.available_cities || [];
const stats = useMemo(() => {
const uniqueCities = new Set(items.map((item) => item.city)).size;
const finalCount = items.filter((item) => item.is_final).length;
return {
rows: items.length,
filtered: payload?.filtered_count ?? items.length,
uniqueCities,
finalCount,
};
}, [items, payload?.filtered_count]);
return (
<main className="min-h-screen bg-slate-950 px-3 py-6 text-slate-100 sm:px-6 sm:py-8 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-5 sm:gap-6">
<section className="rounded-3xl border border-slate-800 bg-slate-900/80 p-4 shadow-2xl backdrop-blur-xl sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">Ops</Badge>
<Badge variant="secondary">Truth History</Badge>
<Link
href="/ops"
className="inline-flex items-center rounded-full border border-slate-700 bg-slate-950/70 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-cyan-400/50 hover:text-white"
>
/ops
</Link>
</div>
<div>
<h1 className="text-2xl font-black tracking-tight sm:text-3xl"></h1>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
/ `actual_high`
</p>
</div>
</div>
<Button onClick={() => void load()} disabled={loading} className="gap-2">
<RefreshCcw className="h-4 w-4" />
{loading ? "加载中" : "刷新"}
</Button>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.rows}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> limit </CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.filtered}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.uniqueCities}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Final Rows</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.finalCount}</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> city / date range </CardDescription>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-[1.4fr_1fr_1fr_160px_auto]">
<select
value={city}
onChange={(event) => setCity(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
>
<option value=""></option>
{availableCities.map((item) => (
<option key={item.city} value={item.city}>
{item.name || item.city}
</option>
))}
</select>
<input
type="date"
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
/>
<input
type="date"
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
/>
<input
type="number"
min={1}
max={1000}
value={limit}
onChange={(event) => setLimit(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
/>
<Button onClick={() => void load()} disabled={loading}>
</Button>
</CardContent>
</Card>
{error ? (
<Card className="border-rose-500/30 bg-rose-500/10">
<CardHeader>
<CardTitle className="text-rose-300"></CardTitle>
<CardDescription className="text-rose-200/80">{error}</CardDescription>
</CardHeader>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
`actual_high``settlement_source``station_code``truth_version``updated_by``updated_at`
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">City</th>
<th className="px-4 py-3">Actual</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Station</th>
<th className="px-4 py-3">Version</th>
<th className="px-4 py-3">Updated By</th>
<th className="px-4 py-3">Updated At</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{items.map((item) => (
<tr key={`${item.city}-${item.target_date}`}>
<td className="px-4 py-3">{item.target_date}</td>
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{item.display_name || item.city}</div>
<div className="mt-1 text-xs text-slate-500">{item.city}</div>
</td>
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{item.actual_high ?? "-"}</div>
<div className="mt-1 text-xs text-slate-500">{item.is_final ? "final" : "non-final"}</div>
</td>
<td className="px-4 py-3">{item.settlement_source || "-"}</td>
<td className="px-4 py-3">
<div>{item.settlement_station_code || "-"}</div>
<div className="mt-1 text-xs text-slate-500">{item.settlement_station_label || "-"}</div>
</td>
<td className="px-4 py-3">{item.truth_version || "-"}</td>
<td className="px-4 py-3">{item.updated_by || "-"}</td>
<td className="px-4 py-3">{formatUnixDateTime(item.truth_updated_at)}</td>
</tr>
))}
{!items.length ? (
<tr>
<td className="px-4 py-4 text-slate-500" colSpan={8}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</main>
);
}
@@ -156,6 +156,21 @@
max-width: 420px;
}
.trialPromo {
margin-top: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.24);
background: rgba(34, 211, 238, 0.08);
color: #a5f3fc;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
/* ── Card grid ── */
.grid {
position: relative;
@@ -141,6 +141,11 @@ export function UnlockProOverlay({
? "High-precision weather intelligence, delivered everywhere."
: "全球最精准的高精度气象推送,全平台覆盖"}
</p>
<div className={s.trialPromo}>
{isEn
? "New users get a free 3-day Pro trial before billing."
: "新用户可先免费体验 3 天 Pro,再决定是否付费。"}
</div>
</div>
{/* ── Cards ── */}
@@ -4,7 +4,6 @@ import React from "react";
import {
BarChart2,
Target,
ShieldAlert,
Zap,
Info,
Activity,
@@ -23,7 +22,7 @@ export function AnalyticsPanel({
data,
t = {}, // Default empty for now, can be expanded via context or props
}: AnalyticsPanelProps) {
const { overview, market_scan, models, ai_analysis } = data;
const { overview, market_scan, models } = data;
const modelEntries = Object.entries(models)
.filter(([_, v]) => v !== undefined && v !== null)
@@ -222,25 +221,6 @@ export function AnalyticsPanel({
))}
</div>
</section>
{ai_analysis && (
<section className="pt-2">
<div className="flex items-center gap-2 mb-3">
<Activity className="h-3 w-3 text-amber-500" />
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
AI COGNITIVE ANALYSIS
</span>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-2 opacity-20">
<ShieldAlert className="w-8 h-8 text-amber-500" />
</div>
<p className="text-[11px] leading-relaxed text-zinc-400 relative z-10 font-medium">
{ai_analysis}
</p>
</div>
</section>
)}
</div>
{/* Execute Scan Footer */}
+56 -24
View File
@@ -48,14 +48,14 @@ export const DOCS_PAGES: DocsPage[] = [
title: "PolyWeather 是什么",
blocks: [
{ type: "paragraph", text: "PolyWeather 不是通用天气 App。它面向天气衍生品和温度市场,重点回答三个问题:今天最高温大概会落在哪个区间、机场或官方结算站会不会被压温、市场有没有明显错定价。" },
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、实况、机场预报和结算规则整合成交易可用的信息。" },
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、机场主站实况、官方增强站网、机场预报和结算规则整合成交易可用的信息。" },
],
},
{
id: "core-modules",
title: "你会在页面上看到什么",
blocks: [
{ type: "bullets", items: ["今日日内分析:围绕今日峰值窗口,解释近地面信号、高空结构和机场 TAF。", "多模型预报:展示 DEB 与多模型最高温预测,帮助判断市场当前最热桶是否合理。", "历史对账:查看近 15 天已结算样本、DEB MAE 与最佳单模型表现。", "机场报文解读:把 METAR / TAF 缩写翻成普通用户能理解的话。"] },
{ type: "bullets", items: ["今日日内分析:围绕今日峰值窗口,解释近地面信号、高空结构和机场 TAF。", "多模型预报:展示 DEB 与多模型最高温预测,帮助判断市场当前最热桶是否合理。", "历史对账:查看近 15 天已结算样本、DEB MAE 与最佳单模型表现。", "站点结构:明确区分结算站点、机场主站和官方增强站网。"] },
],
},
{
@@ -76,14 +76,14 @@ export const DOCS_PAGES: DocsPage[] = [
title: "What PolyWeather is",
blocks: [
{ type: "paragraph", text: "PolyWeather is not a generic weather app. It is built for weather derivatives and temperature markets, with one job: estimate the likely high-temperature bucket, explain whether the airport or official settlement site may get capped, and surface whether the market is mispricing that outcome." },
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, observations, airport forecasts, and settlement rules into usable trading context." },
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, airport-primary observations, official nearby networks, airport forecasts, and settlement rules into usable trading context." },
],
},
{
id: "core-modules",
title: "What you see on the site",
blocks: [
{ type: "bullets", items: ["Intraday analysis: peak-window focused reading of surface structure, upper-air structure, and airport TAF.", "Multi-model forecast: DEB versus major model highs, useful for checking whether the hottest market bucket is justified.", "History reconciliation: settled-sample MAE and hit-rate over the last 15 days.", "Airport narrative: plain-language translation of METAR / TAF shorthand."] },
{ type: "bullets", items: ["Intraday analysis: peak-window focused reading of surface structure, upper-air structure, and airport TAF.", "Multi-model forecast: DEB versus major model highs, useful for checking whether the hottest market bucket is justified.", "History reconciliation: settled-sample MAE and hit-rate over the last 15 days.", "Station structure: a clear split between the settlement station, airport primary observation, and official nearby network."] },
],
},
{
@@ -188,7 +188,7 @@ export const DOCS_PAGES: DocsPage[] = [
title: "什么叫机场端压温风险偏高",
blocks: [
{ type: "paragraph", text: "它的意思不是整座城市一定更冷,而是作为结算依据的机场站点,在峰值窗口里更可能因为云、阵雨或雷暴扰动,冲不到本来可能达到的更高温度。" },
{ type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看 METAR、HKO、MGM、NOAA 指定站点、Wunderground 指定站点等实际结算源。" },
{ type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看实际结算站点读数;页面上的官方增强站网只负责领先、偏移和空间分布判断,不会替代机场主站或官方结算站本身。" },
],
},
],
@@ -216,7 +216,7 @@ export const DOCS_PAGES: DocsPage[] = [
title: "What airport-side suppression risk means",
blocks: [
{ type: "paragraph", text: "It does not mean the entire city must run cooler. It means the airport station used for settlement is more likely to get capped by clouds, showers, or thunderstorm disruption during the peak window and fail to reach the next warmer bucket." },
{ type: "callout", tone: "warning", title: "Important distinction", text: "TAF explains whether the airport side may face suppressive weather over the next few hours. Settlement still comes from the actual settlement source such as METAR, HKO, MGM, a designated NOAA station, or a designated Wunderground station." },
{ type: "callout", tone: "warning", title: "Important distinction", text: "TAF explains whether the airport side may face suppressive weather over the next few hours. Settlement still comes from the actual settlement station reading, while the official nearby network is only an enhancement layer for lead/lag and spread, not a replacement anchor." },
],
},
],
@@ -228,12 +228,12 @@ export const DOCS_PAGES: DocsPage[] = [
group: "settlement",
content: {
"zh-CN": {
title: "结算来源说明",
description: "不同城市的结算口径不同。理解结算,比单纯看模型曲线更重要。",
title: "结算站点说明",
description: "不同城市的结算口径不同。理解结算站点,比单纯看模型曲线更重要。",
sections: [
{
id: "why-settlement-matters",
title: "为什么先看结算",
title: "为什么先看结算站点",
blocks: [
{ type: "paragraph", text: "同样是“城市最高温”,市场真正结算看的往往不是城区平均温度,而是规则指定的机场或官方站点。交易上最常见的错觉,是把城市体感温度当成结算温度。" },
],
@@ -242,25 +242,25 @@ export const DOCS_PAGES: DocsPage[] = [
id: "city-rules",
title: "当前主要口径",
blocks: [
{ type: "bullets", items: ["多数欧美机场市场:按机场 METAR 或机场主站实况结算。", "香港:按香港天文台 HKO 主口径,不接机场 TAF 作为主结算逻辑。", "台北、伊斯坦布尔、深圳等 NOAA 市场:按 weather.gov / NOAA 指定站点最终完成质控后的最高整度摄氏值结算,机场观测和市区体感不可混用。", "Ankara:结算主站以 LTAC / Esenboğa 为准,同时保留 Turkish MGM 作为领先结构参考。"] },
{ type: "bullets", items: ["多数机场市场:按机场 METAR 或机场主站实况结算。", "土耳其机场市场:机场主站仍以 METAR 为锚点,同时保留 Turkish MGM 作为领先结构参考。", "中国内地机场市场:机场主站仍以 METAR 为锚点,NMC 当前实况作为官方增强层,不直接替代机场结算站。", "香港 / 流浮山 / 台湾等明确官方站点市场:按规则指定的官方结算站点结算,不能拿机场 TAF 或城区体感替代。"] },
],
},
{
id: "common-mistakes",
title: "最常见的误解",
blocks: [
{ type: "bullets", items: ["TAF 不是结算,它只告诉你机场未来有没有压温扰动。", "市场按机场结算时,城区更热不代表市场就该结到更高温桶。", "NOAA 市场要优先看 weather.gov 指定站点的最终 Temp 列,不要拿其他站或第三方页面替代。", "香港和台北不能简单套用机场 TAF / METAR 主链逻辑。"] },
{ type: "bullets", items: ["TAF 不是结算站点,它只告诉你机场未来有没有压温扰动。", "市场按机场结算时,城区更热不代表市场就该结到更高温桶。", "官方增强站网是领先参考层,不等于它可以替代机场主站做结算锚点。", "香港、流浮山、台湾等明确官方站点市场,不能简单套用通用机场 TAF / METAR 主链逻辑。"] },
],
},
],
},
"en-US": {
title: "Settlement Sources",
description: "Settlement rules differ by city. Understanding the settlement source matters more than staring only at model curves.",
title: "Settlement Stations",
description: "Settlement rules differ by city. Understanding the settlement station matters more than staring only at model curves.",
sections: [
{
id: "why-settlement-matters",
title: "Why settlement source comes first",
title: "Why the settlement station comes first",
blocks: [
{ type: "paragraph", text: "A market may say “city high”, but the true settlement often comes from a designated airport or official site rather than the broader urban feel. One of the most common mistakes is to trade the city feel instead of the actual settlement station." },
],
@@ -269,14 +269,14 @@ export const DOCS_PAGES: DocsPage[] = [
id: "city-rules",
title: "Current primary rules",
blocks: [
{ type: "bullets", items: ["Most airport-linked Western markets: settle on airport METAR or the airport primary observing site.", "Hong Kong: settles on HKO, not on airport TAF as the main settlement logic.", "NOAA markets such as Taipei, Istanbul, and Shenzhen settle against the designated weather.gov / NOAA station using the finalized highest rounded whole-degree Celsius reading; airport observations and downtown feel should not be mixed.", "Ankara: settlement centers on LTAC / Esenboğa, with Turkish MGM retained as a leading-structure reference."] },
{ type: "bullets", items: ["Most airport-linked markets settle on airport METAR or the airport primary observing site.", "Turkish airport markets keep METAR as the airport anchor, with Turkish MGM retained as a leading-structure reference.", "Mainland China airport markets keep METAR as the airport anchor, while NMC current observations act as an official enhancement layer rather than a direct replacement anchor.", "Markets with explicitly designated official sites, such as Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts, should be anchored to those official settlement stations rather than generic airport logic."] },
],
},
{
id: "common-mistakes",
title: "Common mistakes",
blocks: [
{ type: "bullets", items: ["TAF is not a settlement source. It only tells you whether airport-side suppressive weather may appear.", "If the market settles on an airport site, a hotter downtown feel does not automatically justify a warmer settlement bucket.", "NOAA markets should anchor to the designated weather.gov Temp column once the date is finalized, not to a nearby third-party station page.", "Hong Kong and Taipei should not be forced into the generic airport TAF / METAR chain."] },
{ type: "bullets", items: ["TAF is not the settlement station. It only tells you whether airport-side suppressive weather may appear.", "If the market settles on an airport site, a hotter downtown feel does not automatically justify a warmer settlement bucket.", "The official nearby network is a lead/lag and spread layer. It should not be mistaken for the final settlement anchor unless the market explicitly names that station.", "Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts should not be forced into the generic airport TAF / METAR chain."] },
],
},
],
@@ -351,7 +351,7 @@ export const DOCS_PAGES: DocsPage[] = [
content: {
"zh-CN": {
title: "浏览器插件",
description: "侧边栏插件是主站的轻量入口,负责监控、基础判断和导流,不承载完整分析链路。",
description: "PolyWeather Side Panel 是一个面向天气交易场景的浏览器侧边栏工具,负责自动识别城市、展示简版走势与城市档案,并把用户导回完整分析页面。",
sections: [
{
id: "extension-install",
@@ -372,9 +372,25 @@ export const DOCS_PAGES: DocsPage[] = [
{
type: "bullets",
items: [
"自动识别当前市场 URL,对应切换城市。",
"展示风险徽章、城市档案、今日日内走势简版和多日预报。",
"提供今日日内分析、历史对账和返回主站的快捷入口。",
"自动识别当前 Polymarket 页面中的城市,也支持手动切换。",
"展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。",
"展示今日日内走势(简版):DEB 走势与机场主站实况 / 官方增强站网对照,可悬停查看时间与温度。",
"展示多日最高温预报(简版),并提供一键刷新与跳转主站入口。",
],
},
],
},
{
id: "extension-permission",
title: "权限与隐私",
blocks: [
{
type: "bullets",
items: [
"`tabs`:用于识别当前活动标签页 URL 并自动匹配城市。",
"`storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。",
"`sidePanel`:用于在浏览器侧边栏展示界面。",
"插件不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据。",
],
},
],
@@ -409,7 +425,7 @@ export const DOCS_PAGES: DocsPage[] = [
},
"en-US": {
title: "Browser Extension",
description: "The side-panel extension is a lightweight lead-in to the main site. It focuses on monitoring, basic bias, and traffic flow back to the full dashboard.",
description: "PolyWeather Side Panel is a browser side-panel tool for weather trading workflows. It auto-detects cities, shows compact intraday and city-profile context, and routes users back to the full dashboard.",
sections: [
{
id: "extension-install",
@@ -430,9 +446,25 @@ export const DOCS_PAGES: DocsPage[] = [
{
type: "bullets",
items: [
"Auto-detects the current market URL and switches the side panel to the matching city.",
"Shows risk badges, city profile, a compact intraday chart, and multi-day forecast.",
"Provides quick links into the main intraday analysis and history reconciliation views.",
"Auto-detects the current Polymarket page city, with manual switching also available.",
"Shows a city profile with settlement station, station distance, observation timestamp, and nearby station count.",
"Shows a compact intraday chart with DEB versus airport-primary observations and official nearby-network observations, including hoverable time and temperature.",
"Shows a compact multi-day daily-high forecast, plus refresh and jump-to-site actions.",
],
},
],
},
{
id: "extension-permission",
title: "Permissions and privacy",
blocks: [
{
type: "bullets",
items: [
"`tabs`: used to inspect the active tab URL and match the current city.",
"`storage`: used for local configuration and local cache only.",
"`sidePanel`: used to render the browser side panel UI.",
"The extension does not require login, does not collect personally identifiable information, and does not upload browsing history. It only requests weather endpoints when needed to render the panel.",
],
},
],
+277 -143
View File
@@ -13,6 +13,7 @@ import {
getCityRevision,
toCitySummary,
} from "@/lib/dashboard-client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import {
CityDetail,
CityListItem,
@@ -71,102 +72,15 @@ function getInitialProAccessState(): ProAccessState {
return {
loading: true,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
points: 0,
error: null,
};
}
const AI_EMPTY_PATTERNS = [
/暂无\s*AI\s*分析/i,
/当前以结构化气象与模型数据为主/i,
/No\s*AI\s*analysis\s*available/i,
/Structured\s+meteorological\s+and\s+model\s+data/i,
];
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function extractAiPayload(analysis: CityDetail["ai_analysis"]) {
if (!analysis) {
return {
bullets: [] as string[],
summary: "",
};
}
if (typeof analysis === "string") {
return {
bullets: [] as string[],
summary: normalizeText(analysis),
};
}
const summary =
normalizeText(analysis.summary) ||
normalizeText(analysis.text) ||
normalizeText(analysis.message);
const bulletsSource = Array.isArray(analysis.highlights)
? analysis.highlights
: Array.isArray(analysis.points)
? analysis.points
: [];
return {
bullets: bulletsSource.map((item) => normalizeText(item)).filter(Boolean),
summary,
};
}
function hasMeaningfulAiAnalysis(analysis: CityDetail["ai_analysis"]) {
const parsed = extractAiPayload(analysis);
const hasBullets = parsed.bullets.length > 0;
const hasSummary =
Boolean(parsed.summary) &&
!AI_EMPTY_PATTERNS.some((pattern) => pattern.test(parsed.summary));
return hasBullets || hasSummary;
}
function normalizeMetarSignature(detail?: CityDetail) {
if (!detail) return "";
const metar = normalizeText(detail.current?.raw_metar)
.replace(/\s+/g, " ")
.toUpperCase();
const obsTime = normalizeText(detail.current?.obs_time);
return [metar, obsTime].filter(Boolean).join("|");
}
function mergeAiAnalysisIfStable(
previousDetail: CityDetail | undefined,
nextDetail: CityDetail,
) {
if (!previousDetail) return nextDetail;
if (hasMeaningfulAiAnalysis(nextDetail.ai_analysis)) return nextDetail;
if (!hasMeaningfulAiAnalysis(previousDetail.ai_analysis)) return nextDetail;
const prevTemp = Number(previousDetail.current?.temp);
const nextTemp = Number(nextDetail.current?.temp);
const tempUnchanged =
Number.isFinite(prevTemp) &&
Number.isFinite(nextTemp) &&
prevTemp === nextTemp;
const prevMetar = normalizeMetarSignature(previousDetail);
const nextMetar = normalizeMetarSignature(nextDetail);
const metarUnchanged =
Boolean(prevMetar) && Boolean(nextMetar) && prevMetar === nextMetar;
if (!tempUnchanged && !metarUnchanged) {
return nextDetail;
}
return {
...nextDetail,
ai_analysis: previousDetail.ai_analysis,
};
}
function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
const normalizedDate = String(targetDate || "").trim() || "local";
return `${cityName}::${normalizedDate}`;
@@ -174,30 +88,64 @@ function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
const EAGER_CITY_SUMMARIES_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES === "true";
function countAvailableModels(
detail?: CityDetail | null,
targetDate?: string | null,
): number {
if (!detail) return 0;
const date = String(targetDate || detail.local_date || "").trim();
const dailyModels = detail.multi_model_daily?.[date]?.models;
const models = dailyModels && typeof dailyModels === "object"
? dailyModels
: detail.multi_model || {};
return Object.values(models).filter((value) =>
Number.isFinite(Number(value)),
).length;
}
function countForecastDays(detail?: CityDetail | null): number {
const daily = detail?.forecast?.daily;
return Array.isArray(daily) ? daily.length : 0;
}
function hasSparseModelCoverage(
detail?: CityDetail | null,
targetDate?: string | null,
): boolean {
return countAvailableModels(detail, targetDate) <= 1;
}
function hasSparseDetailCoverage(
detail?: CityDetail | null,
targetDate?: string | null,
): boolean {
if (!detail) return true;
return (
hasSparseModelCoverage(detail, targetDate) || countForecastDays(detail) <= 1
);
}
export function DashboardStoreProvider({
children,
}: {
children: React.ReactNode;
}) {
const initialCache = dashboardClient.readCityDetailCacheBundle();
const initialCacheRef = useRef<ReturnType<
typeof dashboardClient.readCityDetailCacheBundle
> | null>(null);
const [cities, setCities] = useState<CityListItem[]>([]);
const [cityDetailsByName, setCityDetailsByName] = useState<
Record<string, CityDetail>
>(() => initialCache.details);
>({});
const [citySummariesByName, setCitySummariesByName] = useState<
Record<string, CitySummary>
>(() =>
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
>({});
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>(() => initialCache.meta);
>({});
const [marketScanByCityName, setMarketScanByCityName] = useState<
Record<string, MarketScan>
>({});
@@ -220,18 +168,13 @@ export function DashboardStoreProvider({
const mapStopMotionRef = useRef<() => void>(() => {});
const hydratedSelectionRef = useRef(false);
const hydratedProCacheRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>(
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
const selectedDetail = selectedCity
? cityDetailsByName[selectedCity] || null
: null;
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedDetail =
selectedCity && proAccess.subscriptionActive
? cityDetailsByName[selectedCity] || null
: null;
const selectedMarketDate =
futureModalDate ||
selectedForecastDate ||
@@ -240,16 +183,28 @@ export function DashboardStoreProvider({
const selectedMarketScanKey = selectedCity
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
: null;
const selectedMarketScan = selectedCity
? marketScanByCityName[selectedMarketScanKey || ""] || null
: null;
const selectedMarketScan =
selectedCity && proAccess.subscriptionActive
? marketScanByCityName[selectedMarketScanKey || ""] || null
: null;
useEffect(() => {
if (proAccess.loading) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
dashboardClient.clearCityDetailCache();
return;
}
dashboardClient.writeCityDetailCacheBundle(
cityDetailsByName,
cityDetailMetaByName,
);
}, [cityDetailMetaByName, cityDetailsByName]);
}, [
cityDetailMetaByName,
cityDetailsByName,
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
]);
useEffect(() => {
citySummariesRef.current = citySummariesByName;
@@ -259,6 +214,43 @@ export function DashboardStoreProvider({
proAccessRef.current = proAccess;
}, [proAccess]);
useEffect(() => {
if (proAccess.loading) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
hydratedProCacheRef.current = false;
initialCacheRef.current = null;
return;
}
if (hydratedProCacheRef.current) return;
hydratedProCacheRef.current = true;
const cached =
initialCacheRef.current || dashboardClient.readCityDetailCacheBundle();
initialCacheRef.current = cached;
if (!Object.keys(cached.details).length) return;
setCityDetailsByName(cached.details);
setCityDetailMetaByName(cached.meta);
setCitySummariesByName((current) => ({
...Object.fromEntries(
Object.entries(cached.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
...current,
}));
}, [proAccess.authenticated, proAccess.loading, proAccess.subscriptionActive]);
useEffect(() => {
if (proAccess.loading) return;
if (proAccess.authenticated && proAccess.subscriptionActive) return;
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
setMarketScanByCityName({});
}, [proAccess]);
const scheduleBackgroundDetailRefresh = (
cityName: string,
cached: CityDetail,
@@ -282,7 +274,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
const detail = latestDetail;
setCityDetailsByName((current) => ({
...current,
@@ -306,7 +298,13 @@ export function DashboardStoreProvider({
const ensureCityDetail = async (cityName: string, force = false) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date);
if (
!force &&
cached &&
!cachedIsSparse &&
dashboardClient.isCityDetailFresh(cachedMeta)
) {
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
return cached;
}
@@ -316,6 +314,28 @@ export function DashboardStoreProvider({
const summary = await dashboardClient.getCitySummary(cityName);
const revision = getCityRevision(summary);
if (revision && revision === cachedMeta?.revision) {
if (cachedIsSparse) {
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: true,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
return detail;
}
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
@@ -333,7 +353,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
const detail = latestDetail;
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
@@ -352,6 +372,39 @@ export function DashboardStoreProvider({
return detail;
};
useEffect(() => {
if (proAccess.loading) return;
if (!selectedCity) return;
if (!isPanelOpen) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) return;
if (cityDetailsByName[selectedCity]) return;
let cancelled = false;
setLoadingState((current) => ({ ...current, cityDetail: true }));
void ensureCityDetail(selectedCity, false)
.then((detail) => {
if (cancelled) return;
setSelectedForecastDate(detail.local_date);
})
.catch(() => {})
.finally(() => {
if (cancelled) return;
setLoadingState((current) => ({ ...current, cityDetail: false }));
});
return () => {
cancelled = true;
};
}, [
cityDetailsByName,
ensureCityDetail,
isPanelOpen,
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
selectedCity,
]);
const ensureCityMarketScan = async (
cityName: string,
force = false,
@@ -406,13 +459,19 @@ export function DashboardStoreProvider({
}
const payload = (await response.json()) as {
authenticated?: boolean;
user_id?: string | null;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_expires_at?: string | null;
points?: number;
};
setProAccess({
loading: false,
authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null,
subscriptionActive: payload.subscription_active === true,
subscriptionPlanCode: payload.subscription_plan_code ?? null,
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
points: payload.points ?? 0,
error: null,
});
@@ -420,7 +479,10 @@ export function DashboardStoreProvider({
setProAccess({
loading: false,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
points: 0,
error: String(error),
});
@@ -436,6 +498,40 @@ export function DashboardStoreProvider({
}, []);
useEffect(() => {
if (proAccess.loading || !proAccess.authenticated || !proAccess.userId) {
return;
}
if (
markAnalyticsOnce(`dashboard-active:${proAccess.userId}`, "session")
) {
trackAppEvent("dashboard_active", {
subscription_active: proAccess.subscriptionActive,
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
const isTrialPlan = /trial/i.test(
String(proAccess.subscriptionPlanCode || ""),
);
if (
isTrialPlan &&
markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local")
) {
trackAppEvent("signup_completed", {
source: "auth_me_trial",
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
}, [
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
proAccess.subscriptionPlanCode,
proAccess.userId,
]);
useEffect(() => {
if (!EAGER_CITY_SUMMARIES_ENABLED) return;
if (!cities.length) return;
const queue = cities
@@ -491,9 +587,33 @@ export function DashboardStoreProvider({
await refreshProAccess();
}
const access = proAccessRef.current;
if (!access.authenticated || !access.subscriptionActive) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
if (!citySummariesRef.current[cityName]) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
setCitySummariesByName((current) => ({
...current,
[cityName]: summary,
}));
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
} else {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
return;
}
const cachedDetail = cityDetailsByName[cityName];
const needsDetailRefresh = hasSparseDetailCoverage(
cachedDetail,
cachedDetail?.local_date,
);
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
const detail = await ensureCityDetail(cityName);
const detail = await ensureCityDetail(cityName, needsDetailRefresh);
setSelectedForecastDate(detail.local_date);
if (access.authenticated && access.subscriptionActive) {
// 预热市场数据,不做 await 阻塞,后台静默拉取
@@ -550,34 +670,39 @@ export function DashboardStoreProvider({
};
const refreshAll = async () => {
const previousSelectedDetail = selectedCity
? cityDetailsByName[selectedCity]
: undefined;
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
if (selectedCity) {
const access = proAccessRef.current;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
});
const detail = mergeAiAnalysisIfStable(
previousSelectedDetail,
latestDetail,
);
setCityDetailsByName({ [selectedCity]: detail });
setCitySummariesByName((current) => ({
...current,
[selectedCity]: toCitySummary(detail),
}));
setCityDetailMetaByName({
[selectedCity]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
});
setSelectedForecastDate(detail.local_date);
if (access.authenticated && access.subscriptionActive) {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
});
const detail = latestDetail;
setCityDetailsByName({ [selectedCity]: detail });
setCitySummariesByName((current) => ({
...current,
[selectedCity]: toCitySummary(detail),
}));
setCityDetailMetaByName({
[selectedCity]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
});
setSelectedForecastDate(detail.local_date);
} else {
const summary = await dashboardClient.getCitySummary(selectedCity, {
force: true,
});
setCitySummariesByName((current) => ({
...current,
[selectedCity]: summary,
}));
}
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
@@ -642,6 +767,12 @@ export function DashboardStoreProvider({
mapStopMotionRef.current();
setFutureModalDate(dateStr);
if (!selectedCity || !proAccess.subscriptionActive) return;
const cachedDetail = cityDetailsByName[selectedCity];
const needsDetailRefresh =
!forceRefresh && hasSparseDetailCoverage(cachedDetail, dateStr);
if (needsDetailRefresh) {
void ensureCityDetail(selectedCity, true).catch(() => {});
}
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan(
@@ -668,6 +799,9 @@ export function DashboardStoreProvider({
setFutureModalDate(cachedDetail.local_date);
}
if (!proAccess.subscriptionActive) return;
const needsDetailRefresh =
!forceRefresh &&
hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
setLoadingState((current) => ({
...current,
@@ -678,7 +812,7 @@ export function DashboardStoreProvider({
try {
const detail = await ensureCityDetail(
selectedCity,
Boolean(forceRefresh),
Boolean(forceRefresh || needsDetailRefresh),
);
setSelectedForecastDate(detail.local_date);
setFutureModalDate(detail.local_date);
+130 -41
View File
@@ -8,7 +8,7 @@ import {
CitySummary,
NearbyStation,
} from "@/lib/dashboard-types";
import { pickAnkaraNearbyStations } from "@/lib/dashboard-utils";
import { pickMapNearbyStations } from "@/lib/dashboard-utils";
interface UseLeafletMapArgs {
cities: CityListItem[];
@@ -77,9 +77,42 @@ function createMarkerIcon(
});
}
function getMarkerSignature(
city: CityListItem,
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
) {
return [
city.display_name,
city.risk_level,
city.temp_unit,
city.lat,
city.lon,
snapshot?.current?.temp ?? "",
].join("|");
}
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
const sanitizeWindText = (value?: string | null) => {
const text = String(value || "").trim();
if (!text || text === "9999") return "";
return text;
};
const symbol = detail.temp_symbol || "°C";
const rawLabel =
station.station_label ||
station.name ||
station.station_code ||
station.icao ||
"实测 (OBS)";
const label =
String(station.source_code || station.source_label || "").trim().toLowerCase() === "nmc" &&
/\(NMC\)$/i.test(String(rawLabel)) &&
!String(rawLabel).includes("区域实况")
? String(rawLabel).replace(/\s*\(NMC\)$/i, "区域实况 (NMC)")
: rawLabel;
let windHtml = "";
const windDirectionText = sanitizeWindText(station.wind_direction_text);
const windPowerText = sanitizeWindText(station.wind_power_text);
if (station.wind_dir != null) {
const rotation = (Number(station.wind_dir) + 180) % 360;
@@ -91,6 +124,13 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
<span class="wind-val">${speed}</span>
</div>
`;
} else if (windDirectionText || windPowerText) {
const windText = [windDirectionText, windPowerText].filter(Boolean).join(" ");
windHtml = `
<div class="nearby-wind">
<span class="wind-val">${windText}</span>
</div>
`;
}
return `
@@ -100,7 +140,7 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
<div class="pulse-core"></div>
</div>
<div class="nearby-content">
<span class="nearby-label">${station.name || station.icao || "OBS"}</span>
<span class="nearby-label">${label}</span>
<div class="nearby-stats">
<span class="nearby-temp-val">${station.temp ?? "--"}</span>
<span class="nearby-temp-unit">${symbol}</span>
@@ -111,6 +151,44 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
`;
}
function getNearbyMarkerDisplayOffset(
detail: CityDetail,
station: NearbyStation,
index: number,
) {
const cityLat = Number(detail.lat);
const cityLon = Number(detail.lon);
const stationLat = Number(station.lat);
const stationLon = Number(station.lon);
if (
!Number.isFinite(cityLat) ||
!Number.isFinite(cityLon) ||
!Number.isFinite(stationLat) ||
!Number.isFinite(stationLon)
) {
return { x: 0, y: 0 };
}
const latDiff = Math.abs(cityLat - stationLat);
const lonDiff = Math.abs(cityLon - stationLon);
const isNearCityAnchor = latDiff < 0.02 && lonDiff < 0.02;
if (!isNearCityAnchor) {
return { x: 0, y: 0 };
}
const presets = [
{ x: 0, y: -58 },
{ x: 76, y: -34 },
{ x: -76, y: -34 },
{ x: 72, y: 34 },
{ x: -72, y: 34 },
];
return presets[index % presets.length];
}
export function useLeafletMap({
cities,
cityDetailsByName,
@@ -231,7 +309,7 @@ export function useLeafletMap({
}, [cities]);
const lastCityDataRef = useRef<
Record<string, { temp?: number | null; risk?: string }>
Record<string, string>
>({});
// Handle marker synchronization
@@ -245,29 +323,32 @@ export function useLeafletMap({
if (canceled) return;
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
const cityNames = new Set(cities.map((city) => city.name));
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (cityNames.has(name)) return;
map.removeLayer(entry.marker);
delete currentMarkers[name];
delete lastCityDataRef.current[name];
});
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
const signature = getMarkerSignature(city, snapshot);
const previousSignature = lastCityDataRef.current[city.name];
if (existing) {
if (dataChanged) {
if (existing.city.lat !== city.lat || existing.city.lon !== city.lon) {
existing.marker.setLatLng([city.lat, city.lon]);
}
if (previousSignature !== signature) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
currentMarkers[city.name] = { city, marker: existing.marker };
lastCityDataRef.current[city.name] = signature;
return;
}
@@ -278,25 +359,24 @@ export function useLeafletMap({
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
const currentMap = mapRef.current;
currentMap?.stop();
if (currentMap && !suspendMotion) {
currentMap.flyTo([city.lat, city.lon], 11, {
animate: true,
duration: 1.05,
easeLinearity: 0.22,
});
lastMovedCityRef.current = city.name;
} else {
lastMovedCityRef.current = null;
}
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
currentMarkers[city.name] = { city, marker };
lastCityDataRef.current[city.name] = signature;
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
})
: null;
@@ -325,13 +405,7 @@ export function useLeafletMap({
function renderNearbyStations(detail: CityDetail, preserveView = false) {
layer.clearLayers();
const allNearby = Array.isArray(detail.mgm_nearby)
? detail.mgm_nearby
: [];
const nearbyStations =
String(detail.name || "").toLowerCase() === "ankara"
? pickAnkaraNearbyStations(allNearby)
: allNearby;
const nearbyStations = pickMapNearbyStations(detail);
if (!nearbyStations.length) {
if (!preserveView && detail.lat != null && detail.lon != null) {
@@ -354,15 +428,30 @@ export function useLeafletMap({
const sLon = Number(station.lon);
// Ignore invalid (0,0) or null coordinates which cause global zoom-out
if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return;
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
const displayOffset = getNearbyMarkerDisplayOffset(detail, station, latLngs.length);
const styleAttr =
displayOffset.x || displayOffset.y
? ` style="transform: translate(${displayOffset.x}px, ${displayOffset.y}px);"`
: "";
const icon = L.divIcon({
className: "",
html: buildNearbyIconHtml(detail, station),
html: `
<div class="nearby-marker-shell"${styleAttr}>
${buildNearbyIconHtml(detail, station)}
</div>
`,
iconAnchor: [16, 19],
iconSize: [240, 38],
});
L.marker([sLat, sLon], { icon }).addTo(layer);
L.marker([sLat, sLon], {
icon,
interactive: false,
keyboard: false,
bubblingMouseEvents: false,
}).addTo(layer);
latLngs.push([sLat, sLon]);
});
+94
View File
@@ -0,0 +1,94 @@
"use client";
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
type TrackableAnalyticsEvent =
| "signup_completed"
| "dashboard_active"
| "paywall_feature_clicked"
| "paywall_viewed"
| "checkout_started"
| "checkout_succeeded";
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
const SESSION_ID_KEY = "polyweather:analytics:session-id";
function isClient() {
return typeof window !== "undefined";
}
function randomId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function getStoredId(storage: Storage, key: string) {
let value = storage.getItem(key);
if (!value) {
value = randomId();
storage.setItem(key, value);
}
return value;
}
export function getAnalyticsClientId() {
if (!isClient()) return "";
try {
return getStoredId(window.localStorage, CLIENT_ID_KEY);
} catch {
return "";
}
}
export function getAnalyticsSessionId() {
if (!isClient()) return "";
try {
return getStoredId(window.sessionStorage, SESSION_ID_KEY);
} catch {
return "";
}
}
export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") {
if (!isClient()) return false;
const storage = scope === "local" ? window.localStorage : window.sessionStorage;
const normalizedKey = `polyweather:analytics:once:${key}`;
try {
if (storage.getItem(normalizedKey) === "1") {
return false;
}
storage.setItem(normalizedKey, "1");
return true;
} catch {
return true;
}
}
export function trackAppEvent(
eventType: TrackableAnalyticsEvent,
payload: Record<string, unknown> = {},
) {
if (!isClient() || !ANALYTICS_ENABLED) return;
const body = {
event_type: eventType,
client_id: getAnalyticsClientId() || undefined,
session_id: getAnalyticsSessionId() || undefined,
payload: {
...payload,
path: window.location.pathname,
href: window.location.href,
captured_at: new Date().toISOString(),
},
};
void fetch("/api/analytics/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {});
}
+7 -1
View File
@@ -11,6 +11,10 @@ type HeaderBuildResult = {
response: NextResponse | null;
};
type HeaderBuildOptions = {
includeSupabaseIdentity?: boolean;
};
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
@@ -22,6 +26,7 @@ function extractBearerToken(headerValue: string | null) {
export async function buildBackendRequestHeaders(
request: NextRequest,
options?: HeaderBuildOptions,
): Promise<HeaderBuildResult> {
const headers = new Headers({
Accept: "application/json",
@@ -32,7 +37,8 @@ export async function buildBackendRequestHeaders(
}
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
if (hasSupabaseServerEnv()) {
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
if (hasSupabaseServerEnv() && includeSupabaseIdentity) {
const passthroughResponse = new NextResponse(null, { status: 200 });
const supabase = createSupabaseRouteClient(request, passthroughResponse);
const {
+21
View File
@@ -52,11 +52,31 @@ function normalizeRevisionPart(value: unknown) {
export function getCityRevision(source?: CityDetail | CitySummary | null) {
if (!source) return "";
const modelDaily =
"multi_model_daily" in source && source.multi_model_daily
? source.multi_model_daily?.[source.local_date || ""]
: null;
const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null);
const forecastFootprint =
"forecast" in source && Array.isArray(source.forecast?.daily)
? source.forecast.daily
.map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`)
.join("|")
: "";
return [
normalizeRevisionPart(source.updated_at),
normalizeRevisionPart(source.current?.obs_time),
normalizeRevisionPart(source.current?.temp),
normalizeRevisionPart(source.deb?.prediction),
normalizeRevisionPart(
modelFootprint && typeof modelFootprint === "object"
? Object.keys(modelFootprint)
.sort()
.map((key) => `${key}:${normalizeRevisionPart(modelFootprint[key])}`)
.join("|")
: "",
),
normalizeRevisionPart(forecastFootprint),
].join("|");
}
@@ -74,6 +94,7 @@ export function toCitySummary(detail: CityDetail): CitySummary {
deb: {
prediction: detail.deb?.prediction,
},
deviation_monitor: detail.deviation_monitor,
risk: {
level: detail.risk?.level,
warning: detail.risk?.warning,
+274 -40
View File
@@ -57,23 +57,11 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
href: "https://aviationweather.gov/data/metar/?id=VHHH&decoded=1&taf=1",
kind: "metar",
},
],
"shek kong": [
{
label: "香港天文台",
href: "https://www.hko.gov.hk/en/index.html",
label: "流浮山站(HKO",
href: "https://www.hko.gov.hk/sc/wxinfo/ts/index.htm",
kind: "agency",
},
{
label: "HKO 区域天气数据",
href: "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv",
kind: "agency",
},
{
label: "VHSK Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=VHSK",
kind: "metar",
},
],
"lau fau shan": [
{
@@ -82,34 +70,51 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
kind: "agency",
},
{
label: "HKO 区域天气数据",
href: "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv",
kind: "agency",
},
{
label: "HKO 实时读数页",
href: "https://www.hko.gov.hk/textonly/v2/forecast/text_readings_e.htm",
kind: "agency",
label: "流浮山站(HKO",
href: "https://www.hko.gov.hk/sc/wxinfo/ts/index.htm",
kind: "airport",
},
],
taipei: [
{
label: "NOAA RCTP Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=RCTP",
label: "Wunderground RCSS",
href: "https://www.wunderground.com/history/daily/tw/taipei/RCSS",
kind: "agency",
},
{
label: "桃园机场",
href: "https://www.taoyuan-airport.com/",
label: "台北松山机场",
href: "https://www.tsa.gov.tw/?lang=en",
kind: "airport",
},
{
label: "RCTP METAR",
href: "https://aviationweather.gov/data/metar/?id=RCTP&decoded=1&taf=1",
label: "RCSS METAR",
href: "https://aviationweather.gov/data/metar/?id=RCSS&decoded=1&taf=1",
kind: "metar",
},
],
busan: [
{
label: "Wunderground RKPK",
href: "https://www.wunderground.com/history/daily/kr/busan/RKPK",
kind: "agency",
},
{
label: "金海国际机场",
href: "https://www.airport.co.kr/gimhaeeng/index.do",
kind: "airport",
},
{
label: "RKPK METAR",
href: "https://aviationweather.gov/data/metar/?id=RKPK&decoded=1&taf=1",
kind: "metar",
},
],
istanbul: [
{
label: "MGM",
href: "https://www.mgm.gov.tr/",
kind: "agency",
},
{
label: "NOAA LTFM Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=LTFM",
@@ -126,6 +131,23 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
kind: "metar",
},
],
moscow: [
{
label: "NOAA UUWW Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=UUWW",
kind: "agency",
},
{
label: "Vnukovo International Airport",
href: "https://vnukovo.ru/en/",
kind: "airport",
},
{
label: "UUWW METAR",
href: "https://metar-taf.com/UUWW",
kind: "metar",
},
],
london: [
{
label: "Met Office",
@@ -150,6 +172,108 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
kind: "metar",
},
],
"los angeles": [
{
label: "NWS Los Angeles/Oxnard",
href: "https://www.weather.gov/lox/",
kind: "agency",
},
{
label: "LAX Airport",
href: "https://www.flylax.com/",
kind: "airport",
},
{
label: "KLAX METAR",
href: "https://aviationweather.gov/data/metar/?id=KLAX&decoded=1&taf=1",
kind: "metar",
},
],
"san francisco": [
{
label: "NWS San Francisco Bay Area",
href: "https://www.weather.gov/mtr/",
kind: "agency",
},
{
label: "SFO Airport",
href: "https://www.flysfo.com/",
kind: "airport",
},
{
label: "KSFO METAR",
href: "https://aviationweather.gov/data/metar/?id=KSFO&decoded=1&taf=1",
kind: "metar",
},
],
aurora: [
{
label: "NWS Denver/Boulder",
href: "https://www.weather.gov/bou/",
kind: "agency",
},
{
label: "Buckley Space Force Base",
href: "https://www.buckley.spaceforce.mil/",
kind: "airport",
},
{
label: "KBKF METAR",
href: "https://aviationweather.gov/data/metar/?id=KBKF&decoded=1&taf=1",
kind: "metar",
},
],
austin: [
{
label: "NWS Austin/San Antonio",
href: "https://www.weather.gov/ewx/",
kind: "agency",
},
{
label: "Austin-Bergstrom Airport",
href: "https://www.austintexas.gov/airport",
kind: "airport",
},
{
label: "KAUS METAR",
href: "https://aviationweather.gov/data/metar/?id=KAUS&decoded=1&taf=1",
kind: "metar",
},
],
houston: [
{
label: "NWS Houston/Galveston",
href: "https://www.weather.gov/hgx/",
kind: "agency",
},
{
label: "William P. Hobby Airport",
href: "https://www.fly2houston.com/hobby",
kind: "airport",
},
{
label: "KHOU METAR",
href: "https://aviationweather.gov/data/metar/?id=KHOU&decoded=1&taf=1",
kind: "metar",
},
],
"mexico city": [
{
label: "SMN",
href: "https://smn.conagua.gob.mx/",
kind: "agency",
},
{
label: "AICM",
href: "https://www.aicm.com.mx/",
kind: "airport",
},
{
label: "MMMX METAR",
href: "https://aviationweather.gov/data/metar/?id=MMMX&decoded=1&taf=1",
kind: "metar",
},
],
ankara: [
{
label: "MGM",
@@ -188,10 +312,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
shanghai: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 浦东天气",
href: "https://m.nmc.cn/publish/forecast/ASH/pudong.html",
kind: "agency",
},
{
label: "上海浦东国际机场",
href: "https://www.shanghai-airport.com/",
kind: "airport",
},
{
label: "ZSPD METAR",
href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1",
@@ -380,10 +509,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
chengdu: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 双流天气",
href: "https://m.nmc.cn/publish/forecast/ASC/shuangliu.html",
kind: "agency",
},
{
label: "成都双流国际机场",
href: "https://www.cdairport.com/",
kind: "airport",
},
{
label: "ZUUU METAR",
href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1",
@@ -392,10 +526,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
chongqing: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 渝北天气",
href: "https://m.nmc.cn/publish/forecast/ACQ/yubei.html",
kind: "agency",
},
{
label: "重庆江北国际机场",
href: "https://www.cqa.cn/",
kind: "airport",
},
{
label: "ZUCK METAR",
href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1",
@@ -404,8 +543,8 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
shenzhen: [
{
label: "NOAA ZGSZ Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=ZGSZ",
label: "NMC 深圳天气",
href: "https://m.nmc.cn/publish/forecast/AGD/shenzuo.html",
kind: "agency",
},
{
@@ -419,12 +558,102 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
kind: "metar",
},
],
"kuala lumpur": [
{
label: "Wunderground WMKK",
href: "https://www.wunderground.com/history/daily/my/sepang-district/WMKK",
kind: "agency",
},
{
label: "吉隆坡国际机场",
href: "https://airports.malaysiaairports.com.my/klia",
kind: "airport",
},
{
label: "WMKK METAR",
href: "https://aviationweather.gov/data/metar/?id=WMKK&decoded=1&taf=1",
kind: "metar",
},
],
jakarta: [
{
label: "Wunderground WIHH",
href: "https://www.wunderground.com/history/daily/id/jakarta/WIHH",
kind: "agency",
},
{
label: "Halim Perdanakusuma Airport",
href: "https://www.angkasapura2.co.id/en/airport/read/HLP",
kind: "airport",
},
{
label: "WIHH METAR",
href: "https://aviationweather.gov/data/metar/?id=WIHH&decoded=1&taf=1",
kind: "metar",
},
],
helsinki: [
{
label: "Wunderground EFHK",
href: "https://www.wunderground.com/history/daily/fi/vantaa/EFHK",
kind: "agency",
},
{
label: "Helsinki Airport",
href: "https://www.finavia.fi/en/airports/helsinki-airport",
kind: "airport",
},
{
label: "EFHK METAR",
href: "https://aviationweather.gov/data/metar/?id=EFHK&decoded=1&taf=1",
kind: "metar",
},
],
amsterdam: [
{
label: "Wunderground EHAM",
href: "https://www.wunderground.com/history/daily/nl/schiphol/EHAM",
kind: "agency",
},
{
label: "Amsterdam Airport Schiphol",
href: "https://www.schiphol.nl/en/",
kind: "airport",
},
{
label: "EHAM METAR",
href: "https://aviationweather.gov/data/metar/?id=EHAM&decoded=1&taf=1",
kind: "metar",
},
],
"panama city": [
{
label: "Wunderground MPMG",
href: "https://www.wunderground.com/history/daily/pa/panama-city/MPMG",
kind: "agency",
},
{
label: "Marcos A. Gelabert Airport",
href: "https://www.aeropuertos.net/aeropuerto-internacional-marcos-a-gelabert/",
kind: "airport",
},
{
label: "MPMG METAR",
href: "https://aviationweather.gov/data/metar/?id=MPMG&decoded=1&taf=1",
kind: "metar",
},
],
beijing: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 顺义天气",
href: "https://m.nmc.cn/publish/forecast/ABJ/shunyi.html",
kind: "agency",
},
{
label: "北京首都国际机场",
href: "https://www.bcia.com.cn/",
kind: "airport",
},
{
label: "ZBAA METAR",
href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1",
@@ -433,10 +662,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
wuhan: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 武汉天气",
href: "https://m.nmc.cn/publish/forecast/AHB/wuhan.html",
kind: "agency",
},
{
label: "武汉天河国际机场",
href: "https://www.whairport.com/",
kind: "airport",
},
{
label: "ZHHH METAR",
href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1",
+94
View File
@@ -6,6 +6,11 @@ export interface CityListItem {
lat: number;
lon: number;
risk_level: RiskLevel;
deb_recent_tier?: RiskLevel;
deb_recent_hit_rate?: number | null;
deb_recent_sample_count?: number;
deb_recent_mae?: number | null;
deb_recent_last_date?: string | null;
risk_emoji?: string;
airport: string;
icao: string;
@@ -13,6 +18,10 @@ export interface CityListItem {
is_major?: boolean;
settlement_source?: string;
settlement_source_label?: string;
settlement_station_code?: string;
settlement_station_label?: string;
network_provider?: string;
network_provider_label?: string;
}
export interface ProbabilityBucket {
@@ -85,17 +94,31 @@ export interface AirportCurrentConditions {
wx_desc?: string | null;
raw_metar?: string | null;
source_label?: string | null;
station_code?: string | null;
station_label?: string | null;
is_airport_station?: boolean;
is_official?: boolean;
is_settlement_anchor?: boolean;
}
export interface NearbyStation {
name?: string;
icao?: string;
station_code?: string | null;
station_label?: string | null;
lat: number;
lon: number;
temp: number | null;
wind_dir?: number | null;
wind_speed?: number | null;
wind_speed_kt?: number | null;
source_code?: string | null;
source_label?: string | null;
is_official?: boolean;
is_airport_station?: boolean;
is_settlement_anchor?: boolean;
wind_direction_text?: string | null;
wind_power_text?: string | null;
}
export interface HourlyTrendPoint {
@@ -162,6 +185,7 @@ export interface CitySummary {
deb?: {
prediction?: number | null;
};
deviation_monitor?: DeviationMonitor;
risk?: {
level?: RiskLevel;
warning?: string | null;
@@ -169,6 +193,19 @@ export interface CitySummary {
updated_at?: string | null;
}
export interface DeviationMonitor {
available?: boolean;
current_delta?: number | null;
reference_temp?: number | null;
direction?: "normal" | "cold" | "hot" | string;
severity?: "normal" | "light" | "strong" | string;
trend?: "stable" | "expanding" | "contracting" | string;
label_zh?: string | null;
label_en?: string | null;
trend_label_zh?: string | null;
trend_label_en?: string | null;
}
export interface HourlySeries {
times?: string[];
temps?: Array<number | null>;
@@ -225,6 +262,7 @@ export interface MarketPrimary {
id?: string | null;
question?: string | null;
slug?: string | null;
market_url?: string | null;
condition_id?: string | null;
end_date?: string | null;
active?: boolean;
@@ -252,6 +290,8 @@ export interface MarketScan {
available?: boolean;
reason?: string | null;
primary_market?: MarketPrimary | null;
market_url?: string | null;
primary_market_url?: string | null;
selected_date?: string | null;
selected_condition_id?: string | null;
selected_slug?: string | null;
@@ -294,13 +334,53 @@ export interface CityDetail {
local_date: string;
risk: DashboardRisk;
current: CurrentConditions;
settlement_station?: {
provider_code?: string | null;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
airport_code?: string | null;
airport_name?: string | null;
is_airport_anchor?: boolean;
is_official_station_anchor?: boolean;
};
airport_current?: AirportCurrentConditions;
airport_primary?: AirportCurrentConditions;
airport_primary_today_obs?: Array<{
time?: string;
temp?: number | null;
}>;
mgm?: MgmData;
mgm_nearby?: NearbyStation[];
official_nearby?: NearbyStation[];
nearby_source?: string;
official_network_source?: string;
official_network_status?: {
provider_code?: string | null;
provider_label?: string | null;
available?: boolean;
mode?: string | null;
row_count?: number | null;
};
network_lead_signal?: {
available?: boolean;
delta?: number | null;
leader_station_code?: string | null;
leader_station_label?: string | null;
leader_temp?: number | null;
};
network_spread_signal?: {
available?: boolean;
spread?: number | null;
hottest_station_code?: string | null;
coolest_station_code?: string | null;
};
center_station_candidate?: NearbyStation | null;
airport_vs_network_delta?: number | null;
forecast?: ForecastData;
multi_model?: Record<string, number | null>;
deb?: DebForecast;
deviation_monitor?: DeviationMonitor;
probabilities?: {
mu?: number | null;
distribution?: ProbabilityBucket[];
@@ -327,6 +407,11 @@ export interface CityDetail {
dynamic_commentary?: {
summary?: string | null;
notes?: string[] | null;
headline_zh?: string | null;
headline_en?: string | null;
bullets_zh?: string[] | null;
bullets_en?: string[] | null;
source?: string | null;
};
taf?: {
source?: string | null;
@@ -396,6 +481,12 @@ export interface HistoryPoint {
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
truth_version?: string | null;
updated_by?: string | null;
truth_updated_at?: number | null;
actual_peak_time?: string | null;
deb_at_peak_minus_12h?: number | null;
deb_at_peak_minus_12h_time?: string | null;
@@ -420,7 +511,10 @@ export interface HistoryState {
export interface ProAccessState {
loading: boolean;
authenticated: boolean;
userId: string | null;
subscriptionActive: boolean;
subscriptionPlanCode: string | null;
subscriptionExpiresAt: string | null;
points: number;
error: string | null;
}
+445 -59
View File
@@ -44,6 +44,39 @@ function containsCjk(text: string) {
return /[\u3400-\u9fff]/.test(text);
}
function getLocalizedDynamicCommentary(
detail: CityDetail,
locale: Locale,
): { headline: string; bullets: string[]; source: string } {
const commentary = detail.dynamic_commentary || {};
const preferEnglish = isEnglish(locale);
const rawHeadline = preferEnglish
? String(commentary.headline_en || "").trim()
: String(commentary.headline_zh || "").trim();
const rawBullets = preferEnglish
? commentary.bullets_en
: commentary.bullets_zh;
const bullets = Array.isArray(rawBullets)
? rawBullets.map((item) => String(item || "").trim()).filter(Boolean)
: [];
const fallbackHeadline = String(commentary.summary || "").trim();
const fallbackBullets = Array.isArray(commentary.notes)
? commentary.notes.map((item) => String(item || "").trim()).filter(Boolean)
: [];
return {
headline: rawHeadline || fallbackHeadline,
bullets: bullets.length > 0 ? bullets : fallbackBullets,
source: String(commentary.source || "").trim(),
};
}
function isTurkishMgmCity(detail: CityDetail) {
const city = String(detail.name || detail.display_name || "")
.trim()
.toLowerCase();
return city === "ankara" || city === "istanbul";
}
function getObservationSourceCode(detail: CityDetail): string {
const source = String(detail.current?.settlement_source || "")
.trim()
@@ -78,6 +111,15 @@ function getObservationSourceTag(detail: CityDetail): string {
return "METAR";
}
function getRealtimeObservationTag(detail: CityDetail): string {
const code = getObservationSourceCode(detail);
if (code === "wunderground") {
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
return icao ? `${icao} METAR` : "METAR";
}
return getObservationSourceTag(detail);
}
function getNoaaStationCode(detail: CityDetail): string {
return String(detail.current?.station_code || detail.risk?.icao || "NOAA")
.trim()
@@ -178,12 +220,229 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
return { weatherIcon, weatherText };
}
function normalizeHm(value?: string | null) {
const match = String(value || "").match(/(\d{1,2}):(\d{2})/);
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
function hmToMinutes(value?: string | null) {
const normalized = normalizeHm(value);
if (!normalized) return null;
const [hourText, minuteText] = normalized.split(":");
const hour = Number.parseInt(hourText || "", 10);
const minute = Number.parseInt(minuteText || "", 10);
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
return hour * 60 + minute;
}
function interpolateSeriesAtMinutes(
times: string[],
values: Array<number | null | undefined>,
currentMinutes: number,
) {
const points = times
.map((time, index) => {
const minute = hmToMinutes(time);
const value = values[index];
return minute != null && value != null && Number.isFinite(Number(value))
? { minute, value: Number(value) }
: null;
})
.filter((point): point is { minute: number; value: number } => point != null);
if (!points.length) return null;
const exact = points.find((point) => point.minute === currentMinutes);
if (exact) return exact.value;
let left: { minute: number; value: number } | null = null;
let right: { minute: number; value: number } | null = null;
for (const point of points) {
if (point.minute < currentMinutes) {
left = point;
continue;
}
if (point.minute > currentMinutes) {
right = point;
break;
}
}
if (left && right) {
const span = right.minute - left.minute;
if (span <= 0) return left.value;
const ratio = (currentMinutes - left.minute) / span;
return Number((left.value + (right.value - left.value) * ratio).toFixed(1));
}
if (left) return left.value;
if (right) return right.value;
return null;
}
export function getTodayPaceView(
detail: CityDetail,
locale: Locale = "zh-CN",
) {
const hourly = detail.hourly || {};
const times = hourly.times || [];
const temps = hourly.temps || [];
if (!times.length || !temps.length) return null;
const currentMinutes =
hmToMinutes(detail.local_time) ??
hmToMinutes(detail.airport_primary?.obs_time) ??
hmToMinutes(detail.airport_current?.obs_time) ??
hmToMinutes(detail.current?.obs_time);
if (currentMinutes == null) return null;
const omHigh = Number(detail.forecast?.today_high);
const debHigh = Number(detail.deb?.prediction);
const useDebOffset = Number.isFinite(omHigh) && Number.isFinite(debHigh);
const offset = useDebOffset ? debHigh - omHigh : 0;
const expectedSeries = temps.map((temp) =>
temp != null && Number.isFinite(Number(temp))
? Number((Number(temp) + offset).toFixed(1))
: null,
);
const expectedNow = interpolateSeriesAtMinutes(times, expectedSeries, currentMinutes);
if (expectedNow == null) return null;
const observedNowCandidate = [
detail.airport_primary?.temp,
detail.airport_current?.temp,
detail.current?.temp,
]
.map((value) => Number(value))
.find((value) => Number.isFinite(value));
if (observedNowCandidate == null) return null;
const observedNow = Number(observedNowCandidate);
const delta = Number((observedNow - expectedNow).toFixed(1));
const biasMagnitude = Math.abs(delta);
const biasTone =
delta >= 0.6 ? "warm" : delta <= -0.6 ? "cold" : "neutral";
const badge =
biasTone === "warm"
? isEnglish(locale)
? "Running hot"
: "跑得偏热"
: biasTone === "cold"
? isEnglish(locale)
? "Running cool"
: "跑得偏冷"
: isEnglish(locale)
? "On track"
: "基本跟踪";
const kicker = isEnglish(locale)
? `As of ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`
: `截至 ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`;
const deltaText =
delta === 0
? isEnglish(locale)
? "0.0°C vs expected"
: "0.0°C 相对预期"
: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`;
const topObservedCandidate = [
detail.airport_primary?.max_so_far,
detail.airport_current?.max_so_far,
detail.current?.max_so_far,
observedNow,
]
.map((value) => Number(value))
.find((value) => Number.isFinite(value));
const topObserved = topObservedCandidate != null ? Number(topObservedCandidate) : null;
const projectedBase = Number.isFinite(debHigh)
? debHigh
: Number.isFinite(omHigh)
? omHigh
: null;
const paceAdjustedHigh =
projectedBase != null
? Number(
Math.max(projectedBase + delta, topObserved ?? projectedBase).toFixed(1),
)
: topObserved;
const paceAdjustedLabel = isEnglish(locale)
? "Pace-adjusted high"
: "节奏修正高点";
const peakWindowText =
Number.isFinite(Number(detail.peak?.first_h)) &&
Number.isFinite(Number(detail.peak?.last_h))
? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String(
Number(detail.peak?.last_h) + 1,
).padStart(2, "0")}:00`
: "--";
const observedLabel =
detail.airport_primary?.temp != null || detail.airport_current?.temp != null
? isEnglish(locale)
? "Airport obs"
: "机场实测"
: isEnglish(locale)
? "Current obs"
: "当前实测";
const paceSummary =
biasTone === "warm"
? isEnglish(locale)
? `The airport anchor is ${biasMagnitude.toFixed(1)}°C above the intraday curve. If that bias survives into the peak window, the day high is more likely to lean hotter than the current DEB path.`
: `机场主站当前比盘中曲线高 ${biasMagnitude.toFixed(1)}°C。若这段偏热节奏延续进峰值窗口,日高更容易落在当前 DEB 路径之上。`
: biasTone === "cold"
? isEnglish(locale)
? `The airport anchor is ${biasMagnitude.toFixed(1)}°C below the intraday curve. If that drag survives into the peak window, chasing higher buckets becomes harder.`
: `机场主站当前比盘中曲线低 ${biasMagnitude.toFixed(1)}°C。若这段偏冷节奏延续进峰值窗口,继续追更高温区间会更吃力。`
: isEnglish(locale)
? "The airport anchor is still tracking the intraday curve. Let later pace and peak-window structure decide."
: "机场主站当前仍基本贴着盘中曲线运行,后续主要看峰值窗口内的节奏有没有进一步偏离。";
const clamped = Math.min(Math.max(delta, -4), 4);
const meterLeft =
biasTone === "neutral"
? 46
: clamped >= 0
? 50
: 50 - (Math.abs(clamped) / 4) * 50;
const meterWidth =
biasTone === "neutral" ? 8 : Math.max((Math.abs(clamped) / 4) * 50, 8);
return {
badge,
biasTone,
delta,
deltaText,
expectedNow,
kicker,
meterLeft,
meterWidth,
observedLabel,
observedNow,
paceAdjustedHigh,
paceAdjustedLabel,
peakWindowText,
summary: paceSummary,
topObserved,
};
}
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
const current = detail.current || {};
const parts: string[] = [];
const sourceTag = getObservationSourceTag(detail);
const suppressAnkaraMgmObservation =
String(detail.name || "").trim().toLowerCase() === "ankara";
const sourceTag = getRealtimeObservationTag(detail);
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
if (current.obs_time) {
const ageText =
@@ -253,8 +512,7 @@ export function getTemperatureChartData(
const hourly = detail.hourly || {};
const times = hourly.times || [];
const temps = hourly.temps || [];
const suppressAnkaraMgmObservation =
String(detail.name || "").trim().toLowerCase() === "ankara";
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
if (!times.length) return null;
@@ -276,15 +534,17 @@ export function getTemperatureChartData(
currentIndex < 0 || index >= currentIndex ? temp : null,
);
const observationTag = getObservationSourceTag(detail);
const observationTag = getRealtimeObservationTag(detail);
const observationCode = getObservationSourceCode(detail);
const settlementSource =
observationCode === "hko" ||
observationCode === "cwa" ||
observationCode === "noaa" ||
observationCode === "wunderground";
const useSettlementObservationSource =
settlementSource && observationCode !== "wunderground";
const officialObservationSource =
settlementSource
useSettlementObservationSource
? detail.settlement_today_obs?.length
? detail.settlement_today_obs
: detail.current?.obs_time && detail.current?.temp != null
@@ -294,36 +554,30 @@ export function getTemperatureChartData(
const metarObservationSource = detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
const allowMetarFallback =
settlementSource &&
observationCode !== "hko" &&
observationCode !== "wunderground";
const allowMetarFallback = settlementSource && observationCode !== "hko";
const shouldUseMetarFallback =
allowMetarFallback &&
officialObservationSource.length > 0 &&
officialObservationSource.length < 3 &&
metarObservationSource.length >= 3;
const observationSource = settlementSource
const observationSource = useSettlementObservationSource
? shouldUseMetarFallback
? metarObservationSource
: officialObservationSource
: metarObservationSource;
const airportMetarSource =
settlementSource && observationCode === "wunderground"
? metarObservationSource
: [];
const airportMetarSource: Array<{ time?: string; temp?: number | null }> = [];
const metarFallbackTag = (() => {
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
if (!icao) return "METAR";
return `${icao} METAR`;
})();
const observationDisplayTag =
settlementSource && shouldUseMetarFallback
observationCode === "wunderground"
? metarFallbackTag
: useSettlementObservationSource && shouldUseMetarFallback
? metarFallbackTag
: observationCode === "noaa"
? `NOAA ${getNoaaStationCode(detail)}`
: observationCode === "wunderground"
? "Wunderground"
: observationTag;
const metarPoints = new Array(times.length).fill(null);
@@ -612,12 +866,6 @@ export function getTemperatureChartData(
? `This city settles on NOAA ${noaaCode} using the finalized highest rounded whole-degree Celsius Temp reading; the plotted line is a settlement reference.`
: `该城市按 NOAA ${noaaCode} 最终完成质控后的最高整度摄氏 Temp 读数结算;图中曲线仅作为结算参考线。`,
);
} else if (observationCode === "wunderground") {
legendParts.push(
isEnglish(locale)
? "This city settles on the configured Wunderground station; the plotted observation points follow that settlement source."
: "该城市按配置的 Wunderground 站点结算;图中实测点位按该结算源展示。",
);
}
if (tafMarkers.length) {
const primaryTafMarker = currentTafMarker || nextTafMarker;
@@ -658,7 +906,7 @@ export function getTemperatureChartData(
temps,
},
observationLabel:
(observationCode === "noaa" || observationCode === "wunderground") &&
observationCode === "noaa" &&
!shouldUseMetarFallback
? isEnglish(locale)
? `${observationDisplayTag} Settlement Reference`
@@ -817,6 +1065,92 @@ export function pickAnkaraNearbyStations(stations: NearbyStation[]) {
return picks.length ? picks : stations;
}
function distanceKm(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
) {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function pickMapNearbyStations(detail: CityDetail) {
const stations = Array.isArray(detail.official_nearby)
? detail.official_nearby
: Array.isArray(detail.mgm_nearby)
? detail.mgm_nearby
: [];
const city = String(detail.name || detail.display_name || "")
.trim()
.toLowerCase();
if (city === "ankara") {
return pickAnkaraNearbyStations(stations);
}
if (city === "istanbul" && Number.isFinite(detail.lat) && Number.isFinite(detail.lon)) {
const preferredTokens = [
"havalimani",
"havalimanı",
"arnavutkoy",
"arnavutköy",
"liman feneri",
];
const scored = stations
.map((station) => {
const lat = Number(station.lat);
const lon = Number(station.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return null;
}
const name = String(station.name || "").toLowerCase();
const preferred = preferredTokens.some((token) => name.includes(token));
return {
preferred,
station,
km: distanceKm(Number(detail.lat), Number(detail.lon), lat, lon),
};
})
.filter(Boolean) as Array<{
preferred: boolean;
station: NearbyStation;
km: number;
}>;
const closePreferred = scored
.filter((row) => row.preferred && row.km <= 18)
.sort((a, b) => a.km - b.km)
.map((row) => row.station);
const closeFallback = scored
.filter((row) => row.km <= 8)
.sort((a, b) => a.km - b.km)
.map((row) => row.station);
const merged = [...closePreferred, ...closeFallback].filter(
(station, index, list) =>
list.findIndex(
(row) =>
row.name === station.name &&
row.lat === station.lat &&
row.lon === station.lon,
) === index,
);
return merged.slice(0, 3);
}
return stations;
}
export function getFutureSlice(detail: CityDetail, dateStr: string) {
const hourly = detail.hourly_next_48h || {};
const times = hourly.times || [];
@@ -1019,21 +1353,16 @@ export function computeFrontTrendSignal(
tone?: string;
value: string;
}> = [];
const rawBackendSummary =
const localizedCommentary =
dateStr === detail.local_date
? String(detail.dynamic_commentary?.summary || "").trim()
: "";
? getLocalizedDynamicCommentary(detail, locale)
: { headline: "", bullets: [], source: "" };
const backendSummary =
rawBackendSummary &&
(!isEnglish(locale) || !containsCjk(rawBackendSummary))
? rawBackendSummary
localizedCommentary.headline &&
(!isEnglish(locale) || !containsCjk(localizedCommentary.headline))
? localizedCommentary.headline
: "";
const rawBackendNotes = Array.isArray(detail.dynamic_commentary?.notes)
? detail.dynamic_commentary?.notes
?.map((item) => String(item || "").trim())
.filter(Boolean) || []
: [];
const backendNotes = rawBackendNotes.filter(
const backendNotes = localizedCommentary.bullets.filter(
(note) => !isEnglish(locale) || !containsCjk(note),
);
const slice = getFutureSlice(detail, dateStr);
@@ -1780,8 +2109,8 @@ export function computeFrontTrendSignal(
: "云量回落且温度抬升,白天增温效率在改善。";
}
return isEnglish(locale)
? "Read cloud-cover change together with temperature, dew point, wind, and precipitation; cloud change alone does not define the regime."
: "云量变化需要结合温度、露点、风向和降水一起看,不能单独决定天气形势。";
? "Read forecast cloud-cover increase together with temperature, dew point, wind, and precipitation; it does not override the current observed sky condition."
: "这里显示的是预测窗口内的云量增幅,需要结合温度、露点、风向和降水一起看,不能覆盖当前实况的天空状况。";
})();
const dewNote = (() => {
if (dewDelta >= 1.2 && tempDelta >= 0.8) {
@@ -1892,7 +2221,7 @@ export function computeFrontTrendSignal(
value: `${Math.round(precipMax)}%`,
},
{
label: isEnglish(locale) ? "Cloud-cover delta" : "云量变化",
label: isEnglish(locale) ? "Forecast cloud-cover delta" : "预测云量增幅",
note: cloudNote,
tone:
cloudDelta >= 15 && tempDelta >= 0
@@ -2070,7 +2399,7 @@ export function getShortTermNowcastLines(
const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
const nearbySource = String(detail.nearby_source || "").toLowerCase();
const sourceLabel =
nearbySource === "mgm" || detail.name === "ankara"
nearbySource === "mgm" || isTurkishMgmCity(detail)
? isEnglish(locale)
? "MGM nearby stations"
: "MGM 周边站"
@@ -2295,6 +2624,7 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
const risk = detail.risk || {};
const current = detail.current || {};
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
const nearbySource = String(detail.nearby_source || "").trim().toLowerCase();
const sourceCode = getObservationSourceCode(detail);
const isOfficialSource =
sourceCode === "hko" ||
@@ -2317,18 +2647,17 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
const noaaCode = getNoaaStationCode(detail);
const noaaName = getNoaaStationName(detail);
return isEnglish(locale)
? `NOAA ${noaaCode} (${noaaName})`
: `NOAA ${noaaCode}${noaaName}`;
? `${noaaName}${noaaCode ? ` (${noaaCode})` : ""}`
: `${noaaName}${noaaCode ? `${noaaCode}` : ""}`;
}
if (sourceCode === "wunderground") {
const stationName = String(
detail.current?.settlement_source_label ||
detail.risk?.airport ||
"Wunderground",
).trim();
return isEnglish(locale)
? `${stationName} (Wunderground)`
: `${stationName}Wunderground`;
const stationName = String(
detail.current?.station_name || detail.risk?.airport || "",
).trim();
const stationCode = String(
detail.current?.station_code || detail.risk?.icao || "",
).trim();
if (stationName) {
return `${stationName}${stationCode ? ` (${stationCode})` : ""}`;
}
const tag = getObservationSourceTag(detail);
if (sourceCode === "mgm") {
@@ -2339,12 +2668,12 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
return isEnglish(locale) ? "No profile" : "暂无档案";
})();
return [
const rows = [
{
label: isOfficialSource
? isEnglish(locale)
? "Settlement source"
: "结算"
? "Settlement station"
: "结算站点"
: isEnglish(locale)
? "Settlement airport"
: "结算机场",
@@ -2384,6 +2713,63 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
: "暂无周边站",
},
];
if (nearbyCount > 0) {
rows.push({
label: isEnglish(locale) ? "Nearby source" : "周边站来源",
value:
nearbySource === "kma"
? isEnglish(locale)
? "KMA official stations"
: "KMA 官方站"
: nearbySource === "official_cluster"
? isEnglish(locale)
? "Official station cluster"
: "官方站簇"
: nearbySource === "mgm"
? "MGM"
: isEnglish(locale)
? "Airport / METAR network"
: "机场 / METAR 网络",
});
}
if (nearbySource === "kma" && detail.airport_current?.temp != null) {
const airportLabel =
String(
detail.airport_current.station_label ||
detail.airport_current.station_code ||
detail.risk?.airport ||
"",
).trim() ||
(isEnglish(locale) ? "Airport station" : "机场主站");
const airportObsTime =
String(detail.airport_current.obs_time || "").trim() ||
(isEnglish(locale) ? "pending" : "待更新");
const airportHigh =
detail.airport_current.max_so_far != null
? `${detail.airport_current.max_so_far}${detail.temp_symbol || ""}${
detail.airport_current.max_temp_time
? ` @ ${detail.airport_current.max_temp_time}`
: ""
}`
: isEnglish(locale)
? "Unavailable"
: "未提供";
rows.push({
label: isEnglish(locale) ? "Airport reference" : "机场主站参考",
value: `${airportLabel}: ${detail.airport_current.temp}${
detail.temp_symbol || ""
} @ ${airportObsTime}`,
});
rows.push({
label: isEnglish(locale) ? "Airport high" : "机场目前最高温",
value: airportHigh,
});
}
return rows;
}
export function getSettlementRiskNarrative(
@@ -2435,11 +2821,11 @@ export function getSettlementRiskNarrative(
}
}
if (detail.name === "ankara") {
if (isTurkishMgmCity(detail)) {
lines.push(
isEnglish(locale)
? "For Ankara, focus on LTAC / Esenboğa plus MGM nearby-station linkage, not urban sensation alone."
: "Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。",
? "For Turkish MGM-supported cities, focus on the airport station plus MGM nearby-station linkage, not urban sensation alone."
: "对接入 MGM 的土耳其城市,需要重点看机场站与 MGM 周边站联动,不能只看城区体感。",
);
}
+10 -10
View File
@@ -26,12 +26,12 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"sidebar.currentTemp": "当前 {temp}",
"sidebar.peakTempAt": "峰值 {temp} @ {time}",
"sidebar.peakAt": "峰值 @ {time}",
"sidebar.group.high": "高风险",
"sidebar.group.medium": "中风险",
"sidebar.group.low": "低风险",
"sidebar.group.other": "其他",
"sidebar.group.high": "近期强势",
"sidebar.group.medium": "近期一般",
"sidebar.group.low": "近期偏弱",
"sidebar.group.other": "样本不足",
"dashboard.loading": "正在同步站点观测与结算,请稍候...",
"dashboard.loading": "正在同步站点观测与结算站点信息,请稍候...",
"detail.closeAria": "关闭城市详情面板",
"detail.waitSelect": "等待选择城市",
@@ -189,13 +189,13 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"sidebar.currentTemp": "Current {temp}",
"sidebar.peakTempAt": "Peak {temp} @ {time}",
"sidebar.peakAt": "Peak @ {time}",
"sidebar.group.high": "High Risk",
"sidebar.group.medium": "Medium Risk",
"sidebar.group.low": "Low Risk",
"sidebar.group.other": "Others",
"sidebar.group.high": "Recent Strong",
"sidebar.group.medium": "Recent Mixed",
"sidebar.group.low": "Recent Weak",
"sidebar.group.other": "Low Sample",
"dashboard.loading":
"Synchronizing station observations and settlement feeds...",
"Synchronizing station observations and settlement station data...",
"detail.closeAria": "Close city detail panel",
"detail.waitSelect": "Waiting for city selection",
+122
View File
@@ -0,0 +1,122 @@
import type { CityDetail } from "@/lib/dashboard-types";
import type { Locale } from "@/lib/i18n";
const CITY_TO_MARKET_SLUG: Record<string, string> = {
ankara: "ankara",
atlanta: "atlanta",
austin: "austin",
beijing: "beijing",
"buenos aires": "buenos-aires",
chengdu: "chengdu",
chicago: "chicago",
chongqing: "chongqing",
dallas: "dallas",
houston: "houston",
"hong kong": "hong-kong",
istanbul: "istanbul",
london: "london",
"los angeles": "los-angeles",
lucknow: "lucknow",
madrid: "madrid",
mexico: "mexico-city",
"mexico city": "mexico-city",
miami: "miami",
milan: "milan",
munich: "munich",
"new york": "nyc",
paris: "paris",
"san francisco": "san-francisco",
"sao paulo": "sao-paulo",
seattle: "seattle",
seoul: "seoul",
shanghai: "shanghai",
shenzhen: "shenzhen",
singapore: "singapore",
taipei: "taipei",
"tel aviv": "tel-aviv",
tokyo: "tokyo",
toronto: "toronto",
warsaw: "warsaw",
wellington: "wellington",
wuhan: "wuhan",
};
const MONTHS = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
function normalizeCityKey(detail?: CityDetail | null) {
return String(detail?.name || detail?.display_name || "")
.trim()
.toLowerCase();
}
function slugifyCityName(cityKey: string) {
return cityKey
.trim()
.toLowerCase()
.replace(/['.]/g, "")
.replace(/&/g, " and ")
.replace(/\s+/g, "-");
}
function normalizeDateParts(localDate?: string | null) {
const value = String(localDate || "").trim();
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
const day = Number(match[3]);
if (
!Number.isFinite(year) ||
!Number.isFinite(monthIndex) ||
!Number.isFinite(day) ||
monthIndex < 0 ||
monthIndex > 11
) {
return null;
}
return {
year,
month: MONTHS[monthIndex],
day,
};
}
export function getTodayPolymarketUrl(
detail?: CityDetail | null,
locale: Locale = "en-US",
) {
const directMarketUrl = String(
detail?.market_scan?.market_url ||
detail?.market_scan?.primary_market_url ||
detail?.market_scan?.primary_market?.market_url ||
"",
).trim();
if (directMarketUrl) {
return directMarketUrl;
}
const cityKey = normalizeCityKey(detail);
const citySlug = CITY_TO_MARKET_SLUG[cityKey] || slugifyCityName(cityKey);
const dateParts = normalizeDateParts(detail?.local_date);
if (!citySlug || !dateParts) return null;
const prefix =
locale === "zh-CN"
? "https://polymarket.com/zh/event/"
: "https://polymarket.com/event/";
return `${prefix}highest-temperature-in-${citySlug}-on-${dateParts.month}-${dateParts.day}-${dateParts.year}`;
}
+29
View File
@@ -186,6 +186,7 @@ export interface ModelComparison {
ICON?: number;
GEM?: number;
JMA?: number;
LGBM?: number;
MGM?: number;
NWS?: number;
}
@@ -327,6 +328,16 @@ export interface CityDetail {
local_date: string;
temp_symbol: string;
current_temp: number | null;
settlement_station?: {
provider_code?: string | null;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
airport_code?: string | null;
airport_name?: string | null;
is_airport_anchor?: boolean;
is_official_station_anchor?: boolean;
};
deb_prediction: number | null;
risk_level: string;
risk_warning: string;
@@ -339,6 +350,15 @@ export interface CityDetail {
mgm: any;
mgm_nearby: any[];
nearby_source?: string;
airport_primary?: any;
airport_primary_today_obs?: any[];
official_nearby?: any[];
official_network_source?: string;
official_network_status?: any;
network_lead_signal?: any;
network_spread_signal?: any;
center_station_candidate?: any;
airport_vs_network_delta?: number | null;
};
timeseries: {
metar_recent_obs: any[];
@@ -355,6 +375,15 @@ export interface CityDetail {
};
market_scan: MarketScan;
risk: any;
settlement_station?: any;
airport_primary?: any;
official_nearby?: any[];
official_network_source?: string;
official_network_status?: any;
network_lead_signal?: any;
network_spread_signal?: any;
center_station_candidate?: any;
airport_vs_network_delta?: number | null;
ai_analysis: string;
errors: Record<string, string>;
}
+1
View File
@@ -50,6 +50,7 @@ function isPublicPage(pathname: string) {
function isPublicApi(pathname: string) {
return (
pathname === "/api/auth/me" ||
pathname === "/api/analytics/events" ||
pathname === "/api/cities" ||
pathname === "/api/vitals" ||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
+24 -125
View File
@@ -11,8 +11,6 @@
"@radix-ui/react-slot": "^1.1.2",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.57.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"@walletconnect/ethereum-provider": "^2.23.8",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
@@ -2058,6 +2056,7 @@
"version": "5.5.1",
"resolved": "https://registry.npmmirror.com/@solana/kit/-/kit-5.5.1.tgz",
"integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@solana/accounts": "5.5.1",
@@ -2977,80 +2976,6 @@
"@types/node": "*"
}
},
"node_modules/@vercel/analytics": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/@vercel/analytics/-/analytics-1.6.1.tgz",
"integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
"peerDependencies": {
"@remix-run/react": "^2",
"@sveltejs/kit": "^1 || ^2",
"next": ">= 13",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
}
},
"node_modules/@vercel/speed-insights": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@vercel/speed-insights/-/speed-insights-2.0.0.tgz",
"integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==",
"peerDependencies": {
"@sveltejs/kit": "^1 || ^2",
"next": ">= 13",
"nuxt": ">= 3",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"nuxt": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
}
},
"node_modules/@wallet-standard/base": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@wallet-standard/base/-/base-1.1.0.tgz",
@@ -3214,6 +3139,7 @@
"resolved": "https://registry.npmmirror.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
@@ -3652,14 +3578,15 @@
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.14.0",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios-retry": {
@@ -3850,19 +3777,6 @@
"ieee754": "^1.2.1"
}
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4788,6 +4702,7 @@
"resolved": "https://registry.npmmirror.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
@@ -5552,10 +5467,14 @@
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"optional": true
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/qrcode": {
"version": "1.5.3",
@@ -6608,6 +6527,7 @@
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -8529,18 +8449,6 @@
"@types/node": "*"
}
},
"@vercel/analytics": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/@vercel/analytics/-/analytics-1.6.1.tgz",
"integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
"requires": {}
},
"@vercel/speed-insights": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@vercel/speed-insights/-/speed-insights-2.0.0.tgz",
"integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==",
"requires": {}
},
"@wallet-standard/base": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@wallet-standard/base/-/base-1.1.0.tgz",
@@ -9022,14 +8930,14 @@
}
},
"axios": {
"version": "1.13.6",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.14.0",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"optional": true,
"requires": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"axios-retry": {
@@ -9149,15 +9057,6 @@
"ieee754": "^1.2.1"
}
},
"bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"optional": true,
"requires": {
"node-gyp-build": "^4.3.0"
}
},
"call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -10298,9 +10197,9 @@
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"optional": true
},
"qrcode": {
-2
View File
@@ -12,8 +12,6 @@
"@radix-ui/react-slot": "^1.1.2",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.57.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"@walletconnect/ethereum-provider": "^2.23.8",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
+15
View File
@@ -0,0 +1,15 @@
global:
resolve_timeout: 5m
route:
receiver: telegram-relay
group_by: ["alertname"]
group_wait: 30s
group_interval: 5m
repeat_interval: 3h
receivers:
- name: telegram-relay
webhook_configs:
- url: http://polyweather_alert_relay:9099/alerts
send_resolved: true
@@ -0,0 +1,227 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"expr": "sum by (status) (rate(polyweather_http_requests_total[5m]))",
"legendFormat": "{{status}}",
"refId": "A"
}
],
"title": "HTTP Requests by Status",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "ms"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"expr": "sum(rate(polyweather_http_request_duration_ms_sum[5m])) / clamp_min(sum(rate(polyweather_http_request_duration_ms_count[5m])), 1)",
"legendFormat": "avg",
"refId": "A"
},
{
"expr": "max(polyweather_http_request_duration_ms_max)",
"legendFormat": "max",
"refId": "B"
}
],
"title": "HTTP Latency",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"expr": "sum by (source, outcome) (rate(polyweather_source_requests_total[5m]))",
"legendFormat": "{{source}} / {{outcome}}",
"refId": "A"
}
],
"title": "Source Requests by Outcome",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 25
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
}
},
"targets": [
{
"expr": "(sum(increase(polyweather_source_requests_total{outcome!~\"success|cache_hit\"}[15m])) / clamp_min(sum(increase(polyweather_source_requests_total[15m])), 1))",
"refId": "A"
}
],
"title": "Source Error Rate (15m)",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 41,
"style": "dark",
"tags": [
"polyweather",
"ops"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "PolyWeather Overview",
"uid": "polyweather-overview",
"version": 1
}
@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: PolyWeather Dashboards
orgId: 1
folder: PolyWeather
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: PolyWeather Prometheus
type: prometheus
access: proxy
url: http://polyweather_prometheus:9090
isDefault: true
editable: false
+58
View File
@@ -0,0 +1,58 @@
groups:
- name: polyweather-runtime
rules:
- alert: PolyWeatherWebDown
expr: up{job="polyweather-web"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "PolyWeather web metrics endpoint is down"
description: "/metrics on polyweather_web has been unreachable for more than 2 minutes."
- alert: PolyWeatherHttp5xxBurst
expr: sum(increase(polyweather_http_requests_total{status=~"5.."}[10m])) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "PolyWeather HTTP 5xx burst"
description: "More than 10 server errors were observed in the last 10 minutes."
- alert: PolyWeatherHighSourceErrorRate
expr: |
(
sum(increase(polyweather_source_requests_total{outcome!~"success|cache_hit"}[15m]))
/
clamp_min(sum(increase(polyweather_source_requests_total[15m])), 1)
) > 0.25
and sum(increase(polyweather_source_requests_total[15m])) > 20
for: 10m
labels:
severity: warning
annotations:
summary: "PolyWeather source error rate is high"
description: "External weather source errors exceeded 25% over the last 15 minutes."
- alert: PolyWeatherOpenMeteoCooldownLoop
expr: sum(increase(polyweather_source_requests_total{source="open_meteo",outcome=~"cooldown_skip|error"}[15m])) > 20
for: 10m
labels:
severity: warning
annotations:
summary: "Open-Meteo is rate-limited or erroring"
description: "Open-Meteo has produced repeated cooldown skips or errors in the last 15 minutes."
- alert: PolyWeatherSlowHttpAverage
expr: |
(
sum(rate(polyweather_http_request_duration_ms_sum[10m]))
/
clamp_min(sum(rate(polyweather_http_request_duration_ms_count[10m])), 1)
) > 2000
for: 10m
labels:
severity: warning
annotations:
summary: "PolyWeather average HTTP latency is high"
description: "Average HTTP request duration exceeded 2s over the last 10 minutes."
+19
View File
@@ -0,0 +1,19 @@
global:
scrape_interval: 30s
evaluation_interval: 30s
rule_files:
- /etc/prometheus/alerts.yml
alerting:
alertmanagers:
- static_configs:
- targets:
- polyweather_alertmanager:9093
scrape_configs:
- job_name: polyweather-web
metrics_path: /metrics
static_configs:
- targets:
- polyweather_web:8000
+2
View File
@@ -1,9 +1,11 @@
requests
httpx
loguru
pyTelegramBotAPI
python-dotenv
pytz
numpy
lightgbm
web3
fastapi
uvicorn
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Dict, List
import requests
def _chat_ids() -> List[str]:
raw = (
os.getenv("POLYWEATHER_MONITORING_ALERT_CHAT_IDS")
or os.getenv("TELEGRAM_CHAT_IDS")
or os.getenv("TELEGRAM_CHAT_ID")
or ""
)
return [item.strip() for item in raw.split(",") if item.strip()]
def _format_alerts(payload: Dict[str, Any]) -> str:
alerts = payload.get("alerts") or []
if not isinstance(alerts, list) or not alerts:
return "PolyWeather monitoring received an empty alert payload."
lines = ["PolyWeather monitoring alert"]
for alert in alerts[:10]:
if not isinstance(alert, dict):
continue
status = str(alert.get("status") or "unknown").upper()
labels = alert.get("labels") or {}
annotations = alert.get("annotations") or {}
alert_name = labels.get("alertname") or "unknown_alert"
severity = labels.get("severity") or "info"
summary = annotations.get("summary") or annotations.get("description") or ""
lines.append(f"- [{status}] {alert_name} ({severity})")
if summary:
lines.append(f" {summary}")
return "\n".join(lines)
def _send_telegram_message(text: str) -> None:
token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
chat_ids = _chat_ids()
if not token or not chat_ids:
return
url = f"https://api.telegram.org/bot{token}/sendMessage"
for chat_id in chat_ids:
try:
requests.post(
url,
json={
"chat_id": chat_id,
"text": text,
"disable_web_page_preview": True,
},
timeout=10,
).raise_for_status()
except Exception:
continue
def _send_alert_notifications(text: str) -> None:
_send_telegram_message(text)
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
if self.path.rstrip("/") == "/healthz":
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(b'{"ok":true}')
return
self.send_error(404)
def do_POST(self) -> None: # noqa: N802
if self.path.rstrip("/") != "/alerts":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length > 0 else b"{}"
try:
payload = json.loads(raw.decode("utf-8"))
except Exception:
self.send_error(400, "invalid json")
return
if not isinstance(payload, dict):
self.send_error(400, "invalid payload")
return
_send_alert_notifications(_format_alerts(payload))
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(b'{"ok":true}')
def log_message(self, format: str, *args: object) -> None: # noqa: A003
return
def main() -> None:
port = 9099
server = HTTPServer(("0.0.0.0", port), _Handler)
server.serve_forever()
if __name__ == "__main__":
main()
+7 -6
View File
@@ -16,6 +16,7 @@ from src.analysis.probability_snapshot_archive import ( # noqa: E402
load_snapshot_rows_for_day,
)
from src.database.runtime_state import STATE_STORAGE_FILE, get_state_storage_mode # noqa: E402
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
def _load_daily_records(path: Path) -> Dict[str, Dict[str, Dict[str, Any]]]:
@@ -53,8 +54,8 @@ def main() -> int:
)
parser.add_argument(
"--history-file",
default=str(Path("data") / "daily_records.json"),
help="Path to daily_records.json",
default=_default_history_arg(),
help="Optional legacy daily_records.json path. In sqlite mode this defaults to the runtime database.",
)
parser.add_argument("--city", help="Optional city filter, e.g. ankara")
parser.add_argument("--date", help="Optional YYYY-MM-DD filter")
@@ -70,8 +71,8 @@ def main() -> int:
)
args = parser.parse_args()
history_path = Path(args.history_file)
data = _load_daily_records(history_path)
history_path = Path(args.history_file) if args.history_file else None
data = _load_daily_records(history_path or Path())
model_name = str(args.model or "").strip()
city_filter = str(args.city or "").strip().lower() or None
date_filter = str(args.date or "").strip() or None
@@ -133,8 +134,8 @@ def main() -> int:
if changed:
previous_mode = get_state_storage_mode()
# Reuse existing save path semantics. In sqlite-only mode, save_history would skip file write.
save_history(str(history_path), data)
if previous_mode == STATE_STORAGE_FILE and not history_path.exists():
save_history(str(history_path or ""), data)
if previous_mode == STATE_STORAGE_FILE and (history_path is None or not history_path.exists()):
raise FileNotFoundError(history_path)
return 0
@@ -13,6 +13,7 @@ from src.analysis.probability_calibration import ( # noqa: E402
apply_probability_calibration,
build_probability_features,
)
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
def _sample_to_features(sample):
@@ -44,7 +45,7 @@ def main():
parser = argparse.ArgumentParser(description="Backfill shadow probability snapshots into daily records.")
parser.add_argument(
"--history-file",
default=os.path.join(PROJECT_ROOT, "data", "daily_records.json"),
default=_default_history_arg(),
)
parser.add_argument(
"--training-samples",
@@ -9,6 +9,7 @@ if PROJECT_ROOT not in sys.path:
from src.analysis.deb_algorithm import load_history, reconcile_recent_actual_highs, save_history # noqa: E402
from src.data_collection.city_registry import CITY_REGISTRY # noqa: E402
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
def _target_dates(city_info: dict, lookback_days: int) -> list[str]:
@@ -24,12 +25,12 @@ def _target_dates(city_info: dict, lookback_days: int) -> list[str]:
def _is_metar_city(city_info: dict) -> bool:
source = str(city_info.get("settlement_source") or "metar").strip().lower()
return source == "metar"
return source in {"metar", "hko", "noaa"}
def main() -> None:
parser = argparse.ArgumentParser(
description="Seed recent daily_records rows and backfill actual_high from aviationweather METAR history."
description="Seed recent runtime daily_records rows and backfill actual_high from the city's settlement source."
)
parser.add_argument(
"--cities",
@@ -50,7 +51,7 @@ def main() -> None:
)
args = parser.parse_args()
history_file = os.path.join(PROJECT_ROOT, "data", "daily_records.json")
history_file = _default_history_arg() or ""
data = load_history(history_file)
selected = {str(item).strip().lower() for item in args.cities if str(item).strip()}
+2 -1
View File
@@ -11,6 +11,7 @@ if PROJECT_ROOT not in sys.path:
from src.analysis.deb_algorithm import load_history # noqa: E402
from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
def _sf(value):
@@ -112,7 +113,7 @@ def main():
parser = argparse.ArgumentParser(description="Build live shadow probability report from daily records.")
parser.add_argument(
"--history-file",
default=os.path.join(PROJECT_ROOT, "data", "daily_records.json"),
default=_default_history_arg(),
)
parser.add_argument(
"--output",
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import argparse
import json
import sys
from typing import Dict, Tuple
import requests
def _get_json(url: str, timeout: float) -> Tuple[int, Dict]:
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return response.status_code, response.json()
def _get_text(url: str, timeout: float) -> Tuple[int, str]:
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return response.status_code, response.text
def main() -> int:
parser = argparse.ArgumentParser(description="Run basic PolyWeather ops checks.")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--timeout", type=float, default=8.0)
args = parser.parse_args()
base = args.base_url.rstrip("/")
timeout = args.timeout
report = {"checks": []}
failed = False
try:
_, health = _get_json(f"{base}/healthz", timeout)
ok = str(health.get("status") or "").lower() == "ok"
report["checks"].append({"name": "healthz", "ok": ok, "detail": health})
failed = failed or not ok
except Exception as exc:
report["checks"].append({"name": "healthz", "ok": False, "detail": str(exc)})
failed = True
try:
_, status = _get_json(f"{base}/api/system/status", timeout)
features = status.get("features") or {}
ok = status.get("status") == "ok" and bool((status.get("db") or {}).get("ok"))
report["checks"].append({"name": "system_status", "ok": ok, "detail": {"features": features, "db": status.get("db")}})
failed = failed or not ok
except Exception as exc:
report["checks"].append({"name": "system_status", "ok": False, "detail": str(exc)})
failed = True
try:
_, metrics = _get_text(f"{base}/metrics", timeout)
ok = "polyweather_http_requests_total" in metrics or "polyweather_source_requests_total" in metrics
report["checks"].append({"name": "metrics", "ok": ok, "detail": "metrics exposed"})
failed = failed or not ok
except Exception as exc:
report["checks"].append({"name": "metrics", "ok": False, "detail": str(exc)})
failed = True
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -16,6 +16,7 @@ from src.analysis.probability_calibration import ( # noqa: E402
)
from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
from scripts.fit_probability_calibration import ( # noqa: E402
_default_history_arg,
_extract_samples,
_load_json_if_exists,
)
@@ -61,7 +62,7 @@ def main():
parser = argparse.ArgumentParser(description="Evaluate legacy vs EMOS probability calibration.")
parser.add_argument(
"--history-file",
default=os.path.join(PROJECT_ROOT, "data", "daily_records.json"),
default=_default_history_arg(),
)
parser.add_argument(
"--settlement-history",
@@ -8,6 +8,8 @@ if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from scripts.fit_probability_calibration import ( # noqa: E402
_default_history_arg,
_default_snapshot_arg,
_extract_samples,
_load_history_with_fallback,
_load_json_if_exists,
@@ -19,7 +21,7 @@ def main():
parser = argparse.ArgumentParser(description="Export normalized probability calibration training samples.")
parser.add_argument(
"--history-file",
default=os.path.join(PROJECT_ROOT, "data", "daily_records.json"),
default=_default_history_arg(),
)
parser.add_argument(
"--settlement-history",
@@ -41,7 +43,7 @@ def main():
)
parser.add_argument(
"--snapshot-file",
default=os.path.join(PROJECT_ROOT, "data", "probability_training_snapshots.jsonl"),
default=_default_snapshot_arg(),
)
args = parser.parse_args()
+97 -15
View File
@@ -15,8 +15,12 @@ from src.analysis.probability_calibration import ( # noqa: E402
)
from src.analysis.deb_algorithm import load_history # noqa: E402
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
ProbabilitySnapshotRepository,
STATE_STORAGE_FILE,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode,
)
@@ -38,13 +42,51 @@ def _load_json_if_exists(path):
return data if isinstance(data, dict) else {}
def _legacy_history_path():
return os.path.join(PROJECT_ROOT, "data", "daily_records.json")
def _legacy_snapshot_path():
return os.path.join(PROJECT_ROOT, "data", "probability_training_snapshots.jsonl")
def _default_history_arg():
return _legacy_history_path() if get_state_storage_mode() == STATE_STORAGE_FILE else None
def _default_snapshot_arg():
return _legacy_snapshot_path() if get_state_storage_mode() == STATE_STORAGE_FILE else None
def _load_history_with_fallback(path):
if not path:
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return DailyRecordRepository().load_all()
return {}
data = load_history(path)
if data:
return data
return _load_json_if_exists(path)
def _load_truth_history():
if get_state_storage_mode() != STATE_STORAGE_SQLITE:
return {}
try:
return TruthRecordRepository().load_all()
except Exception:
return {}
def _load_training_feature_history():
if get_state_storage_mode() != STATE_STORAGE_SQLITE:
return {}
try:
return TrainingFeatureRecordRepository().load_all()
except Exception:
return {}
def _load_snapshot_rows(path):
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return ProbabilitySnapshotRepository().load_all_rows()
@@ -65,18 +107,28 @@ def _load_snapshot_rows(path):
return rows
def _actual_high_for(history, settlement_history, city, date_str):
def _actual_high_for(history, truth_history, settlement_history, city, date_str):
city_rows = (history or {}).get(city) or {}
record = city_rows.get(date_str) or {}
actual_high = _sf(record.get("actual_high")) if isinstance(record, dict) else None
truth_record = ((truth_history.get(city) or {}).get(date_str) or {})
if actual_high is None and isinstance(truth_record, dict):
actual_high = _sf(truth_record.get("actual_high"))
filled = False
if actual_high is None:
actual_high = _sf(((settlement_history.get(city) or {}).get(date_str) or {}).get("max_temp"))
filled = actual_high is not None
return actual_high, filled
metadata = {
"settlement_source": truth_record.get("settlement_source"),
"settlement_station_code": truth_record.get("settlement_station_code"),
"truth_version": truth_record.get("truth_version"),
"truth_updated_by": truth_record.get("updated_by"),
"truth_updated_at": truth_record.get("truth_updated_at"),
}
return actual_high, filled, metadata
def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None):
def _extract_snapshot_samples(history, truth_history=None, snapshot_rows=None, settlement_history=None):
samples = []
filled_actual_from_history = 0
today = datetime.utcnow().strftime("%Y-%m-%d")
@@ -88,7 +140,13 @@ def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None):
if not city or not date_str or date_str == today:
continue
actual_high, filled = _actual_high_for(history, settlement_history, city, date_str)
actual_high, filled, truth_meta = _actual_high_for(
history,
truth_history or {},
settlement_history,
city,
date_str,
)
if actual_high is None:
continue
if filled:
@@ -150,13 +208,20 @@ def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None):
"max_so_far_gap": max_so_far_gap,
"peak_flag": peak_flag,
"sample_source": "snapshot",
**truth_meta,
}
)
return samples, filled_actual_from_history
def _extract_daily_record_samples(history, settlement_history=None, excluded_keys=None):
def _extract_daily_record_samples(
history,
training_feature_history=None,
truth_history=None,
settlement_history=None,
excluded_keys=None,
):
samples = []
filled_actual_from_history = 0
today = datetime.utcnow().strftime("%Y-%m-%d")
@@ -172,13 +237,18 @@ def _extract_daily_record_samples(history, settlement_history=None, excluded_key
if (city, date_str) in excluded_keys:
continue
actual_high = _sf(record.get("actual_high"))
truth_meta = ((truth_history or {}).get(city) or {}).get(date_str) or {}
if actual_high is None:
actual_high = _sf(truth_meta.get("actual_high"))
if actual_high is None:
actual_high = _sf((city_settlement.get(date_str) or {}).get("max_temp"))
if actual_high is not None:
filled_actual_from_history += 1
deb_prediction = _sf(record.get("deb_prediction"))
raw_mu = _sf(record.get("mu")) or deb_prediction
forecasts = record.get("forecasts") or {}
feature_record = ((training_feature_history or {}).get(city) or {}).get(date_str) or {}
source_record = feature_record if isinstance(feature_record, dict) and feature_record else record
deb_prediction = _sf(source_record.get("deb_prediction"))
raw_mu = _sf(source_record.get("mu")) or deb_prediction
forecasts = source_record.get("forecasts") or {}
if not isinstance(forecasts, dict):
forecasts = {}
forecast_values = [val for val in (_sf(v) for v in forecasts.values()) if val is not None]
@@ -186,7 +256,7 @@ def _extract_daily_record_samples(history, settlement_history=None, excluded_key
forecast_median = (
forecast_values[len(forecast_values) // 2] if forecast_values else None
)
feature_snapshot = record.get("probability_features") or {}
feature_snapshot = source_record.get("probability_features") or {}
if not isinstance(feature_snapshot, dict):
feature_snapshot = {}
@@ -226,15 +296,21 @@ def _extract_daily_record_samples(history, settlement_history=None, excluded_key
"max_so_far_gap": max_so_far_gap,
"peak_flag": peak_flag,
"sample_source": "daily_record",
"settlement_source": truth_meta.get("settlement_source"),
"settlement_station_code": truth_meta.get("settlement_station_code"),
"truth_version": truth_meta.get("truth_version"),
"truth_updated_by": truth_meta.get("updated_by"),
"truth_updated_at": truth_meta.get("truth_updated_at"),
}
)
return samples, filled_actual_from_history
def _extract_samples(history, settlement_history=None, snapshot_rows=None):
def _extract_samples(history, training_feature_history=None, truth_history=None, settlement_history=None, snapshot_rows=None):
snapshot_samples, snapshot_filled = _extract_snapshot_samples(
history,
snapshot_rows or [],
truth_history=truth_history,
snapshot_rows=snapshot_rows or [],
settlement_history=settlement_history,
)
excluded_keys = {
@@ -243,6 +319,8 @@ def _extract_samples(history, settlement_history=None, snapshot_rows=None):
}
daily_samples, daily_filled = _extract_daily_record_samples(
history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history,
excluded_keys=excluded_keys,
)
@@ -253,8 +331,8 @@ def main():
parser = argparse.ArgumentParser(description="Fit PolyWeather probability calibration parameters.")
parser.add_argument(
"--history-file",
default=os.path.join(PROJECT_ROOT, "data", "daily_records.json"),
help="Path to the historical daily_records.json file.",
default=_default_history_arg(),
help="Optional legacy daily_records.json path. In sqlite mode this defaults to the runtime database.",
)
parser.add_argument(
"--output",
@@ -273,8 +351,8 @@ def main():
)
parser.add_argument(
"--snapshot-file",
default=os.path.join(PROJECT_ROOT, "data", "probability_training_snapshots.jsonl"),
help="Optional JSONL file with archived probability snapshots.",
default=_default_snapshot_arg(),
help="Optional legacy JSONL snapshot archive path. In sqlite mode this defaults to the runtime database.",
)
parser.add_argument(
"--version",
@@ -284,10 +362,14 @@ def main():
args = parser.parse_args()
history = _load_history_with_fallback(args.history_file)
training_feature_history = _load_training_feature_history()
truth_history = _load_truth_history()
settlement_history = _load_json_if_exists(args.settlement_history)
snapshot_rows = _load_snapshot_rows(args.snapshot_file)
samples, filled_actual_from_history = _extract_samples(
history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history,
snapshot_rows=snapshot_rows,
)
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
def _select_exact_user_id(payload: object, email: str) -> str:
from src.payments.contract_checkout import PaymentCheckoutError
normalized_email = str(email or "").strip().lower()
users = payload.get("users") if isinstance(payload, dict) else None
if not isinstance(users, list) or not users:
raise PaymentCheckoutError(404, f"supabase user not found for email={email}")
matches = []
for row in users:
if not isinstance(row, dict):
continue
row_email = str(row.get("email") or "").strip().lower()
user_id = str(row.get("id") or "").strip()
if row_email == normalized_email and user_id:
matches.append(user_id)
unique_matches = []
for user_id in matches:
if user_id not in unique_matches:
unique_matches.append(user_id)
if len(unique_matches) == 1:
return unique_matches[0]
if len(unique_matches) > 1:
raise PaymentCheckoutError(
409,
f"multiple exact supabase users matched email={email}: {unique_matches}",
)
raise PaymentCheckoutError(404, f"exact supabase user not found for email={email}")
def _lookup_user_id_by_email(email: str) -> str:
from src.payments.contract_checkout import PAYMENT_CHECKOUT
payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001
"GET",
f"/admin/users?email={email}",
allowed_status=[200],
)
return _select_exact_user_id(payload, email)
def main() -> int:
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
from src.payments.contract_checkout import PAYMENT_CHECKOUT, PaymentCheckoutError
load_dotenv()
parser = argparse.ArgumentParser(
description="Manually grant a PolyWeather subscription by Supabase email.",
)
parser.add_argument("--email", required=True, help="Supabase email")
parser.add_argument(
"--plan-code",
default="pro_monthly",
help="Plan code to grant (default: pro_monthly)",
)
parser.add_argument(
"--days",
type=int,
default=30,
help="Subscription days to grant (default: 30)",
)
parser.add_argument(
"--actor",
default="manual_admin_grant",
help="Audit actor to record in entitlement_events",
)
args = parser.parse_args()
email = str(args.email or "").strip().lower()
plan_code = str(args.plan_code or "").strip() or "pro_monthly"
days = int(args.days or 0)
actor = str(args.actor or "").strip() or "manual_admin_grant"
if not email:
print(json.dumps({"ok": False, "reason": "invalid_email"}, ensure_ascii=False, indent=2))
return 1
if days <= 0:
print(json.dumps({"ok": False, "reason": "invalid_days"}, ensure_ascii=False, indent=2))
return 1
if not PAYMENT_CHECKOUT.supabase_url or not PAYMENT_CHECKOUT.supabase_service_role_key:
print(
json.dumps(
{
"ok": False,
"reason": "supabase_not_configured",
"detail": "SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY missing",
},
ensure_ascii=False,
indent=2,
)
)
return 1
try:
user_id = _lookup_user_id_by_email(email)
latest_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001
"GET",
"subscriptions",
params={
"select": "id,expires_at,status,plan_code,starts_at,source,created_at",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"order": "expires_at.desc",
"limit": "20",
},
allowed_status=[200],
)
now = datetime.now(timezone.utc)
before = None
upcoming = None
if isinstance(latest_rows, list):
for row in latest_rows:
if not isinstance(row, dict):
continue
starts_raw = str(row.get("starts_at") or "").strip()
starts_dt = None
if starts_raw:
try:
starts_dt = datetime.fromisoformat(starts_raw.replace("Z", "+00:00"))
if starts_dt.tzinfo is None:
starts_dt = starts_dt.replace(tzinfo=timezone.utc)
starts_dt = starts_dt.astimezone(timezone.utc)
except Exception:
starts_dt = None
if starts_dt is None or starts_dt <= now:
if before is None:
before = row
elif upcoming is None and str(row.get("plan_code") or "").strip().lower() == plan_code.lower():
upcoming = row
starts_at = now
if isinstance(before, dict):
before_plan_code = str(before.get("plan_code") or "").strip().lower()
before_source = str(before.get("source") or "").strip().lower()
before_is_trial = "trial" in before_plan_code or "trial" in before_source
if not before_is_trial:
expires_raw = str(before.get("expires_at") or "").strip()
if expires_raw:
try:
latest_exp = datetime.fromisoformat(expires_raw.replace("Z", "+00:00"))
if latest_exp.tzinfo is None:
latest_exp = latest_exp.replace(tzinfo=timezone.utc)
latest_exp = latest_exp.astimezone(timezone.utc)
if latest_exp > starts_at:
starts_at = latest_exp
except Exception:
pass
expires_at = starts_at + timedelta(days=days)
if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip():
updated = PAYMENT_CHECKOUT._rest( # noqa: SLF001
"PATCH",
"subscriptions",
params={"id": f"eq.{upcoming['id']}"},
payload={
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"updated_at": now.isoformat(),
},
prefer="return=representation",
allowed_status=[200],
)
subscription = updated[0] if isinstance(updated, list) and updated else {}
else:
created = PAYMENT_CHECKOUT._rest( # noqa: SLF001
"POST",
"subscriptions",
payload={
"user_id": user_id,
"plan_code": plan_code,
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": actor,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
},
prefer="return=representation",
allowed_status=[201],
)
subscription = created[0] if isinstance(created, list) and created else {}
PAYMENT_CHECKOUT._rest( # noqa: SLF001
"POST",
"entitlement_events",
payload={
"user_id": user_id,
"action": "subscription_granted",
"reason": "manual_admin_grant",
"actor": actor,
"payload": {
"email": email,
"plan_code": plan_code,
"days": days,
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"mode": "updated_upcoming" if isinstance(upcoming, dict) else "created_new",
},
"created_at": now.isoformat(),
},
prefer="return=representation",
allowed_status=[201],
)
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
print(
json.dumps(
{
"ok": True,
"email": email,
"user_id": user_id,
"plan_code": plan_code,
"days": days,
"before": before,
"subscription": subscription,
},
ensure_ascii=False,
indent=2,
default=str,
)
)
return 0
except PaymentCheckoutError as exc:
print(
json.dumps(
{
"ok": False,
"email": email,
"status_code": exc.status_code,
"error": exc.detail,
},
ensure_ascii=False,
indent=2,
)
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def parse_args() -> argparse.Namespace:
from src.utils.prewarm_dashboard import DEFAULT_CITIES
parser = argparse.ArgumentParser(
description="Prewarm PolyWeather summary/detail/market caches for selected cities.",
)
parser.add_argument(
"--base-url",
default=os.getenv("POLYWEATHER_BACKEND_URL", "http://127.0.0.1:8000"),
help="Backend base URL, defaults to POLYWEATHER_BACKEND_URL or http://127.0.0.1:8000",
)
parser.add_argument(
"--cities",
default=",".join(DEFAULT_CITIES),
help="Comma-separated city names to prewarm",
)
parser.add_argument("--force-refresh", action="store_true")
parser.add_argument("--include-detail", action="store_true")
parser.add_argument("--include-market", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
from src.utils.prewarm_dashboard import run_prewarm
return run_prewarm(
base_url=args.base_url,
cities=args.cities,
force_refresh=bool(args.force_refresh),
include_detail=bool(args.include_detail),
include_market=bool(args.include_market),
)
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def parse_args() -> argparse.Namespace:
from src.utils.prewarm_dashboard import DEFAULT_CITIES
parser = argparse.ArgumentParser(
description="Run a background dashboard prewarm worker for hot PolyWeather cities.",
)
parser.add_argument(
"--base-url",
default=os.getenv("POLYWEATHER_BACKEND_URL", "http://127.0.0.1:8000"),
help="Backend base URL, defaults to POLYWEATHER_BACKEND_URL or http://127.0.0.1:8000",
)
parser.add_argument(
"--cities",
default=",".join(DEFAULT_CITIES),
help="Comma-separated city names to prewarm",
)
parser.add_argument(
"--interval-sec",
type=int,
default=int(os.getenv("POLYWEATHER_PREWARM_INTERVAL_SEC", "300")),
help="Worker interval in seconds",
)
parser.add_argument(
"--jitter-sec",
type=int,
default=int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20")),
help="Random jitter added to each loop in seconds",
)
parser.add_argument("--force-refresh", action="store_true")
parser.add_argument("--include-detail", action="store_true")
parser.add_argument("--include-market", action="store_true")
parser.add_argument("--once", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
from src.utils.prewarm_dashboard import run_worker_loop
return run_worker_loop(
base_url=args.base_url,
cities=args.cities,
interval_sec=args.interval_sec,
jitter_sec=args.jitter_sec,
force_refresh=bool(args.force_refresh),
include_detail=bool(args.include_detail),
include_market=bool(args.include_market),
once=bool(args.once),
)
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import json
import os
import sys
from typing import Any, Dict
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCHEMA_PATH = os.path.join(ROOT_DIR, "artifacts", "models", "lgbm_daily_high_schema.json")
def _load_schema(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise SystemExit(f"Invalid schema payload in {path}")
return data
def _fmt_metric(value: Any) -> str:
if value is None:
return "--"
try:
return f"{float(value):.3f}"
except Exception:
return str(value)
def _winner(metrics: Dict[str, Any]) -> str:
candidates = {
"LGBM": metrics.get("lgbm_mae"),
"DEB": metrics.get("deb_mae"),
"Best Single": metrics.get("best_single_mae"),
"Median": metrics.get("median_mae"),
}
filtered = {k: float(v) for k, v in candidates.items() if v is not None}
if not filtered:
return "--"
return min(filtered.items(), key=lambda item: item[1])[0]
def _print_block(label: str, metrics: Dict[str, Any]) -> None:
print(label)
print(f" Samples : {metrics.get('sample_count', 0)}")
print(f" LGBM MAE : {_fmt_metric(metrics.get('lgbm_mae'))}")
print(f" DEB MAE : {_fmt_metric(metrics.get('deb_mae'))}")
print(f" Best Single : {_fmt_metric(metrics.get('best_single_mae'))}")
print(f" Model Median : {_fmt_metric(metrics.get('median_mae'))}")
print(f" Winner : {_winner(metrics)}")
def main() -> int:
path = sys.argv[1] if len(sys.argv) > 1 else SCHEMA_PATH
if not os.path.exists(path):
raise SystemExit(f"Schema file not found: {path}")
schema = _load_schema(path)
metrics = schema.get("metrics") or {}
validation = metrics.get("validation") or {}
full_sample = metrics.get("full_sample") or {}
print("LightGBM Daily High Report")
print(f" Target : {schema.get('target', '--')}")
print(f" Horizon : {schema.get('horizon', '--')}")
print(f" Sample Count : {schema.get('sample_count', 0)}")
print(f" Train Count : {schema.get('train_count', 0)}")
print(f" Valid Count : {schema.get('validation_count', 0)}")
print(f" Trained At : {schema.get('trained_at', '--')}")
print("")
_print_block("Validation", validation)
print("")
_print_block("Full Sample", full_sample)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
ProbabilitySnapshotRepository,
TrainingFeatureRecordRepository,
get_state_storage_mode,
)
def _load_legacy_snapshot_rows(path: str):
rows = []
if not path or not os.path.exists(path):
return rows
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def _spread_from_ensemble(ensemble: dict):
if not isinstance(ensemble, dict):
return None
try:
p10 = float(ensemble.get("p10"))
p90 = float(ensemble.get("p90"))
except Exception:
return None
if p90 < p10:
return None
return max(0.1, round((p90 - p10) / 2.56, 3))
def main():
parser = argparse.ArgumentParser(
description="Restore permanent training feature history from snapshot archives."
)
parser.add_argument(
"--snapshot-file",
default=os.path.join(PROJECT_ROOT, "data", "probability_training_snapshots.jsonl"),
)
args = parser.parse_args()
rows = []
if get_state_storage_mode() == "sqlite":
rows.extend(ProbabilitySnapshotRepository().load_all_rows())
rows.extend(_load_legacy_snapshot_rows(args.snapshot_file))
latest = {}
for row in rows:
city = str(row.get("city") or "").strip().lower()
date_str = str(row.get("date") or "").strip()
ts = str(row.get("timestamp") or "")
if not city or not date_str:
continue
key = (city, date_str)
current = latest.get(key)
if current is None or ts >= str(current.get("timestamp") or ""):
latest[key] = row
repo = TrainingFeatureRecordRepository()
restored = 0
for (city, date_str), row in latest.items():
repo.upsert_record(
city,
date_str,
{
"forecasts": row.get("multi_model") or {},
"deb_prediction": row.get("deb_prediction"),
"mu": row.get("raw_mu"),
"probability_features": {
"raw_mu": row.get("raw_mu"),
"raw_sigma": row.get("raw_sigma"),
"deb_prediction": row.get("deb_prediction"),
"ens_median": ((row.get("ensemble") or {}).get("median")),
"ensemble_spread": _spread_from_ensemble(row.get("ensemble") or {}),
"max_so_far": row.get("max_so_far"),
"peak_status": row.get("peak_status"),
},
"prob_snapshot": row.get("prob_snapshot") or [],
"shadow_prob_snapshot": row.get("shadow_prob_snapshot") or [],
"probability_calibration": {
"engine": row.get("probability_engine"),
"mode": row.get("probability_mode"),
"calibration_version": row.get("calibration_version"),
"calibration_source": row.get("calibration_source"),
"calibrated_mu": row.get("calibrated_mu"),
"calibrated_sigma": row.get("calibrated_sigma"),
},
"observation": row.get("observation") or {},
"snapshot_timestamp": row.get("timestamp"),
},
)
restored += 1
print(json.dumps({"restored_feature_records": restored}, ensure_ascii=False))
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.data_collection.city_registry import CITY_REGISTRY # noqa: E402
from src.database.runtime_state import TruthRecordRepository # noqa: E402
from scripts.fit_probability_calibration import ( # noqa: E402
_default_history_arg,
_load_history_with_fallback,
_load_json_if_exists,
)
def _sf(value):
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _truth_meta(city: str) -> dict:
city_meta = CITY_REGISTRY.get(city) or {}
return {
"settlement_source": str(city_meta.get("settlement_source") or "metar").strip().lower(),
"settlement_station_code": str(
city_meta.get("settlement_station_code") or city_meta.get("icao") or ""
).strip().upper()
or None,
"settlement_station_label": str(
city_meta.get("settlement_station_label")
or city_meta.get("airport_name")
or city_meta.get("name")
or ""
).strip()
or None,
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Restore permanent training truth history from settlement history and recent runtime cache."
)
parser.add_argument(
"--history-file",
default=_default_history_arg(),
)
parser.add_argument(
"--settlement-history",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"settlement_history.json",
),
)
parser.add_argument(
"--truth-version",
default="v1",
)
args = parser.parse_args()
repo = TruthRecordRepository()
settlement_history = _load_json_if_exists(args.settlement_history)
runtime_history = _load_history_with_fallback(args.history_file)
restored = 0
for city, city_rows in (settlement_history or {}).items():
if not isinstance(city_rows, dict):
continue
meta = _truth_meta(city)
for date_str, payload in city_rows.items():
if not isinstance(payload, dict):
continue
actual_high = _sf(payload.get("max_temp"))
if actual_high is None:
continue
repo.upsert_truth(
city=city,
target_date=str(date_str),
actual_high=actual_high,
settlement_source=meta["settlement_source"],
settlement_station_code=meta["settlement_station_code"],
settlement_station_label=meta["settlement_station_label"],
truth_version=args.truth_version,
updated_by="restore:settlement_history",
source_payload=payload,
is_final=True,
reason="restore_training_truth_history",
)
restored += 1
merged_recent = 0
for city, city_rows in (runtime_history or {}).items():
if not isinstance(city_rows, dict):
continue
meta = _truth_meta(city)
for date_str, payload in city_rows.items():
if not isinstance(payload, dict):
continue
actual_high = _sf(payload.get("actual_high"))
if actual_high is None:
continue
repo.upsert_truth(
city=city,
target_date=str(date_str),
actual_high=actual_high,
settlement_source=meta["settlement_source"],
settlement_station_code=meta["settlement_station_code"],
settlement_station_label=meta["settlement_station_label"],
truth_version=args.truth_version,
updated_by="restore:runtime_daily_records",
source_payload={
"actual_high": actual_high,
"payload_json": payload,
},
is_final=True,
reason="restore_training_truth_history",
)
merged_recent += 1
print(
json.dumps(
{
"restored_from_settlement_history": restored,
"merged_recent_runtime_records": merged_recent,
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
from __future__ import annotations
import argparse
import os
import sqlite3
import sys
from typing import Any
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
def main() -> None:
from src.database.db_manager import DBManager
parser = argparse.ArgumentParser(
description="Backfill Supabase public.profiles.telegram_* fields from local SQLite bindings.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print rows that would be synced without writing to Supabase.",
)
args = parser.parse_args()
db = DBManager()
synced = 0
skipped = 0
rows_out: list[dict[str, Any]] = []
with db._get_connection() as conn: # noqa: SLF001
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT
lower(trim(COALESCE(b.supabase_user_id, ''))) AS supabase_user_id,
b.telegram_id AS telegram_id,
COALESCE(u.username, '') AS telegram_username
FROM supabase_bindings b
LEFT JOIN users u
ON u.telegram_id = b.telegram_id
WHERE trim(COALESCE(b.supabase_user_id, '')) <> ''
ORDER BY b.updated_at DESC, b.supabase_user_id ASC
"""
).fetchall()
for row in rows:
supabase_user_id = str(row["supabase_user_id"] or "").strip().lower()
if not supabase_user_id:
skipped += 1
continue
telegram_id = int(row["telegram_id"] or 0)
telegram_username = str(row["telegram_username"] or "").strip()
payload = {
"supabase_user_id": supabase_user_id,
"telegram_id": telegram_id,
"telegram_username": telegram_username or None,
}
rows_out.append(payload)
if args.dry_run:
continue
ok = db._sync_supabase_profile_telegram_fields( # noqa: SLF001
supabase_user_id=supabase_user_id,
telegram_id=telegram_id,
telegram_username=telegram_username,
)
if ok:
synced += 1
else:
skipped += 1
if args.dry_run:
print(
{
"mode": "dry_run",
"rows": len(rows_out),
"items": rows_out,
}
)
return
print(
{
"mode": "sync",
"synced": synced,
"skipped": skipped,
"rows": len(rows_out),
}
)
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More