Stabilize the decision workspace data boundaries

The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests.

Constraint: Preserve existing global scan-terminal class names and API responses during the refactor

Constraint: No new dependencies; keep this as a file-boundary cleanup

Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path

Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording

Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules

Not-tested: Live DeepSeek provider network replay and browser visual QA
This commit is contained in:
2569718930@qq.com
2026-04-28 14:45:34 +08:00
parent 12d90c5051
commit b122e7cbae
23 changed files with 5699 additions and 4940 deletions
+3
View File
@@ -48,6 +48,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Business state tests
run: npm run test:business
- name: Build
run: npm run build
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
/* Scan terminal calendar/action view styles. */
.root :global(.scan-calendar-view) {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
}
.root :global(.scan-calendar-group) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
}
.root :global(.scan-calendar-group-head) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid rgba(90, 123, 166, 0.1);
}
.root :global(.scan-calendar-date) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-calendar-count) {
font-size: 13px;
color: #8fa4c3;
}
.root :global(.scan-calendar-subtitle) {
margin-top: 4px;
color: #7f96b6;
font-size: 12px;
font-weight: 700;
}
.root :global(.scan-calendar-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
padding: 14px;
}
.root :global(.scan-calendar-card),
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
}
.root :global(.scan-calendar-card) {
text-align: left;
padding: 14px;
cursor: pointer;
}
.root :global(.scan-calendar-card.selected) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42);
}
.root :global(.scan-calendar-card.peak-active) {
border-color: rgba(23, 217, 139, 0.34);
background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
}
.root :global(.scan-calendar-card.peak-next) {
border-color: rgba(94, 234, 212, 0.3);
}
.root :global(.scan-calendar-card.peak-past) {
opacity: 0.78;
}
.root :global(.scan-calendar-city) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-calendar-action) {
margin-top: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 15px;
font-weight: 800;
color: #dcecff;
}
.root :global(.scan-calendar-action span) {
color: #8fa4c3;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-action b) {
color: #eef7ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-calendar-action.buy) {
color: #21e391;
}
.root :global(.scan-calendar-action.sell) {
color: #ff6e6e;
}
.root :global(.scan-calendar-meta) {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 12px;
color: #8fa4c3;
font-size: 13px;
}
.root :global(.scan-calendar-countdown) {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.18);
border-radius: 12px;
background: rgba(20, 184, 166, 0.1);
color: #5fffe8;
font-size: 15px;
font-weight: 900;
}
.root :global(.scan-calendar-countdown small) {
display: block;
margin-top: 4px;
color: #9fb4d2;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-reason) {
margin: 10px 0 0;
color: #d6e4f5;
font-size: 13px;
font-weight: 760;
line-height: 1.45;
}
.root :global(.scan-summary-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-summary-grid.monitor) {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
padding: 18px;
}
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,11 @@ import {
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
@@ -78,6 +83,18 @@ function ScanTerminalScreen() {
const userLocalTime = useUserLocalClock();
const { setThemeMode, themeMode } = useScanTerminalTheme();
const lastMapSelectedCityRef = useRef<string>("");
const scanTerminalRootClassName = clsx(
styles.root,
scanTerminalStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
modalChromeStyles.root,
themeMode === "light" && "light",
);
const timeSortedRows = useMemo(
() => sortRowsByUserTime(terminalData?.rows || []),
@@ -373,7 +390,7 @@ function ScanTerminalScreen() {
if (store.proAccess.loading) {
return (
<div className={clsx(styles.root, scanTerminalStyles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-loading-state">
@@ -393,7 +410,7 @@ function ScanTerminalScreen() {
}
return (
<div className={clsx(styles.root, scanTerminalStyles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
@@ -0,0 +1,841 @@
/* Scan terminal light theme overrides. Keep imported after other scan CSS modules. */
.root :global(.scan-terminal.light) {
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%),
linear-gradient(180deg, #F7F9FC 0%, #EEF2F7 100%);
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-filter-panel),
.root :global(.scan-terminal.light .scan-data-grid),
.root :global(.scan-terminal.light .scan-detail-panel),
.root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
border-color: #E2E8F0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12);
}
.root :global(.scan-terminal.light .scan-sidebar-brand-name),
.root :global(.scan-terminal.light .scan-topbar-tab.active),
.root :global(.scan-terminal.light .scan-hero h1),
.root :global(.scan-terminal.light .scan-topbar-title strong),
.root :global(.scan-terminal.light .scan-city-name),
.root :global(.scan-terminal.light .scan-opportunity-city strong),
.root :global(.scan-terminal.light .panel-title-area h2),
.root :global(.scan-terminal.light .detail-summary-temp),
.root :global(.scan-terminal.light .scan-detail-city-name),
.root :global(.scan-terminal.light .scan-empty-title),
.root :global(.scan-terminal.light .scan-detail-section-title),
.root :global(.scan-terminal.light .scan-detail-volume-big),
.root :global(.scan-terminal.light .scan-kv strong),
.root :global(.scan-terminal.light .scan-time-main),
.root :global(.scan-terminal.light .scan-calendar-date),
.root :global(.scan-terminal.light .scan-summary-value),
.root :global(.scan-terminal.light .scan-distribution-card strong),
.root :global(.scan-terminal.light .scan-table-header),
.root :global(.scan-terminal.light .scan-list-tabs button.active) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-filter-heading),
.root :global(.scan-terminal.light .scan-topbar-actions),
.root :global(.scan-terminal.light .scan-topbar-time),
.root :global(.scan-terminal.light .scan-hero p),
.root :global(.scan-terminal.light .scan-topbar-title span),
.root :global(.scan-terminal.light .scan-city-sub),
.root :global(.scan-terminal.light .scan-opportunity-phase),
.root :global(.scan-terminal.light .scan-opportunity-phase em),
.root :global(.scan-terminal.light .panel-overline),
.root :global(.scan-terminal.light .detail-summary-supporting),
.root :global(.scan-terminal.light .detail-value-muted),
.root :global(.scan-terminal.light .detail-empty-state),
.root :global(.scan-terminal.light .scan-time-remaining),
.root :global(.scan-terminal.light .scan-detail-city-sub),
.root :global(.scan-terminal.light .scan-detail-volume-caption),
.root :global(.scan-terminal.light .scan-empty-copy),
.root :global(.scan-terminal.light .scan-loading-copy-block span),
.root :global(.scan-terminal.light .scan-kv span:first-child),
.root :global(.scan-terminal.light .scan-chart-label),
.root :global(.scan-terminal.light .scan-trade-sub),
.root :global(.scan-terminal.light .scan-trade-note) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-loading-copy-block strong) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-loading-signal) {
border-color: #dbeafe;
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%),
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9));
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.1);
}
.root :global(.scan-terminal.light .scan-loading-signal.compact) {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.1), transparent 34%),
rgba(255, 255, 255, 0.78);
}
.root :global(.scan-terminal.light .scan-loading-node) {
background: #ffffff;
border-color: rgba(148, 163, 184, 0.22);
}
.root :global(.scan-terminal.light .scan-loading-node.hot) {
background: linear-gradient(135deg, #f97316, #fde047);
}
.root :global(.scan-terminal.light .scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, #2563eb);
}
.root :global(.scan-terminal.light .scan-loading-node.action) {
background: linear-gradient(135deg, #16a34a, #22c55e);
}
.root :global(.scan-terminal.light .scan-list-tabs button),
.root :global(.scan-terminal.light .scan-mode-tab-sub),
.root :global(.scan-terminal.light .scan-opportunity-models em),
.root :global(.scan-terminal.light .scan-opportunity-stat small),
.root :global(.scan-terminal.light .panel-meta-chip),
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note) {
color: #94A3B8;
}
.root :global(.scan-terminal.light .scan-distribution-line em) {
color: #243b5a;
}
.root :global(.scan-terminal.light .scan-distribution-line b) {
color: #6f86a4;
}
.root :global(.scan-terminal.light .scan-mode-tab),
.root :global(.scan-terminal.light .scan-table-shell),
.root :global(.scan-terminal.light .scan-table-header),
.root :global(.scan-terminal.light .scan-kpi-card),
.root :global(.scan-terminal.light .scan-calendar-group),
.root :global(.scan-terminal.light .scan-calendar-card),
.root :global(.scan-terminal.light .scan-summary-card),
.root :global(.scan-terminal.light .scan-settings-card),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-opportunity-group),
.root :global(.scan-terminal.light .detail-summary-shell),
.root :global(.scan-terminal.light .detail-structured-section),
.root :global(.scan-terminal.light .detail-card),
.root :global(.scan-terminal.light .scan-trade-card) {
border-color: #E2E8F0;
background: #FFFFFF;
}
.root :global(.scan-terminal.light .scan-mode-tab.active) {
color: #1D4ED8;
background: #DBEAFE;
border-color: rgba(59, 130, 246, 0.34);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16);
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-label) {
color: #1D4ED8;
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-sub) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-calendar-action b) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-calendar-reason) {
color: #243b5a;
}
.root :global(.scan-terminal.light .scan-calendar-countdown) {
border-color: rgba(10, 160, 100, 0.18);
background: rgba(220, 252, 239, 0.68);
color: #087b55;
}
.root :global(.scan-terminal.light .scan-calendar-card.peak-active) {
border-color: rgba(10, 160, 100, 0.34);
background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86));
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) {
background: rgba(16, 185, 129, 0.16);
color: #069668;
}
.root :global(.scan-terminal.light .scan-table-row.selected) {
background: linear-gradient(180deg, rgba(228, 252, 242, 0.92), rgba(241, 250, 247, 0.96));
border-color: rgba(10, 160, 100, 0.34);
box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18);
}
.root :global(.scan-terminal.light .scan-table-row) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 250, 254, 0.9));
border-bottom-color: rgba(35, 72, 118, 0.08);
}
.root :global(.scan-terminal.light .scan-table-row:hover) {
background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96));
}
.root :global(.scan-terminal.light .scan-kpi-card) {
box-shadow: 0 10px 24px rgba(40, 70, 110, 0.08);
}
.root :global(.scan-terminal.light .scan-kpi-value),
.root :global(.scan-terminal.light .scan-opportunity-phase > span) {
color: #14253a;
}
.root :global(.scan-terminal.light .scan-opportunity-group-head) {
background: rgba(246, 250, 255, 0.78);
border-bottom-color: rgba(35, 72, 118, 0.1);
}
.root :global(.scan-terminal.light .scan-opportunity-item) {
background: rgba(255, 255, 255, 0.62);
color: #405977;
border-bottom-color: rgba(35, 72, 118, 0.08);
border-color: rgba(35, 72, 118, 0.1);
}
.root :global(.scan-terminal.light .scan-opportunity-item:hover) {
background: rgba(230, 242, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-opportunity-item.selected) {
background: linear-gradient(180deg, rgba(222, 250, 238, 0.9), rgba(241, 250, 247, 0.96));
border-color: rgba(10, 160, 100, 0.34);
}
.root :global(.scan-terminal.light .scan-opportunity-group.selected) {
border-color: rgba(10, 160, 100, 0.34);
box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18);
}
.root :global(.scan-terminal.light .scan-opportunity-models span) {
border-color: rgba(35, 72, 118, 0.1);
background: rgba(255, 255, 255, 0.68);
}
.root :global(.scan-terminal.light .scan-opportunity-stat) {
border-color: rgba(35, 72, 118, 0.1);
background: rgba(248, 252, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-opportunity-models b),
.root :global(.scan-terminal.light .scan-opportunity-stat b) {
color: #14253a;
}
.root :global(.scan-terminal.light .scan-opportunity-branch::before),
.root :global(.scan-terminal.light .scan-opportunity-branch i) {
background: rgba(56, 86, 126, 0.24);
}
.root :global(.scan-terminal.light .panel-header),
.root :global(.scan-terminal.light .panel-body section) {
border-color: rgba(35, 72, 118, 0.1);
}
.root :global(.scan-terminal.light .panel-action-button-secondary),
.root :global(.scan-terminal.light .panel-action-button-ghost) {
border-color: rgba(35, 72, 118, 0.14);
background: rgba(255, 255, 255, 0.76);
}
.root :global(.scan-terminal.light .detail-summary-shell-live) {
background: linear-gradient(180deg, rgba(224, 247, 255, 0.82), rgba(241, 248, 255, 0.92));
}
.root :global(.scan-terminal.light .scan-locale-switch),
.root :global(.scan-terminal.light .scan-ghost-button),
.root :global(.scan-terminal.light .scan-theme-button),
.root :global(.scan-terminal.light .scan-sort-pill),
.root :global(.scan-terminal.light .scan-icon-pill),
.root :global(.scan-terminal.light .scan-account-button),
.root :global(.scan-terminal.light .scan-detail-action-button) {
border-color: rgba(35, 72, 118, 0.14);
background: rgba(255, 255, 255, 0.78);
color: #1f3654;
}
.root :global(.scan-terminal.light .scan-primary-button) {
color: #FFFFFF;
border-color: rgba(59, 130, 246, 0.34);
background: linear-gradient(180deg, #3B82F6, #2563EB);
}
.root :global(.scan-terminal.light .scan-ai-button) {
color: #1D4ED8;
border-color: rgba(59, 130, 246, 0.28);
background: #DBEAFE;
}
.root :global(.scan-terminal.light .scan-opportunity-ai) {
border-color: rgba(35, 72, 118, 0.1);
background: rgba(248, 252, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-opportunity-ai small) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-opportunity-strength) {
border-color: rgba(35, 72, 118, 0.12);
background: rgba(248, 252, 255, 0.78);
}
.root :global(.scan-terminal.light .scan-opportunity-expand) {
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.18);
background: rgba(239, 246, 255, 0.84);
}
.root :global(.scan-terminal.light .scan-v4-analysis),
.root :global(.scan-terminal.light .scan-v4-analysis section),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span),
.root :global(.scan-terminal.light .scan-v4-model-sources span) {
border-color: rgba(35, 72, 118, 0.12);
background: rgba(255, 255, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-v4-analysis strong) {
color: #057a6b;
}
.root :global(.scan-terminal.light .scan-v4-analysis p),
.root :global(.scan-terminal.light .scan-v4-analysis ul),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span) {
color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-v4-model-sources em) {
color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-v4-model-sources b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-forecast-city-card),
.root :global(.scan-terminal.light .scan-forecast-row),
.root :global(.scan-terminal.light .scan-ai-analysis) {
border-color: #E2E8F0;
background: #FFFFFF;
}
.root :global(.scan-terminal.light .scan-forecast-city-head) {
background: #EEF2F7;
}
.root :global(.scan-terminal.light .scan-forecast-city-card.selected),
.root :global(.scan-terminal.light .scan-forecast-city-card:has(.scan-forecast-row.selected)) {
border-color: rgba(59, 130, 246, 0.38);
box-shadow:
inset 0 0 0 1px rgba(59, 130, 246, 0.12),
0 16px 34px rgba(40, 70, 110, 0.12);
}
.root :global(.scan-terminal.light .scan-forecast-row.selected),
.root :global(.scan-terminal.light .scan-forecast-row.expanded) {
border-color: rgba(59, 130, 246, 0.38);
background:
linear-gradient(180deg, rgba(219, 234, 254, 0.62), rgba(255, 255, 255, 0.98)),
#FFFFFF;
box-shadow: inset 4px 0 0 #3B82F6;
}
.root :global(.scan-terminal.light .scan-forecast-city-title strong),
.root :global(.scan-terminal.light .scan-forecast-city-read > b:not(.scan-phase-badge)),
.root :global(.scan-terminal.light .scan-forecast-bucket strong),
.root :global(.scan-terminal.light .scan-forecast-signals b),
.root :global(.scan-terminal.light .scan-ai-temperature-line b) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-forecast-city-chips span),
.root :global(.scan-terminal.light .scan-forecast-signals span),
.root :global(.scan-terminal.light .scan-ai-evidence-line span) {
border-color: #E2E8F0;
background: #EEF2F7;
}
.root :global(.scan-terminal.light .scan-forecast-city-chips span),
.root :global(.scan-terminal.light .scan-forecast-city-read span),
.root :global(.scan-terminal.light .scan-forecast-bucket small),
.root :global(.scan-terminal.light .scan-forecast-ai-line small),
.root :global(.scan-terminal.light .scan-ai-brief-grid li),
.root :global(.scan-terminal.light .scan-ai-airport-read p) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-ai-analysis-head p) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-ai-analysis-head small),
.root :global(.scan-terminal.light .scan-ai-evidence-line b) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-ai-workspace) {
background: #f7f9fc;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-overview) {
background: #f7f9fc;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-hero > div:first-child),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane),
.root :global(.scan-terminal.light .scan-opportunity-decision-card),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span) {
background: #ffffff;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-hero strong),
.root :global(.scan-terminal.light .scan-opportunity-summary b),
.root :global(.scan-terminal.light .scan-opportunity-lane-head strong),
.root :global(.scan-terminal.light .scan-opportunity-decision-head strong),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-opportunity-hero p),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane-head p),
.root :global(.scan-terminal.light .scan-opportunity-decision-card p),
.root :global(.scan-terminal.light .scan-opportunity-decision-head span),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span),
.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) {
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-card),
.root :global(.scan-terminal.light .scan-ai-city-hero) {
background: #ffffff;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-ai-city-section),
.root :global(.scan-terminal.light .scan-ai-decision-band),
.root :global(.scan-terminal.light .scan-ai-market-decision),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness span) {
background: #eef2f7;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-ai-workspace-head strong),
.root :global(.scan-terminal.light .scan-ai-city-hero h3),
.root :global(.scan-terminal.light .scan-ai-city-hero-side > strong),
.root :global(.scan-terminal.light .scan-ai-decision-band strong),
.root :global(.scan-terminal.light .scan-ai-decision-metrics b),
.root :global(.scan-terminal.light .scan-ai-market-bucket strong),
.root :global(.scan-terminal.light .scan-ai-weather-summary),
.root :global(.scan-terminal.light .scan-ai-decision-reasons small),
.root :global(.scan-terminal.light .scan-ai-market-decision strong),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-ai-workspace-head p),
.root :global(.scan-terminal.light .scan-ai-city-section p),
.root :global(.scan-terminal.light .scan-ai-decision-band p),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket span),
.root :global(.scan-terminal.light .scan-ai-market-decision span),
.root :global(.scan-terminal.light .scan-ai-market-decision p),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-city-muted),
.root :global(.scan-terminal.light .scan-ai-city-loading),
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
.root :global(.scan-terminal.light .scan-ai-weather-bullets) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-ai-raw-metar) {
border-top-color: #e2e8f0;
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) {
border-color: #cbd5e1;
background: #f8fafc;
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-log-panel) {
border-color: rgba(35, 72, 118, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9));
}
.root :global(.scan-terminal.light .scan-ai-log-panel.compact) {
background: rgba(255, 255, 255, 0.82);
}
.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary span) {
color: #334155;
}
.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary strong) {
color: #2563eb;
}
.root :global(.scan-terminal.light .scan-ai-log-head strong),
.root :global(.scan-terminal.light .scan-ai-log-item b) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-ai-log-head span),
.root :global(.scan-terminal.light .scan-ai-log-time),
.root :global(.scan-terminal.light .scan-ai-log-empty) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-ai-log-item) {
border-color: rgba(35, 72, 118, 0.12);
background: rgba(255, 255, 255, 0.72);
}
.root :global(.scan-terminal.light .scan-ai-log-item small) {
color: #58708f;
}
.root :global(.scan-terminal.light .scan-ai-log-item.info) {
border-color: rgba(14, 165, 233, 0.24);
}
.root :global(.scan-terminal.light .scan-ai-log-item.success) {
border-color: rgba(10, 160, 100, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-log-item.warning) {
border-color: rgba(217, 119, 6, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-log-item.error) {
border-color: rgba(220, 38, 38, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-summary-card),
.root :global(.scan-terminal.light .scan-ai-city-card) {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(148, 163, 184, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-summary-card strong),
.root :global(.scan-terminal.light .scan-ai-city-head strong),
.root :global(.scan-terminal.light .scan-ai-contract b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-ai-summary-card p),
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small) {
color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-ai-city-head) {
background: #f8fafc;
}
.root :global(.scan-terminal.light .scan-ai-cluster-note) {
color: #334155;
background: #f8fafc;
border-color: rgba(148, 163, 184, 0.26);
}
.root :global(.scan-terminal.light .scan-ai-contract) {
border-top-color: rgba(148, 163, 184, 0.2);
}
.root :global(.scan-terminal.light .scan-ai-inline-button) {
color: #064e3b;
background: #d1fae5;
border-color: rgba(16, 185, 129, 0.28);
}
.root :global(.scan-terminal.light .scan-cta-ghost),
.root :global(.scan-terminal.light .scan-detail-analysis-button) {
border-color: rgba(7, 160, 100, 0.24);
background: rgba(11, 188, 116, 0.12);
color: #057a4d;
}
.root :global(.scan-terminal.light .scan-score-ring::after) {
background: #f5f9fe;
}
.root :global(.scan-terminal.light .scan-score-ring span) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-trade-card p) {
color: #405977;
}
.root :global(.scan-terminal.light .scan-map-shell) {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%),
linear-gradient(180deg, #f8fbff, #eaf3ff);
border-color: rgba(37, 99, 235, 0.16);
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12);
}
.root :global(.scan-terminal.light .scan-map-shell .map),
.root :global(.scan-terminal.light .scan-map-shell .leaflet-container) {
background: #eaf3ff !important;
}
.root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) {
filter: saturate(1.06) contrast(0.98) brightness(1.04) !important;
}
.root :global(.scan-terminal.light .scan-map-caption) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-list-tabs button),
.root :global(.scan-terminal.light .scan-mode-tab-sub),
.root :global(.scan-terminal.light .scan-opportunity-models em),
.root :global(.scan-terminal.light .scan-opportunity-stat small),
.root :global(.scan-terminal.light .panel-meta-chip),
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note),
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small),
.root :global(.scan-terminal.light .scan-opportunity-ai small),
.root :global(.scan-terminal.light .scan-ai-log-head span),
.root :global(.scan-terminal.light .scan-ai-log-time),
.root :global(.scan-terminal.light .scan-ai-log-empty),
.root :global(.scan-terminal.light .scan-ai-log-item small),
.root :global(.scan-terminal.light .scan-ai-summary-card p),
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small),
.root :global(.scan-terminal.light .scan-v4-analysis p),
.root :global(.scan-terminal.light .scan-v4-analysis ul),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span),
.root :global(.scan-terminal.light .scan-v4-model-sources em),
.root :global(.scan-terminal.light .scan-ai-raw-metar),
.root :global(.scan-terminal.light .scan-opportunity-hero p),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane-head p),
.root :global(.scan-terminal.light .scan-opportunity-decision-card p),
.root :global(.scan-terminal.light .scan-opportunity-decision-head span),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span),
.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) {
color: #475569;
}
/* Light-mode hard overrides for the scan terminal. Keep this block last so the
decision workspace cannot inherit the dark map/card palette from base rules. */
.root:global(.light) {
--bg-primary: #eef7ff;
--bg-secondary: #e0f2fe;
--bg-card: #ffffff;
--bg-glass: rgba(255, 255, 255, 0.92);
--border-glass: #cfe8ff;
--border-subtle: rgba(125, 171, 214, 0.34);
--text-primary: #0f172a;
--text-secondary: #334155;
--text-muted: #475569;
}
.root:global(.light) :global(.scan-terminal.light) {
background:
radial-gradient(circle at 18% 12%, rgba(59, 130, 246, 0.12), transparent 30%),
linear-gradient(180deg, #eef7ff 0%, #e8f4ff 48%, #f8fbff 100%) !important;
color: #0f172a !important;
}
:global(html.light) .root :global(.map),
.root:global(.light) :global(.map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-container),
.root:global(.light) :global(.leaflet-container),
.root:global(.light) :global(.leaflet-pane),
.root:global(.light) :global(.leaflet-map-pane),
.root:global(.light) :global(.leaflet-tile-pane) {
background: #eef7ff !important;
}
:global(html.light) .root :global(.map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell) {
border-color: rgba(37, 99, 235, 0.18) !important;
box-shadow:
0 18px 40px rgba(61, 100, 145, 0.14),
inset 0 0 0 1px rgba(255, 255, 255, 0.72) !important;
}
:global(html.light) .root :global(.map .leaflet-tile),
.root:global(.light) :global(.map .leaflet-tile),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-tile) {
filter: none !important;
opacity: 1 !important;
}
.root:global(.light) :global(.scan-city-detail-rail),
.root:global(.light) :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
background:
linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96)) !important;
border-color: rgba(37, 99, 235, 0.16) !important;
color: #0f172a !important;
}
.root:global(.light) :global(.scan-city-detail-rail .panel-header),
.root:global(.light) :global(.scan-city-detail-rail .detail-structured-section),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-shell),
.root:global(.light) :global(.scan-city-detail-rail .detail-card) {
background: rgba(255, 255, 255, 0.74) !important;
border-color: rgba(125, 171, 214, 0.28) !important;
}
.root:global(.light) :global(.scan-city-detail-rail h2),
.root:global(.light) :global(.scan-city-detail-rail h3),
.root:global(.light) :global(.scan-city-detail-rail strong),
.root:global(.light) :global(.scan-city-detail-rail .detail-value),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-temp),
.root:global(.light) :global(.scan-city-detail-rail .detail-section-head h3),
.root:global(.light) :global(.scan-city-detail-rail .panel-title-area h2) {
color: #0f172a !important;
}
.root:global(.light) :global(.scan-city-detail-rail p),
.root:global(.light) :global(.scan-city-detail-rail li),
.root:global(.light) :global(.scan-city-detail-rail small),
.root:global(.light) :global(.scan-city-detail-rail .panel-overline),
.root:global(.light) :global(.scan-city-detail-rail .panel-loading-hint),
.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip),
.root:global(.light) :global(.scan-city-detail-rail .detail-section-kicker),
.root:global(.light) :global(.scan-city-detail-rail .detail-label),
.root:global(.light) :global(.scan-city-detail-rail .detail-value-muted),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-note),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-kind),
.root:global(.light) :global(.scan-city-detail-rail .detail-mini-meta),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-supporting),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-city-sub),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-volume-caption),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-score-label),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-empty) {
color: #334155 !important;
}
.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip),
.root:global(.light) :global(.scan-city-detail-rail .panel-weather-chip),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-link),
.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-secondary),
.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-ghost) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.18) !important;
color: #1f3654 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-section),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card p),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
color: #334155 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
color: #047857 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
color: #1d4ed8 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
color: #b45309 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
color: #b91c1c !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement) {
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 36%),
#ffffff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12) !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-head h3),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
color: #0f172a !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: #334155 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy span) {
color: #047857 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why) {
color: #1d4ed8 !important;
}
@@ -0,0 +1,413 @@
/* Scan terminal mobile action-card layout and responsive overrides. */
.root :global(.scan-mobile-decision-card) {
display: grid;
gap: 12px;
padding-bottom: 14px;
}
.root :global(.scan-mobile-decision-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 0;
}
.root :global(.scan-mobile-decision-head h3) {
margin: 6px 0 0;
color: #e6edf3;
font-size: 24px;
line-height: 1.08;
}
.root :global(.scan-mobile-decision-metrics) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 0 14px;
}
.root :global(.scan-mobile-decision-metrics span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-mobile-decision-metrics small) {
color: #9fb2c7;
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-mobile-decision-metrics b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-mobile-decision-reason) {
margin: 0 14px;
border: 1px solid rgba(77, 163, 255, 0.24);
border-radius: 13px;
background: rgba(77, 163, 255, 0.1);
color: #d8e7f8;
font-size: 14px;
font-weight: 900;
line-height: 1.45;
padding: 11px 12px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags),
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness),
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line),
.root :global(.scan-mobile-decision-folds) {
margin-right: 14px;
margin-left: 14px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) {
grid-template-columns: 1fr;
max-width: none;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-decision) {
display: none;
}
.root :global(.scan-mobile-decision-folds) {
display: grid;
gap: 10px;
}
.root :global(.scan-mobile-fold) {
padding: 13px 14px;
}
.root :global(.scan-mobile-fold summary) {
cursor: pointer;
list-style: none;
margin-bottom: 0;
}
.root :global(.scan-mobile-fold summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-mobile-fold[open] summary) {
margin-bottom: 10px;
}
.root :global(.scan-mobile-fold .scan-ai-city-section.models) {
border: 0;
background: transparent;
padding: 0;
}
.root :global(.scan-ai-city-collapse svg) {
transition: transform 0.18s ease;
}
@media (max-width: 1480px) {
.root :global(.scan-terminal) {
grid-template-columns: minmax(0, 1fr);
}
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
position: relative;
top: auto;
grid-column: 1 / -1;
height: auto;
max-height: none;
min-height: auto;
}
.root :global(.scan-detail-panel) {
grid-column: 1 / -1;
min-height: auto;
}
}
@media (max-width: 1100px) {
.root :global(.scan-terminal) {
grid-template-columns: 1fr;
}
.root :global(.scan-filter-panel),
.root :global(.scan-data-grid),
.root :global(.scan-detail-panel),
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
min-height: auto;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.scan-topbar) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-upgrade-announcement) {
grid-template-columns: 1fr;
}
.root :global(.scan-upgrade-announcement ul) {
justify-content: flex-start;
}
.root :global(.scan-table-header),
.root :global(.scan-table-row) {
grid-template-columns: 1fr;
}
.root :global(.scan-table-header) {
display: none;
}
.root :global(.scan-opportunity-group-head),
.root :global(.scan-opportunity-item) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-head),
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-brief-grid),
.root :global(.scan-ai-city-analysis-grid),
.root :global(.scan-ai-decision-band),
.root :global(.scan-ai-evidence-line) {
grid-template-columns: 1fr;
}
.root :global(.scan-ai-workspace-head),
.root :global(.scan-opportunity-hero),
.root :global(.scan-ai-city-hero),
.root :global(.scan-ai-city-section-head) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-opportunity-hero) {
display: flex;
}
.root :global(.scan-ai-workspace-head p),
.root :global(.scan-ai-city-hero-side) {
text-align: left;
justify-items: start;
}
.root :global(.scan-ai-decision-metrics) {
min-width: 0;
grid-template-columns: 1fr;
}
.root :global(.scan-ai-market-decision) {
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-card-grid),
.root :global(.scan-opportunity-summary) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-read) {
justify-items: start;
text-align: left;
}
.root :global(.scan-forecast-signals),
.root :global(.scan-ai-temperature-line) {
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-phase) {
flex-wrap: wrap;
}
.root :global(.scan-opportunity-branch) {
display: none;
}
.root :global(.scan-opportunity-stat.edge) {
justify-self: start;
justify-items: start;
}
.root :global(.scan-opportunity-ai),
.root :global(.scan-v4-analysis) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis) {
grid-template-columns: 1fr;
}
.root :global(.scan-trade-cards) {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.root :global(.scan-terminal) {
padding: 10px;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: 1fr;
}
.root :global(.scan-topbar-tabs) {
gap: 14px;
flex-wrap: wrap;
}
.root :global(.scan-upgrade-announcement) {
padding: 14px;
}
.root :global(.scan-upgrade-announcement-copy strong) {
font-size: 16px;
}
.root :global(.scan-upgrade-announcement ul) {
gap: 6px;
}
.root :global(.scan-upgrade-announcement li) {
font-size: 11px;
padding: 6px 8px;
}
.root :global(.scan-ai-city-hero) {
padding: 16px;
}
.root :global(.scan-ai-city-hero h3) {
margin-bottom: 10px;
font-size: 24px;
}
.root :global(.scan-ai-city-mobile-priority) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.root :global(.scan-ai-city-mobile-priority span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-ai-city-mobile-priority small) {
color: #9fb2c7;
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-ai-city-mobile-priority b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-ai-city-pills) {
display: none;
}
.root :global(.scan-ai-city-freshness) {
grid-template-columns: 1fr;
}
.root :global(.scan-ai-decision-band) {
padding: 14px;
}
.root :global(.scan-ai-decision-band strong) {
font-size: 20px;
}
.root :global(.scan-ai-decision-long),
.root :global(.scan-ai-decision-reasons),
.root :global(.scan-ai-decision-risk) {
display: none;
}
.root :global(.scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(11, 18, 32, 0.48);
padding: 10px 12px;
}
.root :global(.scan-ai-market-mobile-line span) {
color: #9fb2c7;
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-ai-market-mobile-line b) {
color: #e6edf3;
font-size: 13px;
text-align: right;
}
.root :global(.scan-ai-market-decision) {
margin-top: 10px;
}
.root :global(.scan-ai-city-ai-read:not([open])) {
padding-bottom: 4px;
}
.root :global(.scan-list-header) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-opportunity-groups) {
padding: 12px;
}
.root :global(.scan-opportunity-models span) {
white-space: normal;
}
.root :global(.scan-forecast-city-title strong) {
font-size: 21px;
}
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-analysis) {
padding: 13px;
}
.root :global(.scan-forecast-ai-line) {
grid-template-columns: 1fr;
padding: 0 13px 13px;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import {
buildAiCityErrorForecastState,
buildAiCityForecastCacheKey,
buildAiCityForecastKey,
buildAiCityProgressForecastState,
buildAiCityReadyForecastState,
readReadyCachedAiForecastState,
} from "@/components/dashboard/scan-terminal/ai-city-forecast-stream-state";
import { readCachedPayload, writeCachedPayload } from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type { AiCityForecastPayload, AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
function installLocalStorageMock() {
const store = new Map<string, string>();
const localStorage = {
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
removeItem: (key: string) => {
store.delete(key);
},
setItem: (key: string, value: string) => {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { localStorage },
});
return localStorage;
}
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
return {
airport_current: {
raw_metar: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
temp: 21,
},
current: {
temp: 21,
},
local_date: "2026-04-28",
metar_status: {
last_observation_time: "2026-04-28T00:00:00Z",
stale_for_today: false,
},
name: "Test City",
temp_symbol: "°C",
...extra,
} as CityDetail;
}
function readyPayload(extra: Partial<AiCityForecastPayload> = {}): AiCityForecastPayload {
return {
city_forecast: {
confidence: "medium",
final_judgment_en: "Centered near 25°C.",
final_judgment_zh: "预计最高温以 25°C 为中枢。",
metar_read_en: "Latest METAR supports the path.",
metar_read_zh: "最新 METAR 支撑当前路径。",
model_cluster_note_en: "Models are clustered.",
model_cluster_note_zh: "模型较集中。",
predicted_max: 25,
range_high: 26,
range_low: 24,
reasoning_en: "Evidence is aligned.",
reasoning_zh: "证据一致。",
risks_en: [],
risks_zh: [],
unit: "°C",
},
status: "ready",
...extra,
};
}
export function runTests() {
const storage = installLocalStorageMock();
storage.clear();
const forecastKey = buildAiCityForecastKey({
detail: cityDetail(),
detailCityName: "Test City",
locale: "zh-CN",
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
});
const cacheKey = buildAiCityForecastCacheKey(forecastKey);
const payload = readyPayload();
writeCachedPayload(cacheKey, payload);
const cachedReady = readReadyCachedAiForecastState(cacheKey, 0);
assert.equal(cachedReady?.status, "ready");
assert.equal(cachedReady?.payload?.city_forecast?.predicted_max, 25);
const degradedCacheKey = `${cacheKey}:degraded`;
writeCachedPayload(degradedCacheKey, readyPayload({ degraded: true }));
assert.equal(readReadyCachedAiForecastState(degradedCacheKey, 0), null);
assert.equal(readCachedPayload(degradedCacheKey, 60 * 60 * 1000), null);
const currentLoading: AiCityForecastState = {
status: "loading",
streamText: "已有快速判断",
};
const callingAiProgress = buildAiCityProgressForecastState({
cacheKey: `${cacheKey}:progress`,
current: currentLoading,
isEn: false,
progress: {
message_zh: "DeepSeek 正在补充机场报文细节",
stage: "calling_ai",
},
});
assert.equal(callingAiProgress?.streamText, "已有快速判断");
const errorState = buildAiCityErrorForecastState({
cacheKey: `${cacheKey}:error`,
detail: cityDetail(),
error: new Error("timeout"),
isEn: false,
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
});
assert.equal(errorState.status, "ready");
assert.equal(errorState.payload?.status, "timeout_fallback");
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
const hkoState = buildAiCityErrorForecastState({
cacheKey: `${cacheKey}:hko`,
detail: cityDetail({
airport_current: null,
current: {
settlement_source: "hko",
temp: 30,
},
settlement_station: {
settlement_source: "hko",
},
} as unknown as Partial<CityDetail>),
error: new Error("timeout"),
isEn: false,
report: "",
});
const hkoRead = hkoState.payload?.city_forecast?.metar_read_zh || "";
assert.doesNotMatch(hkoRead, /METAR|机场报文/);
assert.match(hkoRead, /官方观测|30\.0°C/);
const degradedReadyState = buildAiCityReadyForecastState({
cacheKey: `${cacheKey}:ready-degraded`,
detail: cityDetail(),
isEn: false,
payload: readyPayload({ degraded: true }),
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
});
assert.equal(degradedReadyState.status, "ready");
assert.equal(readCachedPayload(`${cacheKey}:ready-degraded`, 60 * 60 * 1000), null);
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import {
buildCityMarketScanCacheKey,
deriveCityMarketScanView,
resolveCityMarketScanSnapshot,
writeCachedCityMarketScan,
} from "@/components/dashboard/scan-terminal/market-scan-state";
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
function installLocalStorageMock() {
const store = new Map<string, string>();
const localStorage = {
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
removeItem: (key: string) => {
store.delete(key);
},
setItem: (key: string, value: string) => {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { localStorage },
});
return localStorage;
}
function marketScan(label = "cached"): MarketScan {
return {
generated_at: "2026-04-28T00:00:00Z",
label,
} as unknown as MarketScan;
}
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
return {
local_date: "2026-04-28",
name: "Test City",
...extra,
} as CityDetail;
}
export function runTests() {
const storage = installLocalStorageMock();
storage.clear();
const embeddedScan = marketScan("embedded");
const embeddedSnapshot = resolveCityMarketScanSnapshot({
detail: cityDetail({ market_scan: embeddedScan }),
detailCityName: "Test City",
enabled: true,
});
assert.equal(embeddedSnapshot.action, "success");
if (embeddedSnapshot.action === "success") {
assert.equal(embeddedSnapshot.payload, embeddedScan);
assert.equal(embeddedSnapshot.shouldWriteCache, true);
}
const cacheKey = buildCityMarketScanCacheKey({
detailCityName: "Test City",
localDate: "2026-04-28",
});
const cachedScan = marketScan("cached");
writeCachedCityMarketScan(cacheKey, cachedScan);
const cachedSnapshot = resolveCityMarketScanSnapshot({
detail: cityDetail(),
detailCityName: "Test City",
enabled: false,
});
assert.equal(cachedSnapshot.action, "success");
if (cachedSnapshot.action === "success") {
assert.equal((cachedSnapshot.payload as unknown as { label: string }).label, "cached");
}
storage.clear();
assert.equal(
resolveCityMarketScanSnapshot({
detail: cityDetail(),
detailCityName: "Test City",
enabled: false,
}).action,
"reset",
);
assert.equal(
resolveCityMarketScanSnapshot({
detail: cityDetail(),
detailCityName: "Test City",
enabled: true,
}).action,
"fetch",
);
const previous = marketScan("previous");
const loadingRemote: RemoteData<MarketScan> = {
previous,
status: "loading",
};
const loadingView = deriveCityMarketScanView({
detailMarketScan: null,
marketRemote: loadingRemote,
});
assert.equal(loadingView.marketStatus, "loading");
assert.equal(loadingView.marketScan, previous);
const errorWithPrevious = deriveCityMarketScanView({
detailMarketScan: null,
marketRemote: {
error: "network",
previous,
status: "error",
},
});
assert.equal(errorWithPrevious.marketStatus, "ready");
assert.equal(errorWithPrevious.marketScan, previous);
const errorWithoutPrevious = deriveCityMarketScanView({
detailMarketScan: null,
marketRemote: {
error: "network",
status: "error",
},
});
assert.equal(errorWithoutPrevious.marketStatus, "failed");
assert.equal(errorWithoutPrevious.marketScan, null);
}
@@ -0,0 +1,346 @@
import type { AiCityStreamProgress } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
buildStorageKey,
readCachedPayload,
removeCachedPayload,
writeCachedPayload,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
detail?.current?.settlement_source ||
detail?.settlement_station?.settlement_source ||
"",
)
.trim()
.toLowerCase();
return source === "hko";
}
export function buildAiCityForecastKey({
detail,
detailCityName,
locale,
report,
}: {
detail: CityDetail | null;
detailCityName: string;
locale: string;
report: string;
}) {
if (!detail) return "";
const isHkoObservation = isHkoObservationCity(detail);
const observationSource = isHkoObservation ? "hko" : "metar";
const observationCurrent = isHkoObservation
? detail.current || {}
: detail.airport_current || detail.current || {};
const observationSignature =
(!isHkoObservation ? String(report || "").trim() : "") ||
[
observationSource,
observationCurrent.report_time,
observationCurrent.obs_time_epoch,
observationCurrent.obs_time,
observationCurrent.receipt_time,
observationCurrent.temp,
observationCurrent.max_so_far,
observationCurrent.station_code,
detail.metar_status?.stale_for_today,
detail.metar_status?.last_observation_time,
]
.filter((part) => part != null && part !== "")
.join("|");
return [
normalizeCityKey(detailCityName),
detail.local_date || "",
locale,
observationSignature,
].join(":");
}
export function buildAiCityForecastCacheKey(aiForecastKey: string) {
return buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
}
export function buildAiCityForecastRequestKey(cacheKey: string, refreshToken: number) {
return `${cacheKey}:${refreshToken > 0 ? `refresh:${refreshToken}` : "normal"}`;
}
export function readCachedAiForecastState(key: string) {
const cached = aiCityForecastStateCache.get(key);
if (!cached) return null;
if (Date.now() - cached.updatedAt > AI_CITY_FORECAST_CACHE_TTL_MS) {
aiCityForecastStateCache.delete(key);
return null;
}
return cached.state;
}
export function writeCachedAiForecastState(
key: string,
state: AiCityForecastState,
) {
if (!key || state.status === "idle") return;
aiCityForecastStateCache.set(key, {
state,
updatedAt: Date.now(),
});
}
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
if (refreshToken > 0) return null;
const cachedPayload = readCachedPayload<AiCityForecastPayload>(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
);
if (cachedPayload) {
if (
cachedPayload.status === "ready" &&
!cachedPayload.degraded &&
cachedPayload.city_forecast
) {
const readyState: AiCityForecastState = {
payload: cachedPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
return readyState;
}
removeCachedPayload(cacheKey);
}
const cachedState = readCachedAiForecastState(cacheKey);
return cachedState?.status === "ready" ? cachedState : null;
}
function getAiCityStreamProgressText(
progress: AiCityStreamProgress,
isEn: boolean,
) {
const localizedMessage = String(
(isEn ? progress.message_en : progress.message_zh) ||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
"",
).trim();
if (localizedMessage) return localizedMessage;
const rawLength = Number(progress.raw_length);
if (Number.isFinite(rawLength) && rawLength > 0) {
return isEn
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
}
return "";
}
export function buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
}: {
detail: CityDetail | null;
error?: unknown;
isEn: boolean;
report: string;
}): AiCityForecastPayload {
const tempSymbol = detail?.temp_symbol || "°C";
const isHkoObservation = isHkoObservationCity(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail?.airport_primary?.temp ??
detail?.current?.temp) ?? null;
const currentText =
currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
: isEn
? "the latest observed temperature"
: "最新实测温度";
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
const rawMetar = isHkoObservation
? ""
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
const finalZh = timeoutLike
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
const finalEn = timeoutLike
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
const metarZh = rawMetar
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
const metarEn = rawMetar
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
return {
city_forecast: {
confidence: "low",
final_judgment_en: finalEn,
final_judgment_zh: finalZh,
metar_read_en: metarEn,
metar_read_zh: metarZh,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: reasonEn,
reasoning_zh: reasonZh,
risks_en: [],
risks_zh: [],
unit: tempSymbol,
},
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
reason: isEn ? reasonEn : reasonZh,
reason_en: reasonEn,
reason_zh: reasonZh,
status: timeoutLike ? "timeout_fallback" : "fallback",
};
}
export function buildAiCityLoadingForecastState({
cacheKey,
detail,
isEn,
report,
}: {
cacheKey: string;
detail: CityDetail | null;
isEn: boolean;
report: string;
}) {
const cachedState = readCachedAiForecastState(cacheKey);
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
const loadingState: AiCityForecastState =
cachedState?.status === "loading"
? cachedState
: {
status: "loading",
streamText:
(isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh) ||
(isEn
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
};
writeCachedAiForecastState(cacheKey, loadingState);
return loadingState;
}
export function buildAiCityProgressForecastState({
cacheKey,
current,
isEn,
progress,
}: {
cacheKey: string;
current: AiCityForecastState;
isEn: boolean;
progress: AiCityStreamProgress;
}) {
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return null;
const cachedProgressState = readCachedAiForecastState(cacheKey);
const nextStreamText =
progress.stage === "calling_ai" && cachedProgressState?.streamText
? cachedProgressState.streamText
: progressText;
const cachedNextState: AiCityForecastState = {
...cachedProgressState,
status: "loading",
streamText: nextStreamText,
};
writeCachedAiForecastState(cacheKey, cachedNextState);
return {
...current,
status: "loading",
streamText:
progress.stage === "calling_ai" && current.streamText
? current.streamText
: progressText,
} satisfies AiCityForecastState;
}
export function buildAiCityReadyForecastState({
cacheKey,
detail,
isEn,
payload,
report,
}: {
cacheKey: string;
detail: CityDetail | null;
isEn: boolean;
payload: AiCityForecastPayload;
report: string;
}) {
const usablePayload =
payload?.city_forecast
? payload
: buildAiCityFallbackPayload({
detail,
error: payload?.reason || payload?.raw_reason || payload?.status,
isEn,
report,
});
if (usablePayload.status === "ready" && !usablePayload.degraded) {
writeCachedPayload(cacheKey, usablePayload);
}
const readyState: AiCityForecastState = {
payload: usablePayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
return readyState;
}
export function buildAiCityErrorForecastState({
cacheKey,
detail,
error,
isEn,
report,
}: {
cacheKey: string;
detail: CityDetail | null;
error: unknown;
isEn: boolean;
report: string;
}) {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
const readyState: AiCityForecastState = {
payload: fallbackPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
return readyState;
}
@@ -0,0 +1,102 @@
import {
buildStorageKey,
readCachedPayload,
writeCachedPayload,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
export type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
export type CityMarketScanSnapshot =
| { action: "reset"; cacheKey?: string }
| { action: "success"; cacheKey: string; payload: MarketScan; shouldWriteCache?: boolean }
| { action: "fetch"; cacheKey: string };
export function buildCityMarketScanCacheKey({
detailCityName,
localDate,
}: {
detailCityName: string;
localDate?: string | null;
}) {
return buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
normalizeCityKey(detailCityName),
localDate || "",
"full",
]);
}
export function readCachedCityMarketScan(cacheKey: string) {
return readCachedPayload<MarketScan>(cacheKey, CITY_MARKET_SCAN_CACHE_TTL_MS);
}
export function writeCachedCityMarketScan(cacheKey: string, payload: MarketScan) {
writeCachedPayload(cacheKey, payload);
}
export function resolveCityMarketScanSnapshot({
detail,
detailCityName,
enabled,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled: boolean;
}): CityMarketScanSnapshot {
if (!detail) return { action: "reset" };
const cacheKey = buildCityMarketScanCacheKey({
detailCityName,
localDate: detail.local_date || "",
});
if (detail.market_scan) {
return {
action: "success",
cacheKey,
payload: detail.market_scan,
shouldWriteCache: true,
};
}
const cached = readCachedCityMarketScan(cacheKey);
if (cached) {
return {
action: "success",
cacheKey,
payload: cached,
};
}
return enabled ? { action: "fetch", cacheKey } : { action: "reset", cacheKey };
}
export function deriveCityMarketScanView({
detailMarketScan,
marketRemote,
}: {
detailMarketScan?: MarketScan | null;
marketRemote: RemoteData<MarketScan>;
}) {
const previousMarketScan =
marketRemote.status === "loading" || marketRemote.status === "error"
? marketRemote.previous ?? null
: null;
const marketScan =
marketRemote.status === "success"
? marketRemote.data
: previousMarketScan ?? detailMarketScan ?? null;
const marketStatus: CityMarketScanStatus =
marketRemote.status === "success"
? "ready"
: marketRemote.status === "loading"
? "loading"
: marketRemote.status === "error"
? marketScan
? "ready"
: "failed"
: "idle";
return { marketScan, marketStatus };
}
@@ -29,6 +29,11 @@ export type AiCityStreamProgress = {
raw_length?: number | null;
};
export const scanTerminalQueryPolicy = {
autoRefreshMs: 10 * 60_000,
manualForceRefreshCooldownMs: 2 * 60_000,
} as const;
type AiCityStreamEvent = {
data: Record<string, unknown>;
event: string;
@@ -100,6 +105,32 @@ export function toRemoteError<T>(
};
}
export function shouldSkipManualTerminalRefresh({
hasCurrentData,
lastForcedRefreshAt,
now = Date.now(),
}: {
hasCurrentData: boolean;
lastForcedRefreshAt: number;
now?: number;
}) {
return (
hasCurrentData &&
lastForcedRefreshAt > 0 &&
now - lastForcedRefreshAt < scanTerminalQueryPolicy.manualForceRefreshCooldownMs
);
}
export function shouldRunAutoTerminalRefresh({
documentHidden,
isLoading,
}: {
documentHidden: boolean;
isLoading: boolean;
}) {
return !documentHidden && !isLoading;
}
async function readJsonOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetchBackendApi(path, init);
if (response.ok) return response.json() as Promise<T>;
@@ -1,457 +1,4 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import {
scanTerminalClient,
type AiCityStreamProgress,
type RemoteData,
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
buildStorageKey,
readCachedPayload,
removeCachedPayload,
writeCachedPayload,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
detail?.current?.settlement_source ||
detail?.settlement_station?.settlement_source ||
"",
)
.trim()
.toLowerCase();
return source === "hko";
}
function readCachedAiForecastState(key: string, ttlMs: number) {
const cached = aiCityForecastStateCache.get(key);
if (!cached) return null;
if (Date.now() - cached.updatedAt > ttlMs) {
aiCityForecastStateCache.delete(key);
return null;
}
return cached.state;
}
function writeCachedAiForecastState(key: string, state: AiCityForecastState) {
if (!key || state.status === "idle") return;
aiCityForecastStateCache.set(key, {
state,
updatedAt: Date.now(),
});
}
function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boolean) {
const localizedMessage = String(
(isEn ? progress.message_en : progress.message_zh) ||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
"",
).trim();
if (localizedMessage) return localizedMessage;
const rawLength = Number(progress.raw_length);
if (Number.isFinite(rawLength) && rawLength > 0) {
return isEn
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
}
return "";
}
function buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
}: {
detail: CityDetail | null;
error?: unknown;
isEn: boolean;
report: string;
}): AiCityForecastPayload {
const tempSymbol = detail?.temp_symbol || "°C";
const isHkoObservation = isHkoObservationCity(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail?.airport_primary?.temp ??
detail?.current?.temp) ?? null;
const currentText =
currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
: isEn
? "the latest observed temperature"
: "最新实测温度";
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
const rawMetar = isHkoObservation
? ""
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
const finalZh = timeoutLike
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
const finalEn = timeoutLike
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
const metarZh = rawMetar
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
const metarEn = rawMetar
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
return {
city_forecast: {
confidence: "low",
final_judgment_en: finalEn,
final_judgment_zh: finalZh,
metar_read_en: metarEn,
metar_read_zh: metarZh,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: reasonEn,
reasoning_zh: reasonZh,
risks_en: [],
risks_zh: [],
unit: tempSymbol,
},
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
reason: isEn ? reasonEn : reasonZh,
reason_en: reasonEn,
reason_zh: reasonZh,
status: timeoutLike ? "timeout_fallback" : "fallback",
};
}
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
isEn: boolean;
locale: string;
report: string;
}) {
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const aiForecastKey = useMemo(
() => {
if (!detail) return "";
const isHkoObservation = isHkoObservationCity(detail);
const observationSource = isHkoObservation ? "hko" : "metar";
const observationCurrent = isHkoObservation
? detail.current || {}
: detail.airport_current || detail.current || {};
const observationSignature =
(!isHkoObservation ? String(report || "").trim() : "") ||
[
observationSource,
observationCurrent.report_time,
observationCurrent.obs_time_epoch,
observationCurrent.obs_time,
observationCurrent.receipt_time,
observationCurrent.temp,
observationCurrent.max_so_far,
observationCurrent.station_code,
detail.metar_status?.stale_for_today,
detail.metar_status?.last_observation_time,
]
.filter((part) => part != null && part !== "")
.join("|");
return [
normalizeCityKey(detailCityName),
detail.local_date || "",
locale,
observationSignature,
].join(":");
},
[detail, detailCityName, locale, report],
);
useEffect(() => {
if (!enabled || !aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
const requestKey = `${cacheKey}:${aiRefreshToken > 0 ? `refresh:${aiRefreshToken}` : "normal"}`;
const cachedPayload =
aiRefreshToken <= 0
? readCachedPayload<AiCityForecastPayload>(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
)
: null;
if (cachedPayload) {
if (
cachedPayload.status === "ready" &&
!cachedPayload.degraded &&
cachedPayload.city_forecast
) {
const readyState: AiCityForecastState = {
payload: cachedPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
setAiForecast(readyState);
return () => {
cancelled = true;
};
}
removeCachedPayload(cacheKey);
}
const cachedState =
aiRefreshToken <= 0
? readCachedAiForecastState(cacheKey, AI_CITY_FORECAST_CACHE_TTL_MS)
: null;
if (cachedState?.status === "ready") {
setAiForecast(cachedState);
return () => {
cancelled = true;
};
}
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
const loadingState: AiCityForecastState =
cachedState?.status === "loading"
? cachedState
: {
status: "loading",
streamText:
(isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh) ||
(isEn
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
};
writeCachedAiForecastState(cacheKey, loadingState);
setAiForecast(loadingState);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
locale,
onProgress: (progress) => {
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return;
const cachedProgressState = readCachedAiForecastState(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
);
const nextStreamText =
progress.stage === "calling_ai" && cachedProgressState?.streamText
? cachedProgressState.streamText
: progressText;
writeCachedAiForecastState(cacheKey, {
...cachedProgressState,
status: "loading",
streamText: nextStreamText,
});
if (cancelled) return;
setAiForecast((current) => ({
...current,
status: "loading",
streamText:
progress.stage === "calling_ai" && current.streamText
? current.streamText
: progressText,
}));
},
requestKey,
})
.then((payload) => {
if (!payload) return;
const usablePayload =
payload?.city_forecast
? payload
: buildAiCityFallbackPayload({
detail,
error: payload?.reason || payload?.raw_reason || payload?.status,
isEn,
report,
});
if (usablePayload.status === "ready" && !usablePayload.degraded) {
writeCachedPayload(cacheKey, usablePayload);
}
writeCachedAiForecastState(cacheKey, {
payload: usablePayload,
status: "ready",
});
if (!cancelled) {
setAiForecast({ payload: usablePayload, status: "ready" });
}
})
.catch((error) => {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
writeCachedAiForecastState(cacheKey, {
payload: fallbackPayload,
status: "ready",
});
if (!cancelled) {
setAiForecast({ payload: fallbackPayload, status: "ready" });
}
});
return () => {
cancelled = true;
};
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
}, []);
return { aiForecast, refreshAiForecast };
}
export function useCityMarketScan({
detail,
detailCityName,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
}) {
const [marketRemote, setMarketRemote] = useState<RemoteData<MarketScan>>(
detail?.market_scan ? toRemoteSuccess(detail.market_scan) : { status: "idle" },
);
useEffect(() => {
if (!detail) {
setMarketRemote({ status: "idle" });
return;
}
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
normalizeCityKey(detailCityName),
detail.local_date || "",
"full",
]);
let cancelled = false;
if (detail.market_scan) {
setMarketRemote(toRemoteSuccess(detail.market_scan));
writeCachedPayload(cacheKey, detail.market_scan);
return () => {
cancelled = true;
};
}
if (!enabled) {
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketRemote(toRemoteSuccess(cached));
} else {
setMarketRemote({ status: "idle" });
}
return () => {
cancelled = true;
};
}
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketRemote(toRemoteSuccess(cached));
return () => {
cancelled = true;
};
} else {
setMarketRemote((current) => toRemoteLoading(current));
}
const controller = new AbortController();
void scanTerminalClient.getMarketScan(detailCityName, {
lite: false,
signal: controller.signal,
targetDate: detail.local_date || null,
})
.then((payload) => {
if (cancelled) return;
if (payload) {
writeCachedPayload(cacheKey, payload);
}
const nextPayload = payload || detail.market_scan || null;
if (nextPayload) {
setMarketRemote(toRemoteSuccess(nextPayload));
} else {
setMarketRemote({ status: "idle" });
}
})
.catch((error) => {
if (cancelled) return;
if (detail.market_scan) {
setMarketRemote(toRemoteSuccess(detail.market_scan));
} else {
setMarketRemote((current) => toRemoteError(error, current));
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [detail, detailCityName, enabled]);
const previousMarketScan =
marketRemote.status === "loading" || marketRemote.status === "error"
? marketRemote.previous ?? null
: null;
const marketScan =
marketRemote.status === "success"
? marketRemote.data
: previousMarketScan ?? detail?.market_scan ?? null;
const marketStatus: CityMarketScanStatus =
marketRemote.status === "success"
? "ready"
: marketRemote.status === "loading"
? "loading"
: marketRemote.status === "error"
? marketScan
? "ready"
: "failed"
: "idle";
return { marketRemote, marketScan, marketStatus };
}
export { useAiCityForecast } from "./use-ai-city-forecast";
export { useCityMarketScan } from "./use-city-market-scan";
@@ -0,0 +1,128 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
import {
buildAiCityErrorForecastState,
buildAiCityForecastCacheKey,
buildAiCityForecastKey,
buildAiCityForecastRequestKey,
buildAiCityLoadingForecastState,
buildAiCityProgressForecastState,
buildAiCityReadyForecastState,
readReadyCachedAiForecastState,
} from "./ai-city-forecast-stream-state";
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
isEn: boolean;
locale: string;
report: string;
}) {
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const aiForecastKey = useMemo(
() => buildAiCityForecastKey({ detail, detailCityName, locale, report }),
[detail, detailCityName, locale, report],
);
useEffect(() => {
if (!enabled || !aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
const readyCachedState = readReadyCachedAiForecastState(
cacheKey,
aiRefreshToken,
);
if (readyCachedState) {
setAiForecast(readyCachedState);
return () => {
cancelled = true;
};
}
const loadingState = buildAiCityLoadingForecastState({
cacheKey,
detail,
isEn,
report,
});
setAiForecast(loadingState);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
locale,
onProgress: (progress) => {
if (cancelled) return;
setAiForecast((current) =>
buildAiCityProgressForecastState({
cacheKey,
current,
isEn,
progress,
}) ?? current,
);
},
requestKey,
})
.then((payload) => {
if (!payload) return;
const readyState = buildAiCityReadyForecastState({
cacheKey,
detail,
isEn,
payload,
report,
});
if (!cancelled) {
setAiForecast(readyState);
}
})
.catch((error) => {
const errorState = buildAiCityErrorForecastState({
cacheKey,
detail,
error,
isEn,
report,
});
if (!cancelled) {
setAiForecast(errorState);
}
});
return () => {
cancelled = true;
};
}, [
aiForecastKey,
aiRefreshToken,
detail,
detailCityName,
enabled,
isEn,
locale,
report,
]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
}, []);
return { aiForecast, refreshAiForecast };
}
@@ -0,0 +1,75 @@
"use client";
import { useEffect } from "react";
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import {
deriveCityMarketScanView,
resolveCityMarketScanSnapshot,
writeCachedCityMarketScan,
} from "./market-scan-state";
export function useCityMarketScan({
detail,
detailCityName,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
}) {
const {
remote: marketRemote,
reset: resetMarketRemote,
run: runMarketScanQuery,
setSuccess: setMarketScanSuccess,
} = useRemoteDataQuery<MarketScan>();
useEffect(() => {
const snapshot = resolveCityMarketScanSnapshot({
detail,
detailCityName,
enabled,
});
if (snapshot.action === "reset") {
resetMarketRemote();
return;
}
if (snapshot.action === "success") {
setMarketScanSuccess(snapshot.payload);
if (snapshot.shouldWriteCache) {
writeCachedCityMarketScan(snapshot.cacheKey, snapshot.payload);
}
return;
}
void runMarketScanQuery({
request: (signal) =>
scanTerminalClient.getMarketScan(detailCityName, {
lite: false,
signal,
targetDate: detail?.local_date || null,
}),
showLoading: true,
onSuccess: (payload) => {
if (payload) {
writeCachedCityMarketScan(snapshot.cacheKey, payload);
}
},
});
}, [
detail,
detailCityName,
enabled,
resetMarketRemote,
runMarketScanQuery,
setMarketScanSuccess,
]);
const { marketScan, marketStatus } = deriveCityMarketScanView({
detailMarketScan: detail?.market_scan,
marketRemote,
});
return { marketRemote, marketScan, marketStatus };
}
@@ -0,0 +1,114 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
type RemoteData,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
type RunRemoteQueryOptions<T> = {
onSuccess?: (data: T) => void;
request: (signal: AbortSignal) => Promise<T>;
showLoading?: boolean;
};
export function useRemoteDataQuery<T>() {
const [data, setData] = useState<T | null>(null);
const [remote, setRemote] = useState<RemoteData<T>>({ status: "idle" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const requestSeqRef = useRef(0);
const loadingRef = useRef(false);
const abort = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
const reset = useCallback(() => {
requestSeqRef.current += 1;
abort();
loadingRef.current = false;
setLoading(false);
setError(null);
setData(null);
setRemote({ status: "idle" });
}, [abort]);
const setSuccess = useCallback(
(nextData: T) => {
requestSeqRef.current += 1;
abort();
loadingRef.current = false;
setLoading(false);
setError(null);
setData(nextData);
setRemote(toRemoteSuccess(nextData));
},
[abort],
);
const run = useCallback(
async ({
onSuccess,
request,
showLoading = false,
}: RunRemoteQueryOptions<T>) => {
const requestSeq = ++requestSeqRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
if (showLoading) {
loadingRef.current = true;
setLoading(true);
setRemote((current) => toRemoteLoading(current));
}
setError(null);
try {
const payload = await request(controller.signal);
if (requestSeq !== requestSeqRef.current) return null;
setData(payload);
setRemote(toRemoteSuccess(payload));
setError(null);
onSuccess?.(payload);
return payload;
} catch (caught) {
if (controller.signal.aborted || requestSeq !== requestSeqRef.current) {
return null;
}
const message = caught instanceof Error ? caught.message : String(caught);
setError(message);
setRemote((current) => toRemoteError(caught, current));
return null;
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
}
if (showLoading) {
loadingRef.current = false;
setLoading(false);
}
}
},
[],
);
const isLoading = useCallback(() => loadingRef.current, []);
useEffect(() => abort, [abort]);
return {
abort,
data,
error,
isLoading,
loading,
remote,
reset,
run,
setSuccess,
};
}
@@ -1,18 +1,15 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import {
scanTerminalQueryPolicy,
scanTerminalClient,
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
type RemoteData,
shouldRunAutoTerminalRefresh,
shouldSkipManualTerminalRefresh,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
export function useScanTerminalQuery({
isPro,
proAccessLoading,
@@ -20,15 +17,15 @@ export function useScanTerminalQuery({
isPro: boolean;
proAccessLoading: boolean;
}) {
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
const [scanRemote, setScanRemote] = useState<RemoteData<ScanTerminalResponse>>({
status: "idle",
});
const [scanLoading, setScanLoading] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const scanAbortRef = useRef<AbortController | null>(null);
const scanRequestSeqRef = useRef(0);
const scanLoadingRef = useRef(false);
const {
data: terminalData,
error: scanError,
isLoading,
loading: scanLoading,
remote: scanRemote,
reset,
run,
} = useRemoteDataQuery<ScanTerminalResponse>();
const lastForcedScanRefreshAtRef = useRef(0);
const fetchScanTerminal = useCallback(
@@ -40,74 +37,37 @@ export function useScanTerminalQuery({
showLoading?: boolean;
} = {}) => {
if (proAccessLoading || !isPro) return;
const requestSeq = ++scanRequestSeqRef.current;
scanAbortRef.current?.abort();
const controller = new AbortController();
scanAbortRef.current = controller;
if (forceRefresh) {
lastForcedScanRefreshAtRef.current = Date.now();
}
if (showLoading) {
scanLoadingRef.current = true;
setScanLoading(true);
setScanRemote((current) => toRemoteLoading(current));
}
setScanError(null);
try {
const payload = await scanTerminalClient.getTerminal({
forceRefresh,
signal: controller.signal,
});
if (requestSeq !== scanRequestSeqRef.current) return;
setTerminalData(payload);
setScanRemote(toRemoteSuccess(payload));
setScanError(null);
} catch (error) {
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
const message = error instanceof Error ? error.message : String(error);
setScanError(message);
setScanRemote((current) => toRemoteError(error, current));
} finally {
if (scanAbortRef.current === controller) {
scanAbortRef.current = null;
}
if (showLoading) {
scanLoadingRef.current = false;
setScanLoading(false);
}
}
await run({
request: (signal) =>
scanTerminalClient.getTerminal({
forceRefresh,
signal,
}),
showLoading,
});
},
[isPro, proAccessLoading],
[isPro, proAccessLoading, run],
);
useEffect(() => {
if (proAccessLoading) return;
if (!isPro) {
scanLoadingRef.current = false;
setScanLoading(false);
setScanError(null);
setTerminalData(null);
setScanRemote({ status: "idle" });
reset();
return;
}
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
}, [fetchScanTerminal, isPro, proAccessLoading]);
useEffect(() => {
return () => {
scanAbortRef.current?.abort();
};
}, []);
}, [fetchScanTerminal, isPro, proAccessLoading, reset]);
const refreshScanTerminalManually = useCallback(() => {
const now = Date.now();
const lastForced = lastForcedScanRefreshAtRef.current;
const withinCooldown =
lastForced > 0 &&
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
terminalData;
if (withinCooldown) {
setScanError(null);
if (
shouldSkipManualTerminalRefresh({
hasCurrentData: Boolean(terminalData),
lastForcedRefreshAt: lastForcedScanRefreshAtRef.current,
})
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
@@ -116,12 +76,18 @@ export function useScanTerminalQuery({
useEffect(() => {
if (proAccessLoading || !isPro) return;
const intervalId = window.setInterval(() => {
if (document.hidden) return;
if (scanLoadingRef.current) return;
if (
!shouldRunAutoTerminalRefresh({
documentHidden: document.hidden,
isLoading: isLoading(),
})
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
}, scanTerminalQueryPolicy.autoRefreshMs);
return () => window.clearInterval(intervalId);
}, [fetchScanTerminal, isPro, proAccessLoading]);
}, [fetchScanTerminal, isLoading, isPro, proAccessLoading]);
return {
refreshScanTerminalManually,
+458
View File
@@ -0,0 +1,458 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from web.scan_city_ai_helpers import (
CITY_AI_REQUIRED_FIELDS,
_CITY_AI_TEXT_FIELDS,
_extract_city_ai_partial_fields,
_provider_response_meta,
_safe_float,
_strip_incomplete_ai_sentence,
_truncate_ai_text,
)
def _format_ai_temperature(value: Any, unit: str) -> Optional[str]:
numeric = _safe_float(value)
if numeric is None:
return None
return f"{numeric:.1f}{unit or ''}"
def _city_ai_model_cluster_note(ai_input: Dict[str, Any], *, locale: str) -> str:
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_hko_observation = observation_anchor.get("is_airport_metar") is False
observation_label = (
"HKO observations"
if locale == "en-US" and is_hko_observation
else "香港天文台观测"
if is_hko_observation
else "METAR"
)
cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {}
sources = cluster.get("sources") if isinstance(cluster.get("sources"), list) else []
unit = str(ai_input.get("temp_symbol") or "")
values = [
_safe_float(item.get("value"))
for item in sources
if isinstance(item, dict) and _safe_float(item.get("value")) is not None
]
count = len(values)
deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None)
if locale == "en-US":
if count <= 0:
return f"No usable model cluster was returned; rely on DEB and {observation_label} only."
if count <= 2:
return f"Only {count} model source(s) are available, so model support is thin and should be treated as context."
range_text = f"{min(values):.1f}{unit} to {max(values):.1f}{unit}"
if deb_value is None:
return f"{count} model sources cluster between {range_text}; DEB support cannot be cross-checked."
supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0)
return f"{supporting}/{count} model sources sit within 2{unit} of DEB; model range is {range_text}."
if count <= 0:
return f"没有可用的多模型集合,只能把 DEB 与{observation_label}作为主要依据。"
if count <= 2:
return f"当前只有 {count} 个模型来源,模型支撑偏薄,只能作为辅助上下文。"
range_text = f"{min(values):.1f}{unit} ~ {max(values):.1f}{unit}"
if deb_value is None:
return f"{count} 个模型集中在 {range_text},但无法与 DEB 做一致性校验。"
supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0)
return f"{supporting}/{count} 个模型落在 DEB ±2{unit} 内;模型区间为 {range_text}"
def _build_city_ai_fallback(
ai_input: Dict[str, Any],
*,
locale: str,
reason: str,
raw_content: str = "",
provider_data: Any = None,
) -> Dict[str, Any]:
unit = str(ai_input.get("temp_symbol") or "")
cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {}
values = [
_safe_float(item.get("value"))
for item in (cluster.get("sources") if isinstance(cluster.get("sources"), list) else [])
if isinstance(item, dict) and _safe_float(item.get("value")) is not None
]
deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None)
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {}
current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {}
metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {}
observation_stale = bool(
metar_context.get("stale_for_today")
or airport_current.get("stale_for_today")
or current_obs.get("stale_for_today")
)
current_temp = _safe_float(
airport_current.get("temp") if is_airport_metar else current_obs.get("temp")
)
current_max_so_far = _safe_float(
ai_input.get("current_max_so_far")
or current_obs.get("max_so_far")
or airport_current.get("max_so_far")
)
observed_high_so_far = max(
[value for value in (current_temp, current_max_so_far) if value is not None],
default=None,
)
observed_high_for_revision = None if observation_stale else observed_high_so_far
predicted = deb_value
if predicted is None and values:
predicted = sum(values) / len(values)
if predicted is None:
predicted = observed_high_so_far
range_low = min(values) if values else predicted
range_high = max(values) if values else predicted
peak = ai_input.get("peak") if isinstance(ai_input.get("peak"), dict) else {}
window_phase = str(
ai_input.get("window_phase")
or peak.get("window_phase")
or peak.get("phase")
or ""
).strip().lower()
remaining_window_minutes = _safe_float(
ai_input.get("remaining_window_minutes")
if ai_input.get("remaining_window_minutes") is not None
else peak.get("remaining_window_minutes")
if peak.get("remaining_window_minutes") is not None
else peak.get("remaining_minutes")
)
minutes_until_peak_start = _safe_float(
ai_input.get("minutes_until_peak_start")
if ai_input.get("minutes_until_peak_start") is not None
else peak.get("minutes_until_peak_start")
)
minutes_until_peak_end = _safe_float(
ai_input.get("minutes_until_peak_end")
if ai_input.get("minutes_until_peak_end") is not None
else peak.get("minutes_until_peak_end")
)
peak_window_label = str(
ai_input.get("peak_window_label")
or peak.get("peak_window_label")
or peak.get("label")
or ""
).strip()
peak_has_passed = (
window_phase in {"post_peak", "past"}
or (minutes_until_peak_end is not None and minutes_until_peak_end < 0)
)
peak_is_closing = (
window_phase == "active_peak"
and remaining_window_minutes is not None
and remaining_window_minutes <= 90
)
peak_not_started = (
window_phase in {"early_today", "setup_today", "tomorrow", "week_ahead"}
or (minutes_until_peak_start is not None and minutes_until_peak_start > 0)
)
model_range_high = range_high
model_range_low = range_low
current_above_predicted = (
observed_high_for_revision is not None
and predicted is not None
and observed_high_for_revision > predicted + 0.2
)
current_above_model_range = (
observed_high_for_revision is not None
and model_range_high is not None
and observed_high_for_revision > model_range_high + 0.2
)
observed_high_break = bool(current_above_predicted or current_above_model_range)
current_below_predicted = (
observed_high_for_revision is not None
and predicted is not None
and observed_high_for_revision < predicted - 1.5
)
current_below_model_range = (
observed_high_for_revision is not None
and model_range_low is not None
and observed_high_for_revision < model_range_low - 0.2
)
observed_low_break = bool(current_below_predicted and (peak_has_passed or peak_is_closing))
observed_low_lag = bool(current_below_predicted and not observed_low_break)
original_predicted = predicted
if observed_high_break:
predicted = max(
value
for value in (predicted, observed_high_for_revision)
if value is not None
)
if range_high is not None and observed_high_for_revision is not None:
range_high = max(range_high, observed_high_for_revision)
elif observed_low_break:
predicted = min(
value
for value in (predicted, observed_high_for_revision)
if value is not None
)
if range_low is not None and observed_high_for_revision is not None:
range_low = min(range_low, observed_high_for_revision)
city = str(ai_input.get("city_display_name") or ai_input.get("city") or "this city")
station = str((airport_current.get("station_code") if is_airport_metar else None) or observation_anchor.get("station_code") or current_obs.get("station_code") or "")
raw_metar = str(airport_current.get("raw_metar") or "").strip() if is_airport_metar else ""
metar_temp = _format_ai_temperature((airport_current.get("temp") if is_airport_metar else current_obs.get("temp")), unit)
obs_time = str((airport_current.get("report_time") or airport_current.get("obs_time")) if is_airport_metar else (current_obs.get("obs_time") or current_obs.get("report_time") or "")).strip()
source_name_zh = "METAR" if is_airport_metar else "香港天文台观测"
source_name_en = "METAR" if is_airport_metar else "Hong Kong Observatory observation"
bulletin_zh = "机场报文" if is_airport_metar else "官方观测"
bulletin_en = "airport-bulletin" if is_airport_metar else "official-station observation"
model_note_zh = _city_ai_model_cluster_note(ai_input, locale="zh-CN")
model_note_en = _city_ai_model_cluster_note(ai_input, locale="en-US")
content_preview = _truncate_ai_text(raw_content, 1000)
partial_ai = _extract_city_ai_partial_fields(raw_content)
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
reason_preview = _truncate_ai_text(reason, 260)
reason_lower = str(reason or "").lower()
timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "")
if partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en"):
metar_zh = str(partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en") or "").strip()
metar_en = str(partial_ai.get("metar_read_en") or partial_ai.get("metar_read_zh") or "").strip()
elif content_preview and not looks_like_truncated_json:
metar_zh = f"{bulletin_zh}快速解读已先完成;AI 补充摘要:{content_preview}"
metar_en = f"The fast {bulletin_en} read is available; AI supplemental summary: {content_preview}"
elif content_preview:
metar_zh = f"{bulletin_zh}快速解读已先完成;本轮 AI 增强未完整返回,当前以 DEB、多模型与{source_name_zh}为准。"
metar_en = f"The fast {bulletin_en} read is available; this AI enhancement was incomplete, so DEB, model cluster and {source_name_en} carry the read."
elif raw_metar and observation_stale:
metar_zh = f"{station} 可用 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};但该观测已标记为过旧,当前只能作为背景参考,不能作为强实况锚点。"
metar_en = f"{station} available METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}, but the observation is flagged as stale, so treat it as background context rather than a strong live anchor."
elif raw_metar:
metar_zh = f"{station} 最新 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};当前先把它作为实况锚点,并结合后续报文确认温度路径。"
metar_en = f"{station} latest METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}; use it as the live anchor while later reports confirm the path."
else:
metar_zh = f"当前没有可用的{source_name_zh}正文,暂以 DEB、多模型路径与最新实测为主。"
metar_en = f"No raw {source_name_en} text is available, so DEB, latest observations and the model cluster carry the read."
predicted_text = _format_ai_temperature(predicted, unit) or "--"
current_text = _format_ai_temperature(observed_high_so_far, unit) or "--"
original_predicted_text = _format_ai_temperature(original_predicted, unit) or "--"
model_range_high_text = _format_ai_temperature(model_range_high, unit) or "--"
model_range_low_text = _format_ai_temperature(model_range_low, unit) or "--"
peak_label_text_zh = f"{peak_window_label}" if peak_window_label else ""
peak_label_text_en = f" ({peak_window_label})" if peak_window_label else ""
if partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en"):
final_zh = str(partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en") or "").strip()
final_en = str(partial_ai.get("final_judgment_en") or partial_ai.get("final_judgment_zh") or "").strip()
elif partial_ai:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 已先完成{bulletin_zh}解读,最高温结论结合 DEB、多模型与最新实测校准。"
final_en = f"{city} daily high is centered near {predicted_text}; AI has already read the {bulletin_en}, with the high calibrated against DEB, the model cluster and latest observations."
elif observed_high_break:
final_zh = f"{city} 最新实测已达 {current_text},高于原先 {original_predicted_text} 中枢;最高温中枢需先上修到至少 {predicted_text} 附近。"
final_en = f"{city} latest observation has reached {current_text}, above the prior {original_predicted_text} center; the daily-high center should be revised up to at least near {predicted_text}."
elif observed_low_break:
final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过或接近结束,实测最高仍约 {current_text},低于原先 {original_predicted_text} 中枢;最高温中枢需先下修到 {predicted_text} 附近。"
final_en = f"{city} peak window{peak_label_text_en} has passed or is nearly over, and observed high is still near {current_text}, below the prior {original_predicted_text} center; revise the daily-high center down toward {predicted_text}."
elif peak_has_passed:
final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过;最高温暂以 {predicted_text} 附近为中枢,并以已观测到的高点为主要校准。"
final_en = f"{city} peak window{peak_label_text_en} has passed; the daily high stays centered near {predicted_text}, calibrated mainly against the observed high so far."
elif observation_stale:
final_zh = f"{city} 最高温暂以 {predicted_text} 附近为中枢;当前可用{source_name_zh}已过旧,先以 DEB 和多模型路径为主。"
final_en = f"{city} daily high is centered near {predicted_text}; the available {source_name_en} is stale, so DEB and the model path carry the read for now."
elif timed_out:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。"
final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode."
else:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。"
final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode."
if partial_ai:
fallback_reasoning_zh = f"AI {bulletin_zh}解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续{source_name_zh}用于确认是否需要上调或下修。"
fallback_reasoning_en = f"The AI {bulletin_en} read is already used to calibrate the intraday pace; DEB and the model cluster still constrain the high-temperature center, while later {source_name_en} updates confirm whether to revise it."
elif observed_high_break:
fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}已高于原先 {original_predicted_text} 中枢{(',并超过模型上沿 ' + model_range_high_text) if current_above_model_range else ''},本轮最高温判断应优先承认实测突破并等待完整 AI {bulletin_zh}解读合并。"
fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} is above the prior {original_predicted_text} center{(' and above the model upper edge ' + model_range_high_text) if current_above_model_range else ''}, so the high-temperature read should first acknowledge the observed break and merge the full AI {bulletin_en} read when available."
elif observed_low_break:
fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过或接近结束,最新实测高点仍低于原先 {original_predicted_text} 中枢{(',并低于模型下沿 ' + model_range_low_text) if current_below_model_range else ''},本轮最高温判断应优先承认下修压力并等待完整 AI {bulletin_zh}解读合并。"
fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed or is nearly over, and the observed high remains below the prior {original_predicted_text} center{(' and below the model lower edge ' + model_range_low_text) if current_below_model_range else ''}, so the high-temperature read should first acknowledge downward revision pressure and merge the full AI {bulletin_en} read when available."
elif observed_low_lag and peak_not_started:
fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}仍低于原先 {original_predicted_text} 中枢,但峰值窗口尚未到来,暂不直接下修,只把后续升温是否追上模型路径作为关键确认。"
fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} remains below the prior {original_predicted_text} center, but the peak window has not arrived, so do not revise down yet and use later warming as the key confirmation."
elif peak_has_passed:
fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过,后续{source_name_zh}主要用于确认是否已形成日内高点,而不是继续按待升温路径解读。"
fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed, so later {source_name_en} updates mainly confirm whether the daily high is already set rather than assuming further warming."
elif observation_stale:
fallback_reasoning_zh = f"当前为快速证据模式;可用{source_name_zh}已过旧,不能作为强实况锚点,暂由 DEB 和多模型集合支撑本轮最高温中枢,等待新的{source_name_zh}确认。"
fallback_reasoning_en = f"This is the fast evidence mode; the available {source_name_en} is stale and should not be used as a strong live anchor, so DEB and the model cluster carry the current daily-high center until a newer {source_name_en} confirms it."
else:
fallback_reasoning_zh = f"当前为快速证据模式;DEB、多模型集合和最新{source_name_zh}共同支撑本轮最高温中枢,完整 AI {bulletin_zh}解读返回后再合并。"
fallback_reasoning_en = f"This is the fast evidence mode; DEB, the model cluster and latest {source_name_en} jointly support the current daily-high center, and the full AI {bulletin_en} read will be merged when available."
reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or fallback_reasoning_zh
reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or fallback_reasoning_en
if observed_high_break:
risks_zh = [f"最新{source_name_zh}已突破原模型路径,若后续报文继续持平或升温,需要继续上修最高温中枢。"]
risks_en = [f"Latest {source_name_en} has already broken above the prior model path; if later reports hold steady or warm further, keep revising the daily-high center upward."]
elif observed_low_break:
risks_zh = [f"峰值窗口已过或接近结束且实测仍偏低,若后续{source_name_zh}没有反弹,需要继续下修最高温中枢。"]
risks_en = [f"The peak window has passed or is nearly over while observations remain low; if later {source_name_en} does not rebound, keep revising the daily-high center lower."]
elif observed_low_lag:
risks_zh = [f"最新{source_name_zh}仍未追上原模型路径;若峰值窗口前继续偏低,需要下修最高温中枢。"]
risks_en = [f"Latest {source_name_en} has not caught up with the prior model path; if it stays low before the peak window, revise the daily-high center lower."]
elif peak_has_passed:
risks_zh = [f"峰值窗口已过,后续{source_name_zh}若未再创新高,应避免继续上调最高温中枢。"]
risks_en = [f"The peak window has passed; avoid raising the daily-high center unless later {source_name_en} sets a new high."]
elif observation_stale:
risks_zh = [f"当前{source_name_zh}过旧;新报文若明显偏离 DEB 和模型路径,需要重新校准最高温中枢。"]
risks_en = [f"The current {source_name_en} is stale; if a newer report diverges from DEB and the model path, recalibrate the daily-high center."]
else:
risks_zh = [f"后续{source_name_zh}若明显偏离模型路径,需及时修正最高温中枢。"]
risks_en = [f"If later {source_name_en} updates diverge from the model path, revise the daily-high center promptly."]
evidence_guard = {
"observation_stale": observation_stale,
"observed_high_break": observed_high_break,
"observed_low_break": observed_low_break,
"observed_low_lag": observed_low_lag,
"peak_has_passed": peak_has_passed,
"peak_is_closing": peak_is_closing,
"peak_not_started": peak_not_started,
"observed_high_so_far": observed_high_so_far,
"observed_high_for_revision": observed_high_for_revision,
"original_predicted": original_predicted,
"model_range_low": model_range_low,
"model_range_high": model_range_high,
"predicted_max": predicted,
"range_low": range_low,
"range_high": range_high,
}
return {
"predicted_max": partial_ai.get("predicted_max", predicted),
"range_low": partial_ai.get("range_low", range_low),
"range_high": partial_ai.get("range_high", range_high),
"unit": partial_ai.get("unit") or unit,
"confidence": partial_ai.get("confidence") or ("medium" if partial_ai else "low"),
"final_judgment_zh": final_zh,
"final_judgment_en": final_en,
"metar_read_zh": metar_zh,
"metar_read_en": metar_en,
"reasoning_zh": reasoning_zh,
"reasoning_en": reasoning_en,
"risks_zh": risks_zh,
"risks_en": risks_en,
"model_cluster_note_zh": partial_ai.get("model_cluster_note_zh") or model_note_zh,
"model_cluster_note_en": partial_ai.get("model_cluster_note_en") or model_note_en,
"_polyweather_meta": {
**_provider_response_meta(provider_data),
"fallback": True,
"fallback_kind": "partial_ai_json" if partial_ai else "timeout" if timed_out else "non_json",
"looks_like_truncated_json": looks_like_truncated_json,
"fallback_reason": reason_preview,
"raw_content_preview": content_preview,
"partial_ai_fields": sorted(partial_ai.keys()),
"raw_metar": _truncate_ai_text(raw_metar, 1000),
"evidence_guard": evidence_guard,
},
}
def _complete_city_ai_payload(
ai_raw: Dict[str, Any],
ai_input: Dict[str, Any],
*,
locale: str,
) -> Dict[str, Any]:
"""Fill missing structured fields without marking a provider success as fallback."""
if not isinstance(ai_raw, dict):
return _build_city_ai_fallback(
ai_input,
locale=locale,
reason="provider output was not a JSON object",
)
fallback = _build_city_ai_fallback(
ai_input,
locale=locale,
reason="schema completion",
)
completed: List[str] = []
out = dict(ai_raw)
trimmed: List[str] = []
for field in CITY_AI_REQUIRED_FIELDS:
value = out.get(field)
if isinstance(value, str) and field in _CITY_AI_TEXT_FIELDS:
clean_value = _strip_incomplete_ai_sentence(value)
if clean_value != value:
trimmed.append(field)
value = clean_value
out[field] = clean_value
if value is None or value == "" or value == []:
out[field] = fallback.get(field)
completed.append(field)
for field in ("risks_zh", "risks_en"):
value = out.get(field)
if isinstance(value, list):
cleaned_risks = []
for item in value:
clean_item = _strip_incomplete_ai_sentence(item)
if clean_item:
cleaned_risks.append(clean_item)
if cleaned_risks != value:
trimmed.append(field)
out[field] = cleaned_risks or fallback.get(field)
meta = out.get("_polyweather_meta")
if not isinstance(meta, dict):
meta = {}
if completed:
meta["schema_completed_fields"] = completed
if trimmed:
meta["trimmed_incomplete_fields"] = sorted(set(trimmed))
guard_meta = (fallback.get("_polyweather_meta") or {}).get("evidence_guard")
if isinstance(guard_meta, dict):
deterministic_fields: List[str] = []
numeric_guard_active = bool(
guard_meta.get("observed_high_break")
or guard_meta.get("observed_low_break")
or guard_meta.get("observation_stale")
)
text_guard_active = bool(
numeric_guard_active
or guard_meta.get("peak_has_passed")
or (guard_meta.get("observed_low_lag") and guard_meta.get("peak_not_started"))
)
if numeric_guard_active:
for field in ("predicted_max", "range_low", "range_high"):
guarded_value = fallback.get(field)
if guarded_value is not None and out.get(field) != guarded_value:
out[field] = guarded_value
deterministic_fields.append(field)
if guard_meta.get("observation_stale"):
guarded_text_fields = (
"metar_read_zh",
"metar_read_en",
"final_judgment_zh",
"final_judgment_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
)
elif text_guard_active:
guarded_text_fields = (
"final_judgment_zh",
"final_judgment_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
)
else:
guarded_text_fields = ()
for field in guarded_text_fields:
guarded_value = fallback.get(field)
if guarded_value not in (None, "", []) and out.get(field) != guarded_value:
out[field] = guarded_value
deterministic_fields.append(field)
if deterministic_fields:
meta["deterministic_guard_fields"] = sorted(set(deterministic_fields))
meta["deterministic_guard_reason"] = {
key: guard_meta.get(key)
for key in (
"observation_stale",
"observed_high_break",
"observed_low_break",
"observed_low_lag",
"peak_has_passed",
)
if guard_meta.get(key)
}
out["_polyweather_meta"] = meta
return out
-445
View File
@@ -257,337 +257,6 @@ def _provider_response_meta(data: Any) -> Dict[str, Any]:
}
def _format_ai_temperature(value: Any, unit: str) -> Optional[str]:
numeric = _safe_float(value)
if numeric is None:
return None
return f"{numeric:.1f}{unit or ''}"
def _city_ai_model_cluster_note(ai_input: Dict[str, Any], *, locale: str) -> str:
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_hko_observation = observation_anchor.get("is_airport_metar") is False
observation_label = (
"HKO observations"
if locale == "en-US" and is_hko_observation
else "香港天文台观测"
if is_hko_observation
else "METAR"
)
cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {}
sources = cluster.get("sources") if isinstance(cluster.get("sources"), list) else []
unit = str(ai_input.get("temp_symbol") or "")
values = [
_safe_float(item.get("value"))
for item in sources
if isinstance(item, dict) and _safe_float(item.get("value")) is not None
]
count = len(values)
deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None)
if locale == "en-US":
if count <= 0:
return f"No usable model cluster was returned; rely on DEB and {observation_label} only."
if count <= 2:
return f"Only {count} model source(s) are available, so model support is thin and should be treated as context."
range_text = f"{min(values):.1f}{unit} to {max(values):.1f}{unit}"
if deb_value is None:
return f"{count} model sources cluster between {range_text}; DEB support cannot be cross-checked."
supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0)
return f"{supporting}/{count} model sources sit within 2{unit} of DEB; model range is {range_text}."
if count <= 0:
return f"没有可用的多模型集合,只能把 DEB 与{observation_label}作为主要依据。"
if count <= 2:
return f"当前只有 {count} 个模型来源,模型支撑偏薄,只能作为辅助上下文。"
range_text = f"{min(values):.1f}{unit} ~ {max(values):.1f}{unit}"
if deb_value is None:
return f"{count} 个模型集中在 {range_text},但无法与 DEB 做一致性校验。"
supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0)
return f"{supporting}/{count} 个模型落在 DEB ±2{unit} 内;模型区间为 {range_text}"
def _build_city_ai_fallback(
ai_input: Dict[str, Any],
*,
locale: str,
reason: str,
raw_content: str = "",
provider_data: Any = None,
) -> Dict[str, Any]:
unit = str(ai_input.get("temp_symbol") or "")
cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {}
values = [
_safe_float(item.get("value"))
for item in (cluster.get("sources") if isinstance(cluster.get("sources"), list) else [])
if isinstance(item, dict) and _safe_float(item.get("value")) is not None
]
deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None)
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {}
current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {}
metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {}
observation_stale = bool(
metar_context.get("stale_for_today")
or airport_current.get("stale_for_today")
or current_obs.get("stale_for_today")
)
current_temp = _safe_float(
airport_current.get("temp") if is_airport_metar else current_obs.get("temp")
)
current_max_so_far = _safe_float(
ai_input.get("current_max_so_far")
or current_obs.get("max_so_far")
or airport_current.get("max_so_far")
)
observed_high_so_far = max(
[value for value in (current_temp, current_max_so_far) if value is not None],
default=None,
)
observed_high_for_revision = None if observation_stale else observed_high_so_far
predicted = deb_value
if predicted is None and values:
predicted = sum(values) / len(values)
if predicted is None:
predicted = observed_high_so_far
range_low = min(values) if values else predicted
range_high = max(values) if values else predicted
peak = ai_input.get("peak") if isinstance(ai_input.get("peak"), dict) else {}
window_phase = str(
ai_input.get("window_phase")
or peak.get("window_phase")
or peak.get("phase")
or ""
).strip().lower()
remaining_window_minutes = _safe_float(
ai_input.get("remaining_window_minutes")
if ai_input.get("remaining_window_minutes") is not None
else peak.get("remaining_window_minutes")
if peak.get("remaining_window_minutes") is not None
else peak.get("remaining_minutes")
)
minutes_until_peak_start = _safe_float(
ai_input.get("minutes_until_peak_start")
if ai_input.get("minutes_until_peak_start") is not None
else peak.get("minutes_until_peak_start")
)
minutes_until_peak_end = _safe_float(
ai_input.get("minutes_until_peak_end")
if ai_input.get("minutes_until_peak_end") is not None
else peak.get("minutes_until_peak_end")
)
peak_window_label = str(
ai_input.get("peak_window_label")
or peak.get("peak_window_label")
or peak.get("label")
or ""
).strip()
peak_has_passed = (
window_phase in {"post_peak", "past"}
or (minutes_until_peak_end is not None and minutes_until_peak_end < 0)
)
peak_is_closing = (
window_phase == "active_peak"
and remaining_window_minutes is not None
and remaining_window_minutes <= 90
)
peak_not_started = (
window_phase in {"early_today", "setup_today", "tomorrow", "week_ahead"}
or (minutes_until_peak_start is not None and minutes_until_peak_start > 0)
)
model_range_high = range_high
model_range_low = range_low
current_above_predicted = (
observed_high_for_revision is not None
and predicted is not None
and observed_high_for_revision > predicted + 0.2
)
current_above_model_range = (
observed_high_for_revision is not None
and model_range_high is not None
and observed_high_for_revision > model_range_high + 0.2
)
observed_high_break = bool(current_above_predicted or current_above_model_range)
current_below_predicted = (
observed_high_for_revision is not None
and predicted is not None
and observed_high_for_revision < predicted - 1.5
)
current_below_model_range = (
observed_high_for_revision is not None
and model_range_low is not None
and observed_high_for_revision < model_range_low - 0.2
)
observed_low_break = bool(current_below_predicted and (peak_has_passed or peak_is_closing))
observed_low_lag = bool(current_below_predicted and not observed_low_break)
original_predicted = predicted
if observed_high_break:
predicted = max(
value
for value in (predicted, observed_high_for_revision)
if value is not None
)
if range_high is not None and observed_high_for_revision is not None:
range_high = max(range_high, observed_high_for_revision)
elif observed_low_break:
predicted = min(
value
for value in (predicted, observed_high_for_revision)
if value is not None
)
if range_low is not None and observed_high_for_revision is not None:
range_low = min(range_low, observed_high_for_revision)
city = str(ai_input.get("city_display_name") or ai_input.get("city") or "this city")
station = str((airport_current.get("station_code") if is_airport_metar else None) or observation_anchor.get("station_code") or current_obs.get("station_code") or "")
raw_metar = str(airport_current.get("raw_metar") or "").strip() if is_airport_metar else ""
metar_temp = _format_ai_temperature((airport_current.get("temp") if is_airport_metar else current_obs.get("temp")), unit)
obs_time = str((airport_current.get("report_time") or airport_current.get("obs_time")) if is_airport_metar else (current_obs.get("obs_time") or current_obs.get("report_time") or "")).strip()
source_name_zh = "METAR" if is_airport_metar else "香港天文台观测"
source_name_en = "METAR" if is_airport_metar else "Hong Kong Observatory observation"
bulletin_zh = "机场报文" if is_airport_metar else "官方观测"
bulletin_en = "airport-bulletin" if is_airport_metar else "official-station observation"
model_note_zh = _city_ai_model_cluster_note(ai_input, locale="zh-CN")
model_note_en = _city_ai_model_cluster_note(ai_input, locale="en-US")
content_preview = _truncate_ai_text(raw_content, 1000)
partial_ai = _extract_city_ai_partial_fields(raw_content)
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
reason_preview = _truncate_ai_text(reason, 260)
reason_lower = str(reason or "").lower()
timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "")
if partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en"):
metar_zh = str(partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en") or "").strip()
metar_en = str(partial_ai.get("metar_read_en") or partial_ai.get("metar_read_zh") or "").strip()
elif content_preview and not looks_like_truncated_json:
metar_zh = f"{bulletin_zh}快速解读已先完成;AI 补充摘要:{content_preview}"
metar_en = f"The fast {bulletin_en} read is available; AI supplemental summary: {content_preview}"
elif content_preview:
metar_zh = f"{bulletin_zh}快速解读已先完成;本轮 AI 增强未完整返回,当前以 DEB、多模型与{source_name_zh}为准。"
metar_en = f"The fast {bulletin_en} read is available; this AI enhancement was incomplete, so DEB, model cluster and {source_name_en} carry the read."
elif raw_metar and observation_stale:
metar_zh = f"{station} 可用 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};但该观测已标记为过旧,当前只能作为背景参考,不能作为强实况锚点。"
metar_en = f"{station} available METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}, but the observation is flagged as stale, so treat it as background context rather than a strong live anchor."
elif raw_metar:
metar_zh = f"{station} 最新 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};当前先把它作为实况锚点,并结合后续报文确认温度路径。"
metar_en = f"{station} latest METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}; use it as the live anchor while later reports confirm the path."
else:
metar_zh = f"当前没有可用的{source_name_zh}正文,暂以 DEB、多模型路径与最新实测为主。"
metar_en = f"No raw {source_name_en} text is available, so DEB, latest observations and the model cluster carry the read."
predicted_text = _format_ai_temperature(predicted, unit) or "--"
current_text = _format_ai_temperature(observed_high_so_far, unit) or "--"
original_predicted_text = _format_ai_temperature(original_predicted, unit) or "--"
model_range_high_text = _format_ai_temperature(model_range_high, unit) or "--"
model_range_low_text = _format_ai_temperature(model_range_low, unit) or "--"
peak_label_text_zh = f"{peak_window_label}" if peak_window_label else ""
peak_label_text_en = f" ({peak_window_label})" if peak_window_label else ""
if partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en"):
final_zh = str(partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en") or "").strip()
final_en = str(partial_ai.get("final_judgment_en") or partial_ai.get("final_judgment_zh") or "").strip()
elif partial_ai:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 已先完成{bulletin_zh}解读,最高温结论结合 DEB、多模型与最新实测校准。"
final_en = f"{city} daily high is centered near {predicted_text}; AI has already read the {bulletin_en}, with the high calibrated against DEB, the model cluster and latest observations."
elif observed_high_break:
final_zh = f"{city} 最新实测已达 {current_text},高于原先 {original_predicted_text} 中枢;最高温中枢需先上修到至少 {predicted_text} 附近。"
final_en = f"{city} latest observation has reached {current_text}, above the prior {original_predicted_text} center; the daily-high center should be revised up to at least near {predicted_text}."
elif observed_low_break:
final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过或接近结束,实测最高仍约 {current_text},低于原先 {original_predicted_text} 中枢;最高温中枢需先下修到 {predicted_text} 附近。"
final_en = f"{city} peak window{peak_label_text_en} has passed or is nearly over, and observed high is still near {current_text}, below the prior {original_predicted_text} center; revise the daily-high center down toward {predicted_text}."
elif peak_has_passed:
final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过;最高温暂以 {predicted_text} 附近为中枢,并以已观测到的高点为主要校准。"
final_en = f"{city} peak window{peak_label_text_en} has passed; the daily high stays centered near {predicted_text}, calibrated mainly against the observed high so far."
elif observation_stale:
final_zh = f"{city} 最高温暂以 {predicted_text} 附近为中枢;当前可用{source_name_zh}已过旧,先以 DEB 和多模型路径为主。"
final_en = f"{city} daily high is centered near {predicted_text}; the available {source_name_en} is stale, so DEB and the model path carry the read for now."
elif timed_out:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。"
final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode."
else:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。"
final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode."
if partial_ai:
fallback_reasoning_zh = f"AI {bulletin_zh}解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续{source_name_zh}用于确认是否需要上调或下修。"
fallback_reasoning_en = f"The AI {bulletin_en} read is already used to calibrate the intraday pace; DEB and the model cluster still constrain the high-temperature center, while later {source_name_en} updates confirm whether to revise it."
elif observed_high_break:
fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}已高于原先 {original_predicted_text} 中枢{(',并超过模型上沿 ' + model_range_high_text) if current_above_model_range else ''},本轮最高温判断应优先承认实测突破并等待完整 AI {bulletin_zh}解读合并。"
fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} is above the prior {original_predicted_text} center{(' and above the model upper edge ' + model_range_high_text) if current_above_model_range else ''}, so the high-temperature read should first acknowledge the observed break and merge the full AI {bulletin_en} read when available."
elif observed_low_break:
fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过或接近结束,最新实测高点仍低于原先 {original_predicted_text} 中枢{(',并低于模型下沿 ' + model_range_low_text) if current_below_model_range else ''},本轮最高温判断应优先承认下修压力并等待完整 AI {bulletin_zh}解读合并。"
fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed or is nearly over, and the observed high remains below the prior {original_predicted_text} center{(' and below the model lower edge ' + model_range_low_text) if current_below_model_range else ''}, so the high-temperature read should first acknowledge downward revision pressure and merge the full AI {bulletin_en} read when available."
elif observed_low_lag and peak_not_started:
fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}仍低于原先 {original_predicted_text} 中枢,但峰值窗口尚未到来,暂不直接下修,只把后续升温是否追上模型路径作为关键确认。"
fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} remains below the prior {original_predicted_text} center, but the peak window has not arrived, so do not revise down yet and use later warming as the key confirmation."
elif peak_has_passed:
fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过,后续{source_name_zh}主要用于确认是否已形成日内高点,而不是继续按待升温路径解读。"
fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed, so later {source_name_en} updates mainly confirm whether the daily high is already set rather than assuming further warming."
elif observation_stale:
fallback_reasoning_zh = f"当前为快速证据模式;可用{source_name_zh}已过旧,不能作为强实况锚点,暂由 DEB 和多模型集合支撑本轮最高温中枢,等待新的{source_name_zh}确认。"
fallback_reasoning_en = f"This is the fast evidence mode; the available {source_name_en} is stale and should not be used as a strong live anchor, so DEB and the model cluster carry the current daily-high center until a newer {source_name_en} confirms it."
else:
fallback_reasoning_zh = f"当前为快速证据模式;DEB、多模型集合和最新{source_name_zh}共同支撑本轮最高温中枢,完整 AI {bulletin_zh}解读返回后再合并。"
fallback_reasoning_en = f"This is the fast evidence mode; DEB, the model cluster and latest {source_name_en} jointly support the current daily-high center, and the full AI {bulletin_en} read will be merged when available."
reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or fallback_reasoning_zh
reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or fallback_reasoning_en
if observed_high_break:
risks_zh = [f"最新{source_name_zh}已突破原模型路径,若后续报文继续持平或升温,需要继续上修最高温中枢。"]
risks_en = [f"Latest {source_name_en} has already broken above the prior model path; if later reports hold steady or warm further, keep revising the daily-high center upward."]
elif observed_low_break:
risks_zh = [f"峰值窗口已过或接近结束且实测仍偏低,若后续{source_name_zh}没有反弹,需要继续下修最高温中枢。"]
risks_en = [f"The peak window has passed or is nearly over while observations remain low; if later {source_name_en} does not rebound, keep revising the daily-high center lower."]
elif observed_low_lag:
risks_zh = [f"最新{source_name_zh}仍未追上原模型路径;若峰值窗口前继续偏低,需要下修最高温中枢。"]
risks_en = [f"Latest {source_name_en} has not caught up with the prior model path; if it stays low before the peak window, revise the daily-high center lower."]
elif peak_has_passed:
risks_zh = [f"峰值窗口已过,后续{source_name_zh}若未再创新高,应避免继续上调最高温中枢。"]
risks_en = [f"The peak window has passed; avoid raising the daily-high center unless later {source_name_en} sets a new high."]
elif observation_stale:
risks_zh = [f"当前{source_name_zh}过旧;新报文若明显偏离 DEB 和模型路径,需要重新校准最高温中枢。"]
risks_en = [f"The current {source_name_en} is stale; if a newer report diverges from DEB and the model path, recalibrate the daily-high center."]
else:
risks_zh = [f"后续{source_name_zh}若明显偏离模型路径,需及时修正最高温中枢。"]
risks_en = [f"If later {source_name_en} updates diverge from the model path, revise the daily-high center promptly."]
evidence_guard = {
"observation_stale": observation_stale,
"observed_high_break": observed_high_break,
"observed_low_break": observed_low_break,
"observed_low_lag": observed_low_lag,
"peak_has_passed": peak_has_passed,
"peak_is_closing": peak_is_closing,
"peak_not_started": peak_not_started,
"observed_high_so_far": observed_high_so_far,
"observed_high_for_revision": observed_high_for_revision,
"original_predicted": original_predicted,
"model_range_low": model_range_low,
"model_range_high": model_range_high,
"predicted_max": predicted,
"range_low": range_low,
"range_high": range_high,
}
return {
"predicted_max": partial_ai.get("predicted_max", predicted),
"range_low": partial_ai.get("range_low", range_low),
"range_high": partial_ai.get("range_high", range_high),
"unit": partial_ai.get("unit") or unit,
"confidence": partial_ai.get("confidence") or ("medium" if partial_ai else "low"),
"final_judgment_zh": final_zh,
"final_judgment_en": final_en,
"metar_read_zh": metar_zh,
"metar_read_en": metar_en,
"reasoning_zh": reasoning_zh,
"reasoning_en": reasoning_en,
"risks_zh": risks_zh,
"risks_en": risks_en,
"model_cluster_note_zh": partial_ai.get("model_cluster_note_zh") or model_note_zh,
"model_cluster_note_en": partial_ai.get("model_cluster_note_en") or model_note_en,
"_polyweather_meta": {
**_provider_response_meta(provider_data),
"fallback": True,
"fallback_kind": "partial_ai_json" if partial_ai else "timeout" if timed_out else "non_json",
"looks_like_truncated_json": looks_like_truncated_json,
"fallback_reason": reason_preview,
"raw_content_preview": content_preview,
"partial_ai_fields": sorted(partial_ai.keys()),
"raw_metar": _truncate_ai_text(raw_metar, 1000),
"evidence_guard": evidence_guard,
},
}
def _city_ai_response_example(unit: str) -> Dict[str, Any]:
@@ -617,117 +286,3 @@ def _city_ai_stream_response_example(unit: str) -> Dict[str, Any]:
"reasoning_zh": "当前观测仍贴近上午升温路径,若后续风向转为海风或云雨增强,再下修最高温中枢。",
"reasoning_en": "The latest observation still fits the morning warming path; revise the daily-high center lower if later wind turns onshore or cloud/rain strengthens.",
}
def _complete_city_ai_payload(
ai_raw: Dict[str, Any],
ai_input: Dict[str, Any],
*,
locale: str,
) -> Dict[str, Any]:
"""Fill missing structured fields without marking a provider success as fallback."""
if not isinstance(ai_raw, dict):
return _build_city_ai_fallback(
ai_input,
locale=locale,
reason="provider output was not a JSON object",
)
fallback = _build_city_ai_fallback(
ai_input,
locale=locale,
reason="schema completion",
)
completed: List[str] = []
out = dict(ai_raw)
trimmed: List[str] = []
for field in CITY_AI_REQUIRED_FIELDS:
value = out.get(field)
if isinstance(value, str) and field in _CITY_AI_TEXT_FIELDS:
clean_value = _strip_incomplete_ai_sentence(value)
if clean_value != value:
trimmed.append(field)
value = clean_value
out[field] = clean_value
if value is None or value == "" or value == []:
out[field] = fallback.get(field)
completed.append(field)
for field in ("risks_zh", "risks_en"):
value = out.get(field)
if isinstance(value, list):
cleaned_risks = []
for item in value:
clean_item = _strip_incomplete_ai_sentence(item)
if clean_item:
cleaned_risks.append(clean_item)
if cleaned_risks != value:
trimmed.append(field)
out[field] = cleaned_risks or fallback.get(field)
meta = out.get("_polyweather_meta")
if not isinstance(meta, dict):
meta = {}
if completed:
meta["schema_completed_fields"] = completed
if trimmed:
meta["trimmed_incomplete_fields"] = sorted(set(trimmed))
guard_meta = (fallback.get("_polyweather_meta") or {}).get("evidence_guard")
if isinstance(guard_meta, dict):
deterministic_fields: List[str] = []
numeric_guard_active = bool(
guard_meta.get("observed_high_break")
or guard_meta.get("observed_low_break")
or guard_meta.get("observation_stale")
)
text_guard_active = bool(
numeric_guard_active
or guard_meta.get("peak_has_passed")
or (guard_meta.get("observed_low_lag") and guard_meta.get("peak_not_started"))
)
if numeric_guard_active:
for field in ("predicted_max", "range_low", "range_high"):
guarded_value = fallback.get(field)
if guarded_value is not None and out.get(field) != guarded_value:
out[field] = guarded_value
deterministic_fields.append(field)
if guard_meta.get("observation_stale"):
guarded_text_fields = (
"metar_read_zh",
"metar_read_en",
"final_judgment_zh",
"final_judgment_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
)
elif text_guard_active:
guarded_text_fields = (
"final_judgment_zh",
"final_judgment_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
)
else:
guarded_text_fields = ()
for field in guarded_text_fields:
guarded_value = fallback.get(field)
if guarded_value not in (None, "", []) and out.get(field) != guarded_value:
out[field] = guarded_value
deterministic_fields.append(field)
if deterministic_fields:
meta["deterministic_guard_fields"] = sorted(set(deterministic_fields))
meta["deterministic_guard_reason"] = {
key: guard_meta.get(key)
for key in (
"observation_stale",
"observed_high_break",
"observed_low_break",
"observed_low_lag",
"peak_has_passed",
)
if guard_meta.get(key)
}
out["_polyweather_meta"] = meta
return out
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
import json
from typing import Any, Dict
from web.scan_city_ai_helpers import (
CITY_AI_REQUIRED_FIELDS,
CITY_AI_STREAM_PROVIDER_FIELDS,
_city_ai_response_example,
_city_ai_stream_response_example,
)
SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v6"
def _normalize_locale(value: Any) -> str:
text = str(value or "").strip().lower()
return "en-US" if text.startswith("en") else "zh-CN"
def _observation_prompt_context(ai_input: Dict[str, Any]) -> Dict[str, Any]:
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
return {
"anchor": observation_anchor,
"is_airport_metar": is_airport_metar,
"read_label_zh": str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读")),
"instruction_zh": str(observation_anchor.get("instruction_zh") or ""),
}
def build_city_ai_request_json(
ai_input: Dict[str, Any],
*,
locale: str,
model: str,
max_tokens: int,
) -> Dict[str, Any]:
normalized_locale = _normalize_locale(locale)
context = _observation_prompt_context(ai_input)
observation_label_zh = context["read_label_zh"]
observation_instruction = context["instruction_zh"]
system_prompt = (
"你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON,"
"判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。"
f"你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、{observation_label_zh}、判断依据和风险。"
"必须综合 DEB 最终融合值、全部天气模型预测、实测序列、最新观测、峰值窗口、当地时间、季节背景。"
f"{observation_instruction}"
"如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。"
"你可以基于城市、时间、季节、站点位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
"观测解读必须具体:写清楚最新观测/报文时间、温度、风向风速、云量/天气、能见度或露点中与温度路径相关的因素。"
"涉及风时必须说明该风向对本城市/机场最高温路径倾向增温、降温还是中性,并给出理由;"
"不得只写“风向切换可能冷平流”,必须说明是哪一类风向或哪段风向切换可能带来冷/暖平流。"
"涉及 TAF 或云雨扰动时必须给出报文中的有效时间、BECMG/TEMPO/FM 时间窗或说明“未给出明确时间”;"
"如果没有 TAF 时间依据,不要笼统写“峰值窗口云雨扰动风险”。"
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视最新实测。"
"所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:"
"_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。"
"risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 用 1-2 句。"
"只返回 JSON object,不要 Markdown。"
)
user_payload = {
"locale": normalized_locale,
"task": (
"Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, "
"final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, "
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
"Fill every *_zh field in Simplified Chinese and every *_en field in English in the same response. "
"Use this exact JSON object shape; do not return an array, markdown, or prose outside JSON. "
"Keep final_judgment one short decision sentence. metar_read must explain the latest observation source "
"with report/observation time, temperature, wind direction/speed, cloud/weather/visibility/dewpoint if available. "
"For wind, explicitly say whether the current wind tends to warm, cool, or be neutral for today's high, "
"and why in local city/station context. If mentioning cold/warm advection, name the wind direction or "
"direction shift responsible. If mentioning TAF risk, include the concrete TAF time window or say no "
"explicit timing is available. model_cluster_note must state "
"how many model sources are available, whether they support DEB, and whether the sample is too sparse. "
"Keep the whole JSON compact."
),
"required_json_keys": CITY_AI_REQUIRED_FIELDS,
"json_example": _city_ai_response_example(str(ai_input.get("temp_symbol") or "°C")),
"city_snapshot": ai_input,
}
return {
"model": model,
"temperature": 0.2,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(user_payload, ensure_ascii=False),
},
],
"_polyweather_user_payload": user_payload,
"_polyweather_system_prompt": system_prompt,
}
def build_city_ai_empty_retry_request(request_json: Dict[str, Any]) -> Dict[str, Any]:
system_prompt = str(request_json.get("_polyweather_system_prompt") or "")
user_payload = request_json.get("_polyweather_user_payload") if isinstance(request_json.get("_polyweather_user_payload"), dict) else {}
retry_payload = {
key: value
for key, value in request_json.items()
if not key.startswith("_polyweather_")
}
retry_payload.pop("response_format", None)
retry_payload["temperature"] = 0.1
retry_payload["messages"] = [
{
"role": "system",
"content": (
system_prompt
+ " 这次重试必须返回一个紧凑 JSON object,不要解释,不要空回复。"
+ " If you cannot infer a field, still return the field with a cautious sentence."
),
},
{
"role": "user",
"content": json.dumps(
{
**user_payload,
"retry_reason": "previous provider response had empty message.content",
"task": (
str(user_payload.get("task") or "")
+ " The previous response had empty content. Return only one compact JSON object now."
),
},
ensure_ascii=False,
),
},
]
return retry_payload
def build_city_ai_repair_request_json(
*,
ai_input: Dict[str, Any],
locale: str,
model: str,
max_tokens: int,
previous_error: str,
previous_content: str,
) -> Dict[str, Any]:
normalized_locale = _normalize_locale(locale)
return {
"model": model,
"temperature": 0.0,
"max_tokens": min(max(max_tokens, 1200), 64000),
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"You repair PolyWeather AI output into one strict JSON object. "
"Do not add facts that are not present in the original city snapshot or previous assistant content. "
"Return only JSON, no markdown."
),
},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"required_schema": CITY_AI_REQUIRED_FIELDS,
"previous_error": previous_error,
"previous_assistant_content": previous_content,
"city_snapshot": ai_input,
"instruction": (
"Fill *_zh fields in Simplified Chinese and *_en fields in English; do not leave either language empty. "
"Make final_judgment one direct sentence about today's high temperature. "
"metar_read must interpret the latest observation source with report/observation time, temperature, "
"wind direction/speed, cloud/weather/visibility/dewpoint if available. State whether "
"the current wind tends to warm, cool, or stay neutral for the temperature path, and why. "
"If mentioning cold/warm advection or TAF risk, include the responsible wind direction "
"or the concrete TAF time window; otherwise say timing is not explicit. "
"model_cluster_note must mention available model count/range and whether it supports DEB. "
"Keep the JSON compact."
),
},
ensure_ascii=False,
),
},
],
}
def build_city_ai_stream_request(
ai_input: Dict[str, Any],
*,
locale: str,
model: str,
max_tokens: int,
) -> Dict[str, Any]:
normalized_locale = _normalize_locale(locale)
context = _observation_prompt_context(ai_input)
is_airport_metar = context["is_airport_metar"]
role_label = "机场 METAR 解读员" if is_airport_metar else "官方观测站解读员"
read_label = context["read_label_zh"]
source_instruction = context["instruction_zh"]
system_prompt = (
f"你是 PolyWeather 的{role_label}"
"只返回一个紧凑 JSON object,不要 Markdown。"
f"只解读最新观测/报文对今日最高温路径的影响,必须只写 metar_read_zh、metar_read_en、reasoning_zh、reasoning_en 四个字段,便于前端快速显示{read_label}"
"最高温数值、模型一致性和风险清单由后端规则补齐,不要生成这些字段。"
f"{source_instruction}"
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。"
"涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。"
"如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。"
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
"不要写交易建议、BUY/SELL、Kelly 或套利。"
)
return {
"model": model,
"temperature": 0.2,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
"stream": True,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"task": (
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. "
"Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. "
"Return exactly one JSON object and no markdown."
),
"required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS,
"json_example": _city_ai_stream_response_example(
str(ai_input.get("temp_symbol") or "°C")
),
"city_snapshot": ai_input,
},
ensure_ascii=False,
),
},
],
}
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import json
from typing import Any, Dict
import httpx
from loguru import logger
from web.scan_city_ai_fallback import (
_build_city_ai_fallback,
_complete_city_ai_payload,
)
from web.scan_city_ai_helpers import (
_extract_ai_json_object,
_extract_provider_content,
_provider_response_meta,
_truncate_ai_text,
)
from web.scan_city_ai_prompt import (
_normalize_locale,
build_city_ai_empty_retry_request,
build_city_ai_repair_request_json,
build_city_ai_request_json,
)
def _call_deepseek_city_ai(
ai_input: Dict[str, Any],
*,
locale: str = "zh-CN",
api_key: str,
base_url: str,
model: str,
max_tokens: int,
timeout_sec: int,
) -> Dict[str, Any]:
if not api_key:
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
normalized_locale = _normalize_locale(locale)
timeout = httpx.Timeout(
timeout=float(timeout_sec),
connect=min(8.0, float(timeout_sec)),
read=float(timeout_sec),
write=10.0,
pool=5.0,
)
request_json = build_city_ai_request_json(
ai_input,
locale=normalized_locale,
model=model,
max_tokens=max_tokens,
)
provider_request = {
key: value
for key, value in request_json.items()
if not key.startswith("_polyweather_")
}
logger.info(
"scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}",
ai_input.get("city"),
normalized_locale,
len(json.dumps(provider_request, ensure_ascii=False, default=str).encode("utf-8")),
request_json.get("max_tokens"),
timeout_sec,
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
with httpx.Client(timeout=timeout) as client:
response = client.post(
f"{base_url}/chat/completions",
headers=headers,
json=provider_request,
)
response.raise_for_status()
data = response.json()
content = _extract_provider_content(data)
if not str(content or "").strip():
logger.warning(
"scan city AI provider returned empty content city={} locale={} finish_reason={} retrying_without_json_mode=true",
ai_input.get("city"),
normalized_locale,
_provider_response_meta(data).get("finish_reason"),
)
retry_payload = build_city_ai_empty_retry_request(request_json)
response = client.post(
f"{base_url}/chat/completions",
headers=headers,
json=retry_payload,
)
response.raise_for_status()
data = response.json()
content = _extract_provider_content(data)
try:
parsed = _extract_ai_json_object(str(content or ""))
except ValueError as exc:
preview = _truncate_ai_text(content, 700)
logger.warning(
"scan city AI provider returned non-json city={} locale={} finish_reason={} content_preview={}",
ai_input.get("city"),
normalized_locale,
_provider_response_meta(data).get("finish_reason"),
preview,
)
repair_payload = build_city_ai_repair_request_json(
ai_input=ai_input,
locale=normalized_locale,
model=model,
max_tokens=max_tokens,
previous_error=str(exc),
previous_content=_truncate_ai_text(content, 5000),
)
try:
repair_response = client.post(
f"{base_url}/chat/completions",
headers=headers,
json=repair_payload,
)
repair_response.raise_for_status()
repair_data = repair_response.json()
repair_content = _extract_provider_content(repair_data)
parsed = _extract_ai_json_object(str(repair_content or ""))
if isinstance(parsed, dict):
parsed["_polyweather_meta"] = {
**_provider_response_meta(repair_data),
"repaired_from_non_json": True,
"original_finish_reason": _provider_response_meta(data).get("finish_reason"),
"original_content_preview": preview,
}
return _complete_city_ai_payload(
parsed,
ai_input,
locale=normalized_locale,
)
except Exception as repair_exc:
logger.warning(
"scan city AI provider json repair failed city={} locale={} error={}",
ai_input.get("city"),
normalized_locale,
repair_exc,
)
return _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=str(exc),
raw_content=str(content or ""),
provider_data=data,
)
if isinstance(data, dict):
parsed["_polyweather_meta"] = _provider_response_meta(data)
return _complete_city_ai_payload(
parsed,
ai_input,
locale=normalized_locale,
)
+22 -296
View File
@@ -17,20 +17,24 @@ from loguru import logger
from web.analysis_service import _analyze, _build_city_market_scan_payload
from web.core import CITIES
from src.data_collection.city_registry import ALIASES
from web.scan_city_ai_helpers import (
CITY_AI_REQUIRED_FIELDS,
CITY_AI_STREAM_PROVIDER_FIELDS,
from web.scan_city_ai_fallback import (
_build_city_ai_fallback,
_city_ai_response_example,
_city_ai_stream_response_example,
_complete_city_ai_payload,
)
from web.scan_city_ai_helpers import (
_extract_ai_json_object,
_extract_provider_content,
_extract_provider_stream_delta,
_provider_response_meta,
_safe_float,
_truncate_ai_text,
)
from web.scan_city_ai_prompt import (
SCAN_CITY_AI_PROMPT_VERSION,
build_city_ai_stream_request,
)
from web.scan_city_ai_provider import (
_call_deepseek_city_ai as _call_deepseek_city_ai_provider,
)
_SCAN_TERMINAL_CACHE_LOCK = threading.Lock()
_SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {}
@@ -118,9 +122,6 @@ SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int(
min_value=300,
max_value=64000,
)
SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v6"
def _safe_int(value: Any, default: int) -> int:
try:
@@ -1129,248 +1130,14 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]:
def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -> Dict[str, Any]:
api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip()
if not api_key:
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
normalized_locale = _normalize_locale(locale)
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
observation_label_zh = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读"))
observation_instruction = str(observation_anchor.get("instruction_zh") or "")
system_prompt = (
"你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON,"
"判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。"
f"你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、{observation_label_zh}、判断依据和风险。"
"必须综合 DEB 最终融合值、全部天气模型预测、实测序列、最新观测、峰值窗口、当地时间、季节背景。"
f"{observation_instruction}"
"如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。"
"你可以基于城市、时间、季节、站点位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
"观测解读必须具体:写清楚最新观测/报文时间、温度、风向风速、云量/天气、能见度或露点中与温度路径相关的因素。"
"涉及风时必须说明该风向对本城市/机场最高温路径倾向增温、降温还是中性,并给出理由;"
"不得只写“风向切换可能冷平流”,必须说明是哪一类风向或哪段风向切换可能带来冷/暖平流。"
"涉及 TAF 或云雨扰动时必须给出报文中的有效时间、BECMG/TEMPO/FM 时间窗或说明“未给出明确时间”;"
"如果没有 TAF 时间依据,不要笼统写“峰值窗口云雨扰动风险”。"
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视最新实测。"
"所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:"
"_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。"
"risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 用 1-2 句。"
"只返回 JSON object,不要 Markdown。"
)
user_payload = {
"locale": normalized_locale,
"task": (
"Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, "
"final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, "
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
"Fill every *_zh field in Simplified Chinese and every *_en field in English in the same response. "
"Use this exact JSON object shape; do not return an array, markdown, or prose outside JSON. "
"Keep final_judgment one short decision sentence. metar_read must explain the latest observation source "
"with report/observation time, temperature, wind direction/speed, cloud/weather/visibility/dewpoint if available. "
"For wind, explicitly say whether the current wind tends to warm, cool, or be neutral for today's high, "
"and why in local city/station context. If mentioning cold/warm advection, name the wind direction or "
"direction shift responsible. If mentioning TAF risk, include the concrete TAF time window or say no "
"explicit timing is available. model_cluster_note must state "
"how many model sources are available, whether they support DEB, and whether the sample is too sparse. "
"Keep the whole JSON compact."
),
"required_json_keys": CITY_AI_REQUIRED_FIELDS,
"json_example": _city_ai_response_example(str(ai_input.get("temp_symbol") or "°C")),
"city_snapshot": ai_input,
}
timeout = httpx.Timeout(
timeout=float(SCAN_CITY_AI_TIMEOUT_SEC),
connect=min(8.0, float(SCAN_CITY_AI_TIMEOUT_SEC)),
read=float(SCAN_CITY_AI_TIMEOUT_SEC),
write=10.0,
pool=5.0,
)
request_json = {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(user_payload, ensure_ascii=False),
},
],
}
logger.info(
"scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}",
ai_input.get("city"),
normalized_locale,
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
request_json.get("max_tokens"),
SCAN_CITY_AI_TIMEOUT_SEC,
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
with httpx.Client(timeout=timeout) as client:
response = client.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=request_json,
)
response.raise_for_status()
data = response.json()
content = _extract_provider_content(data)
if not str(content or "").strip():
logger.warning(
"scan city AI provider returned empty content city={} locale={} finish_reason={} retrying_without_json_mode=true",
ai_input.get("city"),
normalized_locale,
_provider_response_meta(data).get("finish_reason"),
)
retry_payload = dict(request_json)
retry_payload.pop("response_format", None)
retry_payload["temperature"] = 0.1
retry_payload["messages"] = [
{
"role": "system",
"content": (
system_prompt
+ " 这次重试必须返回一个紧凑 JSON object,不要解释,不要空回复。"
+ " If you cannot infer a field, still return the field with a cautious sentence."
),
},
{
"role": "user",
"content": json.dumps(
{
**user_payload,
"retry_reason": "previous provider response had empty message.content",
"task": (
user_payload["task"]
+ " The previous response had empty content. Return only one compact JSON object now."
),
},
ensure_ascii=False,
),
},
]
response = client.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=retry_payload,
)
response.raise_for_status()
data = response.json()
content = _extract_provider_content(data)
try:
parsed = _extract_ai_json_object(str(content or ""))
except ValueError as exc:
preview = _truncate_ai_text(content, 700)
logger.warning(
"scan city AI provider returned non-json city={} locale={} finish_reason={} content_preview={}",
ai_input.get("city"),
normalized_locale,
_provider_response_meta(data).get("finish_reason"),
preview,
)
repair_payload = {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.0,
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 1200), 64000),
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": (
"You repair PolyWeather AI output into one strict JSON object. "
"Do not add facts that are not present in the original city snapshot or previous assistant content. "
"Return only JSON, no markdown."
),
},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"required_schema": [
"predicted_max",
"range_low",
"range_high",
"unit",
"confidence",
"final_judgment_zh",
"final_judgment_en",
"metar_read_zh",
"metar_read_en",
"reasoning_zh",
"reasoning_en",
"risks_zh",
"risks_en",
"model_cluster_note_zh",
"model_cluster_note_en",
],
"previous_error": str(exc),
"previous_assistant_content": _truncate_ai_text(content, 5000),
"city_snapshot": ai_input,
"instruction": (
"Fill *_zh fields in Simplified Chinese and *_en fields in English; do not leave either language empty. "
"Make final_judgment one direct sentence about today's high temperature. "
"metar_read must interpret the latest observation source with report/observation time, temperature, "
"wind direction/speed, cloud/weather/visibility/dewpoint if available. State whether "
"the current wind tends to warm, cool, or stay neutral for the temperature path, and why. "
"If mentioning cold/warm advection or TAF risk, include the responsible wind direction "
"or the concrete TAF time window; otherwise say timing is not explicit. "
"model_cluster_note must mention available model count/range and whether it supports DEB. "
"Keep the JSON compact."
),
},
ensure_ascii=False,
),
},
],
}
try:
repair_response = client.post(
f"{SCAN_AI_BASE_URL}/chat/completions",
headers=headers,
json=repair_payload,
)
repair_response.raise_for_status()
repair_data = repair_response.json()
repair_content = _extract_provider_content(repair_data)
parsed = _extract_ai_json_object(str(repair_content or ""))
if isinstance(parsed, dict):
parsed["_polyweather_meta"] = {
**_provider_response_meta(repair_data),
"repaired_from_non_json": True,
"original_finish_reason": _provider_response_meta(data).get("finish_reason"),
"original_content_preview": preview,
}
return _complete_city_ai_payload(
parsed,
ai_input,
locale=normalized_locale,
)
except Exception as repair_exc:
logger.warning(
"scan city AI provider json repair failed city={} locale={} error={}",
ai_input.get("city"),
normalized_locale,
repair_exc,
)
return _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=str(exc),
raw_content=str(content or ""),
provider_data=data,
)
if isinstance(data, dict):
parsed["_polyweather_meta"] = _provider_response_meta(data)
return _complete_city_ai_payload(
parsed,
return _call_deepseek_city_ai_provider(
ai_input,
locale=normalized_locale,
locale=locale,
api_key=str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(),
base_url=SCAN_AI_BASE_URL,
model=SCAN_CITY_AI_MODEL,
max_tokens=SCAN_CITY_AI_MAX_TOKENS,
timeout_sec=SCAN_CITY_AI_TIMEOUT_SEC,
)
@@ -1431,53 +1198,12 @@ def _build_city_ai_stream_request(
*,
locale: str,
) -> Dict[str, Any]:
normalized_locale = _normalize_locale(locale)
observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {}
is_airport_metar = observation_anchor.get("is_airport_metar") is not False
role_label = "机场 METAR 解读员" if is_airport_metar else "官方观测站解读员"
read_label = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读"))
source_instruction = str(observation_anchor.get("instruction_zh") or "")
system_prompt = (
f"你是 PolyWeather 的{role_label}"
"只返回一个紧凑 JSON object,不要 Markdown。"
f"只解读最新观测/报文对今日最高温路径的影响,必须只写 metar_read_zh、metar_read_en、reasoning_zh、reasoning_en 四个字段,便于前端快速显示{read_label}"
"最高温数值、模型一致性和风险清单由后端规则补齐,不要生成这些字段。"
f"{source_instruction}"
"观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。"
"涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。"
"如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。"
"所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。"
"不要写交易建议、BUY/SELL、Kelly 或套利。"
return build_city_ai_stream_request(
ai_input,
locale=locale,
model=SCAN_CITY_AI_MODEL,
max_tokens=SCAN_CITY_AI_STREAM_MAX_TOKENS,
)
return {
"model": SCAN_CITY_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_STREAM_MAX_TOKENS,
"response_format": {"type": "json_object"},
"stream": True,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(
{
"locale": normalized_locale,
"task": (
"Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. "
"Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. "
"Return exactly one JSON object and no markdown."
),
"required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS,
"json_example": _city_ai_stream_response_example(
str(ai_input.get("temp_symbol") or "°C")
),
"city_snapshot": ai_input,
},
ensure_ascii=False,
),
},
],
}
def _cache_city_ai_payload(