Add V4 scan run logs
This commit is contained in:
@@ -10385,6 +10385,110 @@
|
|||||||
font-weight: 700;
|
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) {
|
.root :global(.scan-detail-panel) {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -10992,6 +11096,47 @@
|
|||||||
color: #647a98;
|
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-cta-ghost),
|
||||||
.root :global(.scan-terminal.light .scan-detail-analysis-button) {
|
.root :global(.scan-terminal.light .scan-detail-analysis-button) {
|
||||||
border-color: rgba(7, 160, 100, 0.24);
|
border-color: rgba(7, 160, 100, 0.24);
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ interface FilterState extends ScanTerminalFilters {}
|
|||||||
|
|
||||||
type ContentView = "list" | "map" | "calendar";
|
type ContentView = "list" | "map" | "calendar";
|
||||||
type ThemeMode = "dark" | "light";
|
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") {
|
function formatShortDate(value?: string | null, locale = "zh-CN") {
|
||||||
const text = String(value || "").trim();
|
const text = String(value || "").trim();
|
||||||
if (!text) return "--";
|
if (!text) return "--";
|
||||||
@@ -245,9 +268,14 @@ function ScanTerminalScreen() {
|
|||||||
const [activeView, setActiveView] = useState<ContentView>("map");
|
const [activeView, setActiveView] = useState<ContentView>("map");
|
||||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||||
|
const [aiLogs, setAiLogs] = useState<ScanAiLogEntry[]>([]);
|
||||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||||
const deferredRows = useDeferredValue(terminalData?.rows || []);
|
const deferredRows = useDeferredValue(terminalData?.rows || []);
|
||||||
|
|
||||||
|
const prependAiLogs = (entries: ScanAiLogEntry[]) => {
|
||||||
|
setAiLogs((current) => [...entries, ...current].slice(0, 8));
|
||||||
|
};
|
||||||
const timeSortedRows = useMemo(
|
const timeSortedRows = useMemo(
|
||||||
() => sortRowsByUserTime(deferredRows),
|
() => sortRowsByUserTime(deferredRows),
|
||||||
[deferredRows],
|
[deferredRows],
|
||||||
@@ -370,6 +398,17 @@ function ScanTerminalScreen() {
|
|||||||
return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null;
|
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) {
|
} catch (fetchError) {
|
||||||
setError(String(fetchError));
|
setError(String(fetchError));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -378,14 +417,51 @@ function ScanTerminalScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runAiReview = async () => {
|
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);
|
setAiLoading(true);
|
||||||
setAiError(null);
|
setAiError(null);
|
||||||
try {
|
try {
|
||||||
const response = await dashboardClient.reviewScanTerminalWithAi({
|
const response = await dashboardClient.reviewScanTerminalWithAi({
|
||||||
filters: terminalData.filters || activeFilters,
|
filters: terminalData.filters || activeFilters,
|
||||||
snapshotId: terminalData.snapshot_id || null,
|
snapshotId,
|
||||||
});
|
});
|
||||||
|
const review = response.ai_scan;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setTerminalData(response);
|
setTerminalData(response);
|
||||||
setActiveFilters(response.filters || activeFilters);
|
setActiveFilters(response.filters || activeFilters);
|
||||||
@@ -397,8 +473,55 @@ function ScanTerminalScreen() {
|
|||||||
return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null;
|
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) {
|
} catch (reviewError) {
|
||||||
setAiError(String(reviewError));
|
setAiError(String(reviewError));
|
||||||
|
prependAiLogs([
|
||||||
|
{
|
||||||
|
id: `error-${Date.now()}`,
|
||||||
|
time: formatLogTime(),
|
||||||
|
tone: "error",
|
||||||
|
title: isEn ? "V4 request failed" : "V4 请求失败",
|
||||||
|
detail: String(reviewError),
|
||||||
|
},
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(false);
|
setAiLoading(false);
|
||||||
}
|
}
|
||||||
@@ -746,6 +869,43 @@ function ScanTerminalScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{scanStatus === "failed" && !terminalData ? (
|
||||||
<div className="scan-empty-state">
|
<div className="scan-empty-state">
|
||||||
|
|||||||
@@ -547,9 +547,26 @@ export interface PrimarySignal extends ScanOpportunityRow {}
|
|||||||
|
|
||||||
export interface ScanAiReview {
|
export interface ScanAiReview {
|
||||||
status?: "ready" | "disabled" | "missing_key" | "failed" | "no_rows" | "no_snapshot" | "snapshot_mismatch" | string;
|
status?: "ready" | "disabled" | "missing_key" | "failed" | "no_rows" | "no_snapshot" | "snapshot_mismatch" | string;
|
||||||
|
stage?: "completed" | "fallback" | string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
cached?: boolean;
|
cached?: boolean;
|
||||||
generated_at?: string | null;
|
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;
|
reason?: string | null;
|
||||||
summary_zh?: string | null;
|
summary_zh?: string | null;
|
||||||
summary_en?: string | null;
|
summary_en?: string | null;
|
||||||
|
|||||||
@@ -437,7 +437,13 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
if isinstance(data, dict)
|
if isinstance(data, dict)
|
||||||
else None
|
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]]:
|
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
|
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)]
|
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")}
|
by_id = {str(row.get("id")): row for row in rows if row.get("id")}
|
||||||
recommendations = _normalize_ai_items(ai_raw.get("recommendations"))
|
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 = {
|
ai_scan = {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
|
"stage": "completed",
|
||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"cached": cached,
|
"cached": cached,
|
||||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
"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_zh": ai_raw.get("summary_zh"),
|
||||||
"summary_en": ai_raw.get("summary_en"),
|
"summary_en": ai_raw.get("summary_en"),
|
||||||
"recommended_count": sum(1 for row in rows if row.get("ai_rank") is not None),
|
"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"),
|
"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"),
|
"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 = {
|
merged = {
|
||||||
**payload,
|
**payload,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
@@ -546,14 +572,24 @@ def _build_scan_ai_unavailable_payload(
|
|||||||
*,
|
*,
|
||||||
status: str,
|
status: str,
|
||||||
reason: str,
|
reason: str,
|
||||||
|
duration_ms: Optional[int] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
**payload,
|
**payload,
|
||||||
"ai_scan": {
|
"ai_scan": {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"stage": "fallback",
|
||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"cached": False,
|
"cached": False,
|
||||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
"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,
|
"reason": reason,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -926,6 +962,7 @@ def build_scan_terminal_ai_payload(
|
|||||||
*,
|
*,
|
||||||
snapshot_id: Optional[str] = None,
|
snapshot_id: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
ai_started_at = time.time()
|
||||||
filters = _normalize_scan_terminal_filters(raw_filters)
|
filters = _normalize_scan_terminal_filters(raw_filters)
|
||||||
payload = build_scan_terminal_payload(filters, force_refresh=False)
|
payload = build_scan_terminal_payload(filters, force_refresh=False)
|
||||||
current_snapshot_id = str(payload.get("snapshot_id") or "").strip()
|
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)
|
cached = _get_cached_scan_ai_result(current_snapshot_id, filters)
|
||||||
if cached is not None:
|
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:
|
try:
|
||||||
ai_input = _build_scan_ai_prompt(payload)
|
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)
|
ai_raw = _call_deepseek_scan_ai(ai_input)
|
||||||
_set_cached_scan_ai_result(current_snapshot_id, filters, ai_raw)
|
_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:
|
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(
|
return _build_scan_ai_unavailable_payload(
|
||||||
payload,
|
payload,
|
||||||
status="failed",
|
status="failed",
|
||||||
reason=str(exc),
|
reason=str(exc),
|
||||||
|
duration_ms=duration_ms,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user