From df6a2c73ed1ac0d7a39c70c3fa74455a65ce5b27 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 25 Apr 2026 01:09:08 +0800 Subject: [PATCH] Add V4 scan run logs --- .../components/dashboard/Dashboard.module.css | 145 ++++++++++++++++ .../dashboard/ScanTerminalDashboard.tsx | 164 +++++++++++++++++- frontend/lib/dashboard-types.ts | 17 ++ web/scan_terminal_service.py | 88 +++++++++- 4 files changed, 407 insertions(+), 7 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 98b1e589..b18aba15 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -10385,6 +10385,110 @@ font-weight: 700; } +.root :global(.scan-ai-log-panel) { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 1px solid rgba(68, 100, 150, 0.18); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(9, 23, 38, 0.92), rgba(7, 17, 30, 0.92)), + radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%); +} + +.root :global(.scan-ai-log-head) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.root :global(.scan-ai-log-head strong) { + color: #e9f5ff; + font-size: 13px; + font-weight: 900; +} + +.root :global(.scan-ai-log-head span) { + color: #8fa8c7; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-ai-log-list) { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; +} + +.root :global(.scan-ai-log-list::-webkit-scrollbar) { + height: 5px; +} + +.root :global(.scan-ai-log-list::-webkit-scrollbar-thumb) { + background: rgba(88, 125, 180, 0.26); + border-radius: 999px; +} + +.root :global(.scan-ai-log-item) { + display: grid; + grid-template-columns: auto minmax(180px, 1fr); + gap: 8px; + min-width: 260px; + padding: 9px 10px; + border: 1px solid rgba(68, 100, 150, 0.16); + border-radius: 12px; + background: rgba(14, 29, 48, 0.76); +} + +.root :global(.scan-ai-log-item b) { + display: block; + color: #edf7ff; + font-size: 12px; + font-weight: 900; + line-height: 1.25; +} + +.root :global(.scan-ai-log-item small) { + display: block; + margin-top: 3px; + color: #9fb4d2; + font-size: 11px; + font-weight: 700; + line-height: 1.35; +} + +.root :global(.scan-ai-log-time) { + color: #7892b1; + font-size: 11px; + font-weight: 900; + font-variant-numeric: tabular-nums; +} + +.root :global(.scan-ai-log-item.info) { + border-color: rgba(56, 189, 248, 0.22); +} + +.root :global(.scan-ai-log-item.success) { + border-color: rgba(16, 185, 129, 0.32); +} + +.root :global(.scan-ai-log-item.warning) { + border-color: rgba(245, 158, 11, 0.34); +} + +.root :global(.scan-ai-log-item.error) { + border-color: rgba(248, 113, 113, 0.34); +} + +.root :global(.scan-ai-log-empty) { + color: #8fa8c7; + font-size: 12px; + font-weight: 800; +} + .root :global(.scan-detail-panel) { width: auto; min-width: 0; @@ -10992,6 +11096,47 @@ color: #647a98; } +.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-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-cta-ghost), .root :global(.scan-terminal.light .scan-detail-analysis-button) { border-color: rgba(7, 160, 100, 0.24); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 9a515339..922634ad 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -60,6 +60,29 @@ interface FilterState extends ScanTerminalFilters {} type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; +type ScanAiLogTone = "info" | "success" | "warning" | "error"; + +type ScanAiLogEntry = { + id: string; + time: string; + tone: ScanAiLogTone; + title: string; + detail?: string | null; +}; + +function formatLogTime() { + const now = new Date(); + return `${String(now.getHours()).padStart(2, "0")}:${String( + now.getMinutes(), + ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`; +} + +function formatDuration(ms?: number | null) { + if (ms == null || !Number.isFinite(Number(ms))) return "--"; + if (Number(ms) < 1000) return `${Math.round(Number(ms))}ms`; + return `${(Number(ms) / 1000).toFixed(1)}s`; +} + function formatShortDate(value?: string | null, locale = "zh-CN") { const text = String(value || "").trim(); if (!text) return "--"; @@ -245,9 +268,14 @@ function ScanTerminalScreen() { const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [showScanPaywall, setShowScanPaywall] = useState(false); + const [aiLogs, setAiLogs] = useState([]); const [userLocalTime, setUserLocalTime] = useState("--"); const [themeMode, setThemeMode] = useState("dark"); const deferredRows = useDeferredValue(terminalData?.rows || []); + + const prependAiLogs = (entries: ScanAiLogEntry[]) => { + setAiLogs((current) => [...entries, ...current].slice(0, 8)); + }; const timeSortedRows = useMemo( () => sortRowsByUserTime(deferredRows), [deferredRows], @@ -370,6 +398,17 @@ function ScanTerminalScreen() { return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null; }); }); + prependAiLogs([ + { + id: `rule-${Date.now()}`, + time: formatLogTime(), + tone: response.rows.length ? "success" : "warning", + title: isEn ? "Rule scan snapshot ready" : "规则扫描快照已就绪", + detail: isEn + ? `snapshot ${response.snapshot_id || "--"} · ${response.rows.length} visible rows` + : `快照 ${response.snapshot_id || "--"} · 可见候选 ${response.rows.length} 条`, + }, + ]); } catch (fetchError) { setError(String(fetchError)); } finally { @@ -378,14 +417,51 @@ function ScanTerminalScreen() { }; const runAiReview = async () => { - if (!terminalData?.rows.length || aiLoading) return; + if (aiLoading) return; + if (!terminalData?.rows.length) { + prependAiLogs([ + { + id: `no-rows-${Date.now()}`, + time: formatLogTime(), + tone: "warning", + title: isEn ? "V4 not started" : "V4 未启动", + detail: isEn + ? "Run the rule scan first. V4 only reviews existing candidate rows." + : "需要先完成规则扫描。V4 只复核已有候选,不会凭空抓市场。", + }, + ]); + return; + } + const snapshotId = terminalData.snapshot_id || null; + const rowCount = terminalData.rows.length; + prependAiLogs([ + { + id: `queued-${Date.now()}`, + time: formatLogTime(), + tone: "info", + title: isEn ? "V4 review queued" : "V4 复核已排队", + detail: isEn + ? `snapshot ${snapshotId || "--"} · ${rowCount} rule candidates` + : `快照 ${snapshotId || "--"} · 规则候选 ${rowCount} 条`, + }, + { + id: `send-${Date.now() + 1}`, + time: formatLogTime(), + tone: "info", + title: isEn ? "Sending compact candidate set" : "正在发送精简候选集", + detail: isEn + ? "VPS will call DeepSeek; Vercel only proxies the request." + : "由 VPS 调用 DeepSeek,Vercel 只做短代理。", + }, + ]); setAiLoading(true); setAiError(null); try { const response = await dashboardClient.reviewScanTerminalWithAi({ filters: terminalData.filters || activeFilters, - snapshotId: terminalData.snapshot_id || null, + snapshotId, }); + const review = response.ai_scan; startTransition(() => { setTerminalData(response); setActiveFilters(response.filters || activeFilters); @@ -397,8 +473,55 @@ function ScanTerminalScreen() { return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null; }); }); + prependAiLogs([ + { + id: `done-${Date.now()}`, + time: formatLogTime(), + tone: review?.status === "ready" ? "success" : "warning", + title: + review?.status === "ready" + ? review.cached + ? isEn + ? "V4 cache hit" + : "V4 命中缓存" + : isEn + ? "V4 review completed" + : "V4 复核完成" + : isEn + ? "V4 fell back to rule scan" + : "V4 已回退规则扫描", + 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?.reason || (isEn ? "No AI result returned." : "没有返回 AI 结果。"), + }, + ...(review?.usage + ? [ + { + id: `usage-${Date.now() + 1}`, + time: formatLogTime(), + tone: "info" as const, + title: isEn ? "Token usage" : "Token 用量", + detail: isEn + ? `input ${review.usage.prompt_tokens ?? "--"} · output ${review.usage.completion_tokens ?? "--"} · total ${review.usage.total_tokens ?? "--"}` + : `输入 ${review.usage.prompt_tokens ?? "--"} · 输出 ${review.usage.completion_tokens ?? "--"} · 总计 ${review.usage.total_tokens ?? "--"}`, + }, + ] + : []), + ]); } catch (reviewError) { setAiError(String(reviewError)); + prependAiLogs([ + { + id: `error-${Date.now()}`, + time: formatLogTime(), + tone: "error", + title: isEn ? "V4 request failed" : "V4 请求失败", + detail: String(reviewError), + }, + ]); } finally { setAiLoading(false); } @@ -746,6 +869,43 @@ function ScanTerminalScreen() { ) : null} + {isPro ? ( +
+
+ {isEn ? "V4 run log" : "V4 运行日志"} + + {aiLoading + ? isEn + ? "Waiting for DeepSeek response" + : "正在等待 DeepSeek 返回" + : aiScan?.status + ? `${aiScan.model || "V4"} · ${aiScan.status}` + : isEn + ? "No V4 request yet" + : "还没有发起 V4 请求"} + +
+
+ {aiLogs.length ? ( + aiLogs.map((entry) => ( +
+ {entry.time} +
+ {entry.title} + {entry.detail ? {entry.detail} : null} +
+
+ )) + ) : ( +
+ {isEn + ? "After a rule scan, click AI Deep Scan to see request and result logs here." + : "规则扫描完成后点击 AI 深度扫描,这里会显示请求和返回日志。"} +
+ )} +
+
+ ) : null} {scanStatus === "failed" && !terminalData ? (
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index d6e6e4c9..f06bbff4 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -547,9 +547,26 @@ export interface PrimarySignal extends ScanOpportunityRow {} export interface ScanAiReview { status?: "ready" | "disabled" | "missing_key" | "failed" | "no_rows" | "no_snapshot" | "snapshot_mismatch" | string; + stage?: "completed" | "fallback" | string | null; model?: string | null; cached?: boolean; generated_at?: string | null; + snapshot_id?: string | null; + input_rows?: number | null; + sent_rows?: number | null; + duration_ms?: number | null; + timeout_sec?: number | null; + cache_ttl_sec?: number | null; + provider?: string | null; + base_url?: string | null; + finish_reason?: string | null; + usage?: { + prompt_tokens?: number | null; + completion_tokens?: number | null; + total_tokens?: number | null; + prompt_cache_hit_tokens?: number | null; + prompt_cache_miss_tokens?: number | null; + } | null; reason?: string | null; summary_zh?: string | null; summary_en?: string | null; diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 7ac9e824..196092ea 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -437,7 +437,13 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: if isinstance(data, dict) else None ) - return _extract_ai_json_object(str(content or "")) + parsed = _extract_ai_json_object(str(content or "")) + if isinstance(data, dict): + parsed["_polyweather_meta"] = { + "usage": data.get("usage"), + "finish_reason": ((data.get("choices") or [{}])[0] or {}).get("finish_reason"), + } + return parsed def _normalize_ai_items(raw_items: Any) -> List[Dict[str, Any]]: @@ -454,7 +460,14 @@ def _normalize_ai_items(raw_items: Any) -> List[Dict[str, Any]]: return out -def _merge_scan_ai_result(payload: Dict[str, Any], ai_raw: Dict[str, Any], *, cached: bool = False) -> Dict[str, Any]: +def _merge_scan_ai_result( + payload: Dict[str, Any], + ai_raw: Dict[str, Any], + *, + cached: bool = False, + duration_ms: Optional[int] = None, + input_rows: Optional[int] = None, +) -> Dict[str, Any]: rows = [dict(row) for row in (payload.get("rows") or []) if isinstance(row, dict)] by_id = {str(row.get("id")): row for row in rows if row.get("id")} recommendations = _normalize_ai_items(ai_raw.get("recommendations")) @@ -523,15 +536,28 @@ def _merge_scan_ai_result(payload: Dict[str, Any], ai_raw: Dict[str, Any], *, ca ) ai_scan = { "status": "ready", + "stage": "completed", "model": SCAN_AI_MODEL, "cached": cached, "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), + "duration_ms": duration_ms, + "timeout_sec": SCAN_AI_TIMEOUT_SEC, + "cache_ttl_sec": SCAN_AI_CACHE_TTL_SEC, + "provider": "deepseek", + "base_url": SCAN_AI_BASE_URL, "summary_zh": ai_raw.get("summary_zh"), "summary_en": ai_raw.get("summary_en"), "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"), } + meta = ai_raw.get("_polyweather_meta") + if isinstance(meta, dict): + ai_scan["usage"] = meta.get("usage") + ai_scan["finish_reason"] = meta.get("finish_reason") merged = { **payload, "rows": rows, @@ -546,14 +572,24 @@ def _build_scan_ai_unavailable_payload( *, status: str, reason: str, + duration_ms: Optional[int] = None, ) -> Dict[str, Any]: return { **payload, "ai_scan": { "status": status, + "stage": "fallback", "model": SCAN_AI_MODEL, "cached": False, "generated_at": datetime.utcnow().isoformat() + "Z", + "snapshot_id": payload.get("snapshot_id"), + "input_rows": len(payload.get("rows") or []), + "sent_rows": min(len(payload.get("rows") or []), SCAN_AI_MAX_ROWS), + "duration_ms": duration_ms, + "timeout_sec": SCAN_AI_TIMEOUT_SEC, + "cache_ttl_sec": SCAN_AI_CACHE_TTL_SEC, + "provider": "deepseek", + "base_url": SCAN_AI_BASE_URL, "reason": reason, }, } @@ -926,6 +962,7 @@ def build_scan_terminal_ai_payload( *, snapshot_id: Optional[str] = None, ) -> Dict[str, Any]: + ai_started_at = time.time() filters = _normalize_scan_terminal_filters(raw_filters) payload = build_scan_terminal_payload(filters, force_refresh=False) current_snapshot_id = str(payload.get("snapshot_id") or "").strip() @@ -963,17 +1000,58 @@ def build_scan_terminal_ai_payload( cached = _get_cached_scan_ai_result(current_snapshot_id, filters) if cached is not None: - return _merge_scan_ai_result(payload, cached, cached=True) + logger.info( + "scan terminal AI cache hit snapshot={} rows={}", + current_snapshot_id, + len(payload.get("rows") or []), + ) + return _merge_scan_ai_result( + payload, + cached, + cached=True, + duration_ms=0, + input_rows=len(payload.get("rows") or []), + ) try: ai_input = _build_scan_ai_prompt(payload) + sent_rows = len(ai_input.get("candidates") or []) + logger.info( + "scan terminal AI review start snapshot={} rows={} sent_rows={} model={}", + current_snapshot_id, + len(payload.get("rows") or []), + sent_rows, + SCAN_AI_MODEL, + ) ai_raw = _call_deepseek_scan_ai(ai_input) _set_cached_scan_ai_result(current_snapshot_id, filters, ai_raw) - return _merge_scan_ai_result(payload, ai_raw, cached=False) + duration_ms = int((time.time() - ai_started_at) * 1000) + logger.info( + "scan terminal AI review complete snapshot={} duration_ms={} recommendations={} vetoed={} downgraded={}", + current_snapshot_id, + duration_ms, + len(_normalize_ai_items(ai_raw.get("recommendations"))), + len(_normalize_ai_items(ai_raw.get("vetoed"))), + len(_normalize_ai_items(ai_raw.get("downgraded"))), + ) + return _merge_scan_ai_result( + payload, + ai_raw, + cached=False, + duration_ms=duration_ms, + input_rows=len(payload.get("rows") or []), + ) except Exception as exc: - logger.warning("scan terminal AI review failed: {}", exc) + duration_ms = int((time.time() - ai_started_at) * 1000) + logger.warning( + "scan terminal AI review failed snapshot={} duration_ms={} error={}", + current_snapshot_id, + duration_ms, + exc, + ) return _build_scan_ai_unavailable_payload( payload, status="failed", reason=str(exc), + duration_ms=duration_ms, )