Upgrade V4 scan to city analysis

This commit is contained in:
2569718930@qq.com
2026-04-25 02:19:37 +08:00
parent 1db473d60a
commit 62a12257f0
6 changed files with 680 additions and 76 deletions
@@ -10399,6 +10399,192 @@
radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
}
.root :global(.scan-ai-analysis-view) {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 520px;
}
.root :global(.scan-ai-analysis-view.empty) {
min-height: 620px;
}
.root :global(.scan-ai-summary-card) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)),
radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%);
}
.root :global(.scan-ai-summary-card strong) {
color: #ecf8ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-ai-summary-card p) {
margin: 6px 0 0;
color: #a6bbd7;
font-size: 13px;
font-weight: 700;
line-height: 1.55;
}
.root :global(.scan-ai-inline-button) {
min-height: 42px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 12px;
color: #d9fff6;
background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4));
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.root :global(.scan-ai-inline-button:disabled) {
cursor: not-allowed;
opacity: 0.48;
}
.root :global(.scan-ai-log-panel.in-tab) {
margin: 0;
}
.root :global(.scan-ai-city-list) {
display: flex;
flex-direction: column;
gap: 12px;
}
.root :global(.scan-ai-city-card) {
border: 1px solid rgba(68, 100, 150, 0.2);
border-radius: 18px;
overflow: hidden;
background: rgba(10, 24, 42, 0.82);
}
.root :global(.scan-ai-city-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
background: rgba(15, 34, 57, 0.9);
}
.root :global(.scan-ai-city-head strong) {
color: #f2f8ff;
font-size: 17px;
font-weight: 900;
}
.root :global(.scan-ai-city-head p) {
margin: 6px 0 0;
color: #9fb4d2;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-ai-city-head span) {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
color: #68f3c8;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.2);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-cluster-note) {
margin: 0 18px 14px;
padding: 10px 12px;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 12px;
color: #b7cbe6;
background: rgba(6, 18, 32, 0.5);
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-ai-contracts) {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
}
.root :global(.scan-ai-contract) {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 10px 16px;
align-items: center;
padding: 12px;
border-top: 1px solid rgba(68, 100, 150, 0.16);
}
.root :global(.scan-ai-contract b) {
display: block;
color: #edf7ff;
font-size: 13px;
font-weight: 900;
}
.root :global(.scan-ai-contract small) {
display: block;
margin-top: 3px;
color: #8fa8c7;
font-size: 11px;
font-weight: 800;
}
.root :global(.scan-ai-contract > span) {
justify-self: end;
padding: 5px 9px;
border-radius: 999px;
color: #a8bedc;
background: rgba(68, 100, 150, 0.16);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-contract.approve > span) {
color: #23f0a8;
background: rgba(16, 185, 129, 0.14);
}
.root :global(.scan-ai-contract.veto > span) {
color: #ff8a8a;
background: rgba(248, 113, 113, 0.12);
}
.root :global(.scan-ai-contract.downgrade > span),
.root :global(.scan-ai-contract.watchlist > span) {
color: #ffd166;
background: rgba(245, 158, 11, 0.12);
}
.root :global(.scan-ai-contract p) {
grid-column: 1 / -1;
margin: 0;
color: #9fb4d2;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-ai-log-head) {
display: flex;
align-items: center;
@@ -11144,6 +11330,45 @@
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: #475569;
}
.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);
@@ -156,6 +156,16 @@ function getAiMeta(row: ScanOpportunityRow, locale: string) {
null,
};
}
if (decision === "watchlist") {
return {
label: locale === "en-US" ? "AI watch" : "AI 观察",
tone: "downgrade",
reason:
locale === "en-US"
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
};
}
return null;
}
@@ -58,7 +58,7 @@ const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
interface FilterState extends ScanTerminalFilters {}
type ContentView = "list" | "map" | "calendar";
type ContentView = "list" | "map" | "ai" | "calendar";
type ThemeMode = "dark" | "light";
type ScanAiLogTone = "info" | "success" | "warning" | "error";
@@ -166,6 +166,209 @@ function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) {
return rows.find((row) => rowMatchesCity(row, cityName || "")) || null;
}
function getRowAiReason(row: ScanOpportunityRow, locale: string) {
const isEn = locale === "en-US";
return (
(isEn ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
row.ai_model_cluster_note ||
(isEn
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en) ||
null
);
}
function getAiDecisionLabel(row: ScanOpportunityRow, locale: string) {
const decision = String(row.ai_decision || "neutral").toLowerCase();
if (decision === "veto") return locale === "en-US" ? "Excluded" : "排除";
if (decision === "downgrade") return locale === "en-US" ? "Downgraded" : "降级";
if (decision === "watchlist") return locale === "en-US" ? "Watch" : "观察";
if (row.ai_rank != null || decision === "approve") {
return locale === "en-US" ? `Pick ${row.ai_rank || ""}`.trim() : `推荐 ${row.ai_rank || ""}`.trim();
}
return locale === "en-US" ? "Unreviewed" : "未复核";
}
function ScanAiAnalysisView({
response,
rows,
logs,
loading,
error,
locale,
onRunAi,
}: {
response: ScanTerminalResponse | null;
rows: ScanOpportunityRow[];
logs: ScanAiLogEntry[];
loading: boolean;
error?: string | null;
locale: string;
onRunAi: () => void;
}) {
const isEn = locale === "en-US";
const aiScan = response?.ai_scan || null;
const theses = aiScan?.city_theses || [];
const thesisByCity = useMemo(
() => new Map(theses.map((item) => [normalizeCityKey(item.city), item])),
[theses],
);
const groups = useMemo(() => {
const map = new Map<string, ScanOpportunityRow[]>();
rows.forEach((row) => {
const key = normalizeCityKey(row.city || row.city_display_name || row.display_name);
if (!key) return;
const current = map.get(key) || [];
current.push(row);
map.set(key, current);
});
return Array.from(map.entries()).map(([key, items]) => ({
key,
rows: items,
city:
items[0]?.city_display_name ||
items[0]?.display_name ||
items[0]?.city ||
key,
thesis: thesisByCity.get(key),
}));
}, [rows, thesisByCity]);
if (!rows.length) {
return (
<div className="scan-ai-analysis-view empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Run a rule scan first" : "先完成规则扫描"}
</div>
<div className="scan-empty-copy">
{isEn
? "V4 needs the current city candidates before it can analyze them."
: "V4 需要先拿到当前城市候选,才能做城市级研判。"}
</div>
</div>
</div>
);
}
return (
<div className="scan-ai-analysis-view">
<section className="scan-ai-summary-card">
<div>
<strong>{isEn ? "V4 city analysis" : "V4 城市级分析"}</strong>
<p>
{aiScan?.status === "ready"
? (isEn ? aiScan.summary_en || aiScan.summary_zh : aiScan.summary_zh || aiScan.summary_en) ||
(isEn ? "V4 has reviewed the grouped city snapshot." : "V4 已复核城市分组快照。")
: error ||
aiScan?.reason ||
(isEn
? "Click AI Deep Scan after a rule scan to generate city theses and contract decisions."
: "规则扫描后点击 AI 深度扫描,生成每城 thesis 和合约推荐/排除理由。")}
</p>
</div>
<button
type="button"
className="scan-ai-inline-button"
onClick={onRunAi}
disabled={loading || !rows.length}
>
<Sparkles size={14} className={loading ? "spin" : undefined} />
{loading
? isEn
? "Analyzing..."
: "分析中..."
: isEn
? "Run V4"
: "运行 V4"}
</button>
</section>
<section className="scan-ai-log-panel in-tab">
<div className="scan-ai-log-head">
<strong>{isEn ? "Run log" : "运行日志"}</strong>
<span>
{aiScan?.status === "ready"
? isEn
? `${aiScan.sent_cities ?? "--"} cities · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} contracts`
: `${aiScan.sent_cities ?? "--"} 城 · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} 合约`
: isEn
? "No V4 result yet"
: "暂无 V4 结果"}
</span>
</div>
<div className="scan-ai-log-list">
{logs.length ? (
logs.map((entry) => (
<div key={entry.id} className={`scan-ai-log-item ${entry.tone}`}>
<span className="scan-ai-log-time">{entry.time}</span>
<div>
<b>{entry.title}</b>
{entry.detail ? <small>{entry.detail}</small> : null}
</div>
</div>
))
) : (
<div className="scan-ai-log-empty">
{isEn ? "No request has been sent." : "还没有发起请求。"}
</div>
)}
</div>
</section>
<div className="scan-ai-city-list">
{groups.map((group) => {
const cityThesis =
(isEn
? group.thesis?.thesis_en || group.thesis?.summary_en || group.rows[0]?.ai_city_thesis_en
: group.thesis?.thesis_zh || group.thesis?.summary_zh || group.rows[0]?.ai_city_thesis_zh) ||
(isEn ? "Waiting for V4 city thesis." : "等待 V4 城市研判。");
const clusterNote =
group.thesis?.model_cluster_note || group.rows[0]?.ai_city_model_cluster_note || null;
return (
<section key={group.key} className="scan-ai-city-card">
<div className="scan-ai-city-head">
<div>
<strong>{group.city}</strong>
<p>{cityThesis}</p>
</div>
{group.thesis?.confidence || group.rows[0]?.ai_city_confidence ? (
<span>{group.thesis?.confidence || group.rows[0]?.ai_city_confidence}</span>
) : null}
</div>
{clusterNote ? (
<div className="scan-ai-cluster-note">{clusterNote}</div>
) : null}
<div className="scan-ai-contracts">
{group.rows.map((row) => {
const decision = String(row.ai_decision || "neutral").toLowerCase();
const reason = getRowAiReason(row, locale);
const tempSymbol = row.temp_symbol || row.target_unit || "°C";
return (
<div key={row.id} className={`scan-ai-contract ${decision}`}>
<div>
<b>{row.action || row.target_label || row.market_question || row.id}</b>
<small>
{row.deb_prediction != null
? `DEB ${formatTemperatureValue(row.deb_prediction, tempSymbol)} · `
: ""}
{row.edge_percent != null ? `edge ${Number(row.edge_percent).toFixed(1)}%` : "--"}
</small>
</div>
<span>{getAiDecisionLabel(row, locale)}</span>
{reason ? <p>{reason}</p> : null}
</div>
);
})}
</div>
</section>
);
})}
</div>
</div>
);
}
function CalendarView({
rows,
locale,
@@ -441,19 +644,20 @@ function ScanTerminalScreen() {
tone: "info",
title: isEn ? "V4 review queued" : "V4 复核已排队",
detail: isEn
? `snapshot ${snapshotId || "--"} · ${rowCount} rule candidates`
: `快照 ${snapshotId || "--"} · 规则候选 ${rowCount}`,
? `snapshot ${snapshotId || "--"} · ${rowCount} rule candidates grouped by city`
: `快照 ${snapshotId || "--"} · ${rowCount}候选将按城市分组`,
},
{
id: `send-${Date.now() + 1}`,
time: formatLogTime(),
tone: "info",
title: isEn ? "Sending compact candidate set" : "正在发送精简候选集",
title: isEn ? "Sending city-level context" : "正在发送城市级上下文",
detail: isEn
? "VPS will call DeepSeek; Vercel only proxies the request."
: "由 VPS 调用 DeepSeekVercel 只做短代理。",
? "Each city includes EMOS distribution, model cluster and contracts."
: "每城包含 EMOS 分布、模型集群和候选合约。",
},
]);
setActiveView("ai");
setAiLoading(true);
setAiError(null);
try {
@@ -493,8 +697,8 @@ function ScanTerminalScreen() {
detail:
review?.status === "ready"
? isEn
? `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · sent ${review.sent_rows ?? "--"} rows · ${review.recommended_count ?? 0} picks / ${review.vetoed_count ?? 0} veto`
: `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · 发送 ${review.sent_rows ?? "--"} · 推荐 ${review.recommended_count ?? 0} / 排除 ${review.vetoed_count ?? 0}`
? `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · ${review.sent_cities ?? "--"} cities / ${review.sent_contracts ?? review.sent_rows ?? "--"} contracts · ${review.recommended_count ?? 0} picks / ${review.vetoed_count ?? 0} veto`
: `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · ${review.sent_cities ?? "--"} 城 / ${review.sent_contracts ?? review.sent_rows ?? "--"} 合约 · 推荐 ${review.recommended_count ?? 0} / 排除 ${review.vetoed_count ?? 0}`
: review?.reason || (isEn ? "No AI result returned." : "没有返回 AI 结果。"),
},
...(review?.usage
@@ -608,7 +812,8 @@ function ScanTerminalScreen() {
setMapSelectedCityName(cityName);
const matchedRow = findRowForCity(timeSortedRows, cityName);
setSelectedRowId(matchedRow?.id || null);
}, [timeSortedRows]);
void store.selectCity(cityName);
}, [store, timeSortedRows]);
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
setSelectedRowId(row.id);
@@ -658,6 +863,19 @@ function ScanTerminalScreen() {
/>
);
}
if (resolvedView === "ai") {
return (
<ScanAiAnalysisView
response={terminalData}
rows={timeSortedRows}
logs={aiLogs}
loading={aiLoading}
error={aiError}
locale={locale}
onRunAi={() => void runAiReview()}
/>
);
}
return (
<>
<OpportunityTable
@@ -837,6 +1055,20 @@ function ScanTerminalScreen() {
>
{isEn ? "Opportunity List" : "机会列表"}
</button>
<button
type="button"
className={resolvedView === "ai" ? "active" : ""}
title={!isPro ? (isEn ? "Pro scan required" : "扫描需 Pro") : undefined}
onClick={() => {
if (!isPro) {
openScanPaywall();
return;
}
setActiveView("ai");
}}
>
{isEn ? "V4 Analysis" : "V4 分析"}
</button>
<button
type="button"
className={resolvedView === "calendar" ? "active" : ""}
@@ -870,43 +1102,6 @@ function ScanTerminalScreen() {
) : null}
</div>
</div>
{isPro ? (
<div className="scan-ai-log-panel">
<div className="scan-ai-log-head">
<strong>{isEn ? "V4 run log" : "V4 运行日志"}</strong>
<span>
{aiLoading
? isEn
? "Waiting for DeepSeek response"
: "正在等待 DeepSeek 返回"
: aiScan?.status
? `${aiScan.model || "V4"} · ${aiScan.status}`
: isEn
? "No V4 request yet"
: "还没有发起 V4 请求"}
</span>
</div>
<div className="scan-ai-log-list">
{aiLogs.length ? (
aiLogs.map((entry) => (
<div key={entry.id} className={`scan-ai-log-item ${entry.tone}`}>
<span className="scan-ai-log-time">{entry.time}</span>
<div>
<b>{entry.title}</b>
{entry.detail ? <small>{entry.detail}</small> : null}
</div>
</div>
))
) : (
<div className="scan-ai-log-empty">
{isEn
? "After a rule scan, click AI Deep Scan to see request and result logs here."
: "规则扫描完成后点击 AI 深度扫描,这里会显示请求和返回日志。"}
</div>
)}
</div>
</div>
) : null}
{scanStatus === "failed" && !terminalData ? (
<div className="scan-empty-state">
+32
View File
@@ -401,6 +401,7 @@ export interface MarketScan {
websocket?: Record<string, unknown>;
distribution_bias?: DistributionBias | null;
distribution_preview?: DistributionPreviewPoint[] | null;
distribution_full?: DistributionPreviewPoint[] | null;
window_phase?: string | null;
window_score?: number | null;
primary_signal?: PrimarySignal | null;
@@ -513,6 +514,7 @@ export interface ScanOpportunityRow {
cluster_model_count?: number | null;
cluster_deb_reference?: number | null;
cluster_median?: number | null;
model_cluster_sources?: Record<string, number | null> | null;
window_phase?: string | null;
window_score?: number | null;
remaining_window_minutes?: number | null;
@@ -541,10 +543,35 @@ export interface ScanOpportunityRow {
ai_reason_zh?: string | null;
ai_reason_en?: string | null;
ai_model_cluster_note?: string | null;
ai_city_thesis_zh?: string | null;
ai_city_thesis_en?: string | null;
ai_city_confidence?: string | null;
ai_city_model_cluster_note?: string | null;
ai_watchlist_reason_zh?: string | null;
ai_watchlist_reason_en?: string | null;
}
export interface PrimarySignal extends ScanOpportunityRow {}
export interface ScanAiCityThesis {
city: string;
thesis_zh?: string | null;
thesis_en?: string | null;
summary_zh?: string | null;
summary_en?: string | null;
model_cluster_note?: string | null;
confidence?: string | null;
recommended_row_ids?: string[] | null;
vetoed_row_ids?: string[] | null;
}
export interface ScanAiWatchlistItem {
row_id: string;
reason?: string | null;
reason_zh?: string | null;
reason_en?: string | null;
}
export interface ScanAiReview {
status?: "ready" | "disabled" | "missing_key" | "failed" | "no_rows" | "no_snapshot" | "snapshot_mismatch" | string;
stage?: "completed" | "fallback" | string | null;
@@ -554,6 +581,8 @@ export interface ScanAiReview {
snapshot_id?: string | null;
input_rows?: number | null;
sent_rows?: number | null;
sent_cities?: number | null;
sent_contracts?: number | null;
duration_ms?: number | null;
timeout_sec?: number | null;
cache_ttl_sec?: number | null;
@@ -570,9 +599,12 @@ export interface ScanAiReview {
reason?: string | null;
summary_zh?: string | null;
summary_en?: string | null;
city_theses?: ScanAiCityThesis[] | null;
watchlist?: ScanAiWatchlistItem[] | null;
recommended_count?: number | null;
vetoed_count?: number | null;
downgraded_count?: number | null;
watchlist_count?: number | null;
}
export interface ScanTerminalResponse {
@@ -3359,5 +3359,6 @@ class PolymarketReadOnlyLayer:
"window_phase": window_meta.get("phase"),
"window_score": window_meta.get("score"),
"distribution_preview": distribution_preview[:6],
"distribution_full": distribution_preview,
"resolved_market_type": "maxtemp",
}
+171 -30
View File
@@ -335,11 +335,6 @@ def _set_cached_scan_ai_result(snapshot_id: str, filters: Dict[str, Any], result
def _compact_ai_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": row.get("id"),
"city": row.get("city_display_name") or row.get("display_name") or row.get("city"),
"local_time": row.get("local_time"),
"current_temp": row.get("current_temp"),
"current_max_so_far": row.get("current_max_so_far"),
"deb_prediction": row.get("deb_prediction"),
"action": row.get("action"),
"side": row.get("side"),
"target_label": row.get("target_label"),
@@ -357,35 +352,118 @@ def _compact_ai_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
"quote_age_ms": row.get("quote_age_ms"),
"edge_percent": row.get("edge_percent"),
"final_score": row.get("final_score"),
"window_phase": row.get("window_phase"),
"remaining_window_minutes": row.get("remaining_window_minutes"),
"distribution_preview": (row.get("distribution_preview") or [])[:6],
"peak_value": row.get("peak_value"),
"peak_probability": row.get("peak_probability"),
"cluster_role": row.get("cluster_role"),
"cluster_core_low": row.get("cluster_core_low"),
"cluster_core_high": row.get("cluster_core_high"),
"cluster_median": row.get("cluster_median"),
"cluster_deb_reference": row.get("cluster_deb_reference"),
"cluster_model_count": row.get("cluster_model_count"),
"trend_alignment": row.get("trend_alignment"),
"tradable": row.get("tradable"),
"accepting_orders": row.get("accepting_orders"),
}
def _normalize_ai_city_key(value: Any) -> str:
return str(value or "").strip().lower().replace(" ", "").replace("-", "").replace("_", "")
def _compact_ai_distribution(row: Dict[str, Any]) -> List[Dict[str, Any]]:
raw_items = row.get("distribution_full") or row.get("distribution_preview") or []
if not isinstance(raw_items, list):
return []
out: List[Dict[str, Any]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
out.append(
{
"label": item.get("label"),
"value": item.get("value"),
"unit": item.get("unit") or row.get("target_unit") or row.get("temp_symbol"),
"model_probability": item.get("model_probability"),
"market_probability": item.get("market_probability"),
"highlighted": item.get("highlighted"),
}
)
return out
def _compact_ai_model_sources(row: Dict[str, Any]) -> List[Dict[str, Any]]:
raw_sources = row.get("model_cluster_sources")
if not isinstance(raw_sources, dict):
return []
sources: List[Dict[str, Any]] = []
for name, value in raw_sources.items():
if _safe_float(value) is None:
continue
sources.append({"model": str(name), "value": value})
return sources[:12]
def _compact_ai_city_group(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
first = rows[0]
distribution = _compact_ai_distribution(first)
peak = next((item for item in distribution if item.get("highlighted")), None)
if not peak and distribution:
peak = max(
distribution,
key=lambda item: _safe_float(item.get("model_probability")) or -1.0,
)
return {
"city": first.get("city"),
"city_display_name": first.get("city_display_name") or first.get("display_name") or first.get("city"),
"selected_date": first.get("selected_date") or first.get("local_date"),
"local_time": first.get("local_time"),
"temp_symbol": first.get("temp_symbol") or first.get("target_unit"),
"current_temp": first.get("current_temp"),
"current_max_so_far": first.get("current_max_so_far"),
"deb_prediction": first.get("deb_prediction"),
"window_phase": first.get("window_phase"),
"remaining_window_minutes": first.get("remaining_window_minutes"),
"emos_distribution": distribution,
"emos_peak": {
"label": (peak or {}).get("label"),
"value": (peak or {}).get("value"),
"probability": (peak or {}).get("model_probability"),
},
"model_cluster": {
"core_low": first.get("cluster_core_low"),
"core_high": first.get("cluster_core_high"),
"median": first.get("cluster_median"),
"deb_reference": first.get("cluster_deb_reference"),
"model_count": first.get("cluster_model_count"),
"sources": _compact_ai_model_sources(first),
},
"contracts": [_compact_ai_candidate(row) for row in rows],
}
def _build_scan_ai_prompt(payload: Dict[str, Any]) -> Dict[str, Any]:
rows = [
_compact_ai_candidate(row)
raw_rows = [
row
for row in (payload.get("rows") or [])[:SCAN_AI_MAX_ROWS]
if isinstance(row, dict) and row.get("id")
]
grouped: Dict[str, List[Dict[str, Any]]] = {}
for row in raw_rows:
key = "|".join(
[
_normalize_ai_city_key(row.get("city") or row.get("city_display_name")),
str(row.get("selected_date") or row.get("local_date") or ""),
]
)
grouped.setdefault(key, []).append(row)
cities = [_compact_ai_city_group(rows) for rows in grouped.values() if rows]
sent_contracts = sum(len(city.get("contracts") or []) for city in cities)
return {
"schema_version": "city_group_v2",
"snapshot_id": payload.get("snapshot_id"),
"generated_at": payload.get("generated_at"),
"summary": payload.get("summary") or {},
"filters": payload.get("filters") or {},
"candidates": rows,
"city_count": len(cities),
"candidate_row_count": len(raw_rows),
"cities": cities,
"_polyweather_input_meta": {
"sent_cities": len(cities),
"sent_contracts": sent_contracts,
},
}
@@ -395,19 +473,26 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
system_prompt = (
"你是 PolyWeather 的付费市场扫描员。你只能基于用户提供的 JSON 快照做判断,"
"不得编造城市、价格、概率、盘口或天气数据。你的任务是从候选 rows 中选择真正值得看的机会,"
"尤其要检查 DEB、EMOS 分布、模型集群、当前实测、峰值窗口和盘口价格是否一致。"
"若模型集群不支持某个 YES,不要机械推荐 YES;可以 veto 或改为偏向 NO 候选"
"只能引用输入中的 row id。必须输出 JSON object,字段为 recommendations、vetoed、downgraded、summary_zh、summary_en。"
"你是 PolyWeather 的付费 V4-Flash 市场扫描员。你只能基于用户提供的 JSON 快照做判断,"
"不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 EMOS 分布、"
"DEB、模型集群、当前实测和候选合约。你的任务不是复述 edge,而是形成 city thesis"
"再决定哪些合约值得推荐、哪些必须排除、哪些只降级观察"
"重点规则:最高温市场一天只会落入一个桶;如果 DEB/模型集群/EMOS 主峰集中在更高温区,"
"低温 YES 即使有表面 edge 也通常应 veto,优先考虑相邻尾部 NO;若价格过期、盘口太宽或窗口不支持,也要降级。"
"只能引用输入中的 row id。必须输出 JSON object。"
)
model_snapshot = dict(ai_input)
model_snapshot.pop("_polyweather_input_meta", None)
user_payload = {
"task": (
"Review these existing PolyWeather market candidates. "
"Return strict JSON only. recommendations items need row_id, rank, decision, confidence, "
"reason_zh, reason_en, model_cluster_note. vetoed/downgraded items need row_id and reason."
"Analyze each city first, then contract decisions. Return strict JSON only with: "
"summary_zh, summary_en, city_theses, recommendations, vetoed, downgraded, watchlist. "
"city_theses items need city, thesis_zh, thesis_en, model_cluster_note, confidence, "
"recommended_row_ids, vetoed_row_ids. recommendations items need row_id, rank, decision, "
"confidence, reason_zh, reason_en, model_cluster_note. vetoed/downgraded items need row_id, "
"reason_zh/reason_en. watchlist items are optional and need row_id plus reason."
),
"snapshot": ai_input,
"snapshot": model_snapshot,
}
with httpx.Client(timeout=float(SCAN_AI_TIMEOUT_SEC)) as client:
response = client.post(
@@ -460,6 +545,20 @@ def _normalize_ai_items(raw_items: Any) -> List[Dict[str, Any]]:
return out
def _normalize_ai_city_theses(raw_items: Any) -> List[Dict[str, Any]]:
if not isinstance(raw_items, list):
return []
out: List[Dict[str, Any]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
city = str(item.get("city") or item.get("city_name") or "").strip()
if not city:
continue
out.append({**item, "city": city})
return out
def _merge_scan_ai_result(
payload: Dict[str, Any],
ai_raw: Dict[str, Any],
@@ -473,10 +572,30 @@ def _merge_scan_ai_result(
recommendations = _normalize_ai_items(ai_raw.get("recommendations"))
vetoed = _normalize_ai_items(ai_raw.get("vetoed"))
downgraded = _normalize_ai_items(ai_raw.get("downgraded"))
watchlist = _normalize_ai_items(ai_raw.get("watchlist"))
city_theses = _normalize_ai_city_theses(ai_raw.get("city_theses"))
veto_ids = {str(item.get("row_id")) for item in vetoed}
downgrade_ids = {str(item.get("row_id")) for item in downgraded}
recommended_ids: set[str] = set()
watchlist_ids = {str(item.get("row_id")) for item in watchlist}
thesis_by_city: Dict[str, Dict[str, Any]] = {}
for item in city_theses:
key = _normalize_ai_city_key(item.get("city"))
if key:
thesis_by_city[key] = item
for row in rows:
thesis = thesis_by_city.get(_normalize_ai_city_key(row.get("city"))) or thesis_by_city.get(
_normalize_ai_city_key(row.get("city_display_name"))
)
if not thesis:
continue
row["ai_city_thesis_zh"] = thesis.get("thesis_zh") or thesis.get("summary_zh")
row["ai_city_thesis_en"] = thesis.get("thesis_en") or thesis.get("summary_en")
row["ai_city_confidence"] = thesis.get("confidence")
row["ai_city_model_cluster_note"] = thesis.get("model_cluster_note")
for item in vetoed:
row = by_id.get(str(item.get("row_id")))
@@ -492,6 +611,12 @@ def _merge_scan_ai_result(
row["ai_decision"] = "downgrade"
row["ai_reason_zh"] = item.get("reason_zh") or item.get("reason")
row["ai_reason_en"] = item.get("reason_en")
for item in watchlist:
row = by_id.get(str(item.get("row_id")))
if not row:
continue
row["ai_watchlist_reason_zh"] = item.get("reason_zh") or item.get("reason")
row["ai_watchlist_reason_en"] = item.get("reason_en")
for fallback_rank, item in enumerate(recommendations, start=1):
row_id = str(item.get("row_id"))
row = by_id.get(row_id)
@@ -511,6 +636,8 @@ def _merge_scan_ai_result(
row_id = str(row.get("id"))
if row_id not in recommended_ids and row_id not in veto_ids and row_id not in downgrade_ids:
row["ai_decision"] = row.get("ai_decision") or "neutral"
if row_id in watchlist_ids and row.get("ai_decision") == "neutral":
row["ai_decision"] = "watchlist"
def _ai_sort_key(row: Dict[str, Any]) -> tuple:
decision = str(row.get("ai_decision") or "").lower()
@@ -534,6 +661,9 @@ def _merge_scan_ai_result(
(row for row in rows if str(row.get("ai_decision") or "").lower() != "veto"),
rows[0] if rows else None,
)
input_meta = ai_raw.get("_polyweather_input_meta")
sent_cities = input_meta.get("sent_cities") if isinstance(input_meta, dict) else None
sent_contracts = input_meta.get("sent_contracts") if isinstance(input_meta, dict) else None
ai_scan = {
"status": "ready",
"stage": "completed",
@@ -542,7 +672,9 @@ def _merge_scan_ai_result(
"generated_at": datetime.utcnow().isoformat() + "Z",
"snapshot_id": payload.get("snapshot_id"),
"input_rows": input_rows if input_rows is not None else len(payload.get("rows") or []),
"sent_rows": min(len(payload.get("rows") or []), SCAN_AI_MAX_ROWS),
"sent_rows": sent_contracts if sent_contracts is not None else min(len(payload.get("rows") or []), SCAN_AI_MAX_ROWS),
"sent_cities": sent_cities,
"sent_contracts": sent_contracts,
"duration_ms": duration_ms,
"timeout_sec": SCAN_AI_TIMEOUT_SEC,
"cache_ttl_sec": SCAN_AI_CACHE_TTL_SEC,
@@ -550,9 +682,12 @@ def _merge_scan_ai_result(
"base_url": SCAN_AI_BASE_URL,
"summary_zh": ai_raw.get("summary_zh"),
"summary_en": ai_raw.get("summary_en"),
"city_theses": city_theses,
"watchlist": watchlist,
"recommended_count": sum(1 for row in rows if row.get("ai_rank") is not None),
"vetoed_count": sum(1 for row in rows if row.get("ai_decision") == "veto"),
"downgraded_count": sum(1 for row in rows if row.get("ai_decision") == "downgrade"),
"watchlist_count": sum(1 for row in rows if row.get("ai_decision") == "watchlist"),
}
meta = ai_raw.get("_polyweather_meta")
if isinstance(meta, dict):
@@ -676,6 +811,8 @@ def _build_terminal_row(
"risk_level": ((data.get("risk") or {}).get("level") if isinstance(data.get("risk"), dict) else None),
"distribution_bias": scan.get("distribution_bias"),
"distribution_preview": scan.get("distribution_preview") or row.get("distribution_preview") or [],
"distribution_full": scan.get("distribution_full") or scan.get("distribution_preview") or row.get("distribution_preview") or [],
"model_cluster_sources": daily_entry.get("models") if isinstance(daily_entry.get("models"), dict) else data.get("multi_model"),
"window_phase": row.get("window_phase") or scan.get("window_phase"),
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
"signal_status": scan.get("signal_status"),
@@ -1015,15 +1152,19 @@ def build_scan_terminal_ai_payload(
try:
ai_input = _build_scan_ai_prompt(payload)
sent_rows = len(ai_input.get("candidates") or [])
input_meta = ai_input.get("_polyweather_input_meta") if isinstance(ai_input, dict) else {}
sent_rows = int((input_meta or {}).get("sent_contracts") or 0)
sent_cities = int((input_meta or {}).get("sent_cities") or 0)
logger.info(
"scan terminal AI review start snapshot={} rows={} sent_rows={} model={}",
"scan terminal AI review start snapshot={} rows={} sent_cities={} sent_contracts={} model={}",
current_snapshot_id,
len(payload.get("rows") or []),
sent_cities,
sent_rows,
SCAN_AI_MODEL,
)
ai_raw = _call_deepseek_scan_ai(ai_input)
ai_raw["_polyweather_input_meta"] = input_meta
_set_cached_scan_ai_result(current_snapshot_id, filters, ai_raw)
duration_ms = int((time.time() - ai_started_at) * 1000)
logger.info(