Files
Neo-ZQYY/apps/admin-web/src/pages/AIOperations.tsx
Neo 421e193041 fix(ai): F1-5a 沙箱 batch-run 接入 runtime_context (W1 / 阶段 A 主体)
Neo F1-5 反馈: "让沙箱起到其真正的作用. 真正的模拟日期, 仅能看到沙箱设定日期
及之前日期的数据, 并运行 AI 的各个业务."

调研发现 (4 个并行子代理): batch-run 端点 _run_batch 是空壳 stub
(只 logger.info, 实际不跑 AI), GUC apply_runtime_session_vars 0 处调用
(dead code), 7 张业务表 6 张有 runtime 复合索引唯独 ai_run_logs 漏建,
App2/2a 3 行 _calc_date_range 漏传 ref_date.

本 commit (F1-5a 阶段 A 主体, F1-5b 后续完整 zqyy_app RLS 视图层):

后端核心:
- admin_service.py: _run_batch 真实化 (Semaphore(5)+asyncio.gather+
  return_exceptions=True+ctx_snapshot 防漂移); estimate 入口抓
  RuntimeContext 快照, confirm 取出传给 worker
- admin_ai.py: confirm_batch_run lazy 注入 dispatcher
- admin_service.retry_trigger_job: INSERT 落 runtime_mode +
  sandbox_instance_id 列 (用 runtime_insert_columns helper)
- runtime_context.py: get_runtime_context 加 bind_to_session 参数,
  激活 GUC app.current_business_date / app.current_runtime_mode
- run_log_service.create_log: 启用 bind_to_session=True 试点

App2/2a 3 行 ref_date 修复:
- app2_finance_prompt.py:817 储值卡余额变化板块
- app2_finance_prompt.py:841 日粒度 series + 异常检测窗口
- app2a_finance_area_prompt.py:466 区域日粒度 series

DB:
- migrations/20260505__ai_run_logs_runtime_index.sql:
  补 (site_id, runtime_mode, sandbox_instance_id, created_at DESC) 复合索引

前端:
- AIOperations.tsx: 顶部加 sandbox 模式提示条 (Alert 显示 sandbox_date +
  sandbox_instance_id + 影响范围 + 切回 live 入口)

未做 (留 F1-5b 完整 zqyy_app RLS 视图层一并):
- B1 admin_service 6 处 CURRENT_DATE -> business_date
- B2 fdw_queries 异常分支兜底
- GUC 完整传递 (fdw_queries / page_context 等)
- 测试 3 套 (.gitignore:71 排除, F2-2 入仓时 commit)
- P20 SPEC \xa76/\xa710/\xa711/\xa715 (F1-5b 完整收口后同步更准确)

Neo 决策: docs/_overview/wave1-findings/F1-5-impl-decisions.md

详见 docs/audit/changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md
2026-05-05 03:01:48 +08:00

531 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AI 手动操作页面。
*
* 4 个 Card 区域:
* - Card 1手动重跑App + member_id + site_id → 执行)
* - Card 2缓存失效app_type + member_id + site_id → 失效)
* - Card 3批量执行app_types + member_ids + site_id → 预估 → 确认)
* - Card 4告警管理告警列表 + 确认/忽略)
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Alert, Card, Row, Col, Select, Input, Button, Table, Tag, Space,
Checkbox, Modal, Statistic, message, Typography,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
getAlerts, ackAlert, ignoreAlert, runApp, triggerEvent,
type AlertItem, type AppType, type BatchRunEstimate,
} from "../api/adminAI";
// F1-5a: sandbox 模式提示条数据源
import { fetchRuntimeContext, type RuntimeContext } from "../api/runtimeContext";
const EVENT_TYPE_OPTIONS = [
{ label: "消费事件App3→App8→App7 [+ App4→App5]", value: "consumption" },
{ label: "备注事件App6→App8", value: "note_created" },
{ label: "任务分配App4→App5", value: "task_assigned" },
{ label: "DWS 完成App2 × 72 组合预热)", value: "dws_completed" },
];
const { TextArea } = Input;
const { Title } = Typography;
export const CACHE_TYPE_OPTIONS = [
{ label: "App3 维客线索", value: "app3_clue" },
{ label: "App4 关系分析", value: "app4_analysis" },
{ label: "App5 话术参考", value: "app5_tactics" },
{ label: "App6 备注分析", value: "app6_note_analysis" },
{ label: "App7 客户分析", value: "app7_customer_analysis" },
{ label: "App8 线索整理", value: "app8_clue_consolidated" },
];
export const RUN_APP_TYPE_OPTIONS: { label: string; value: AppType }[] = [
{ label: "App3 维客线索", value: "app3_clue" },
{ label: "App4 关系分析", value: "app4_analysis" },
{ label: "App5 话术参考", value: "app5_tactics" },
{ label: "App6 备注分析", value: "app6_note" },
{ label: "App7 客户分析", value: "app7_customer" },
{ label: "App8 线索整理", value: "app8_consolidation" },
];
const ALERT_STATUS_COLOR: Record<string, string> = {
failed: "red", timeout: "orange", circuit_open: "volcano",
};
const ALERT_MGMT_COLOR: Record<string, string> = {
pending: "warning", acknowledged: "success", ignored: "default",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
const AIOperations: React.FC = () => {
// ---- Card 1: 手动重跑 ----
const [retryJobId, setRetryJobId] = useState<string>("");
const [retryLoading, setRetryLoading] = useState(false);
const handleRetry = async () => {
const id = Number(retryJobId);
if (!id || Number.isNaN(id)) { message.warning("请输入有效的任务 ID"); return; }
setRetryLoading(true);
try {
const res = await retryTriggerJob(id);
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
setRetryJobId("");
} catch {
message.error("重跑失败");
} finally {
setRetryLoading(false);
}
};
// ---- Card 2: 缓存失效 ----
const [cacheAppType, setCacheAppType] = useState<string | undefined>();
const [cacheMemberId, setCacheMemberId] = useState<string>("");
const [cacheSiteId, setCacheSiteId] = useState<number>(2790685415443269);
const [cacheLoading, setCacheLoading] = useState(false);
const [cacheResult, setCacheResult] = useState<number | null>(null);
const handleInvalidate = async () => {
setCacheLoading(true);
setCacheResult(null);
try {
const res = await invalidateCache({
site_id: cacheSiteId,
app_type: cacheAppType,
member_id: cacheMemberId ? Number(cacheMemberId) : undefined,
});
setCacheResult(res.affected_count);
message.success(`已失效 ${res.affected_count} 条缓存`);
} catch {
message.error("缓存失效操作失败");
} finally {
setCacheLoading(false);
}
};
// ---- Card 2.5: 按需重新生成 ----
const [runAppType, setRunAppType] = useState<AppType | undefined>();
const [runMemberId, setRunMemberId] = useState<string>("");
const [runSiteId, setRunSiteId] = useState<number>(2790685415443269);
const [runLoading, setRunLoading] = useState(false);
const [runResult, setRunResult] = useState<{ success: boolean; text: string } | null>(null);
const handleRunApp = async () => {
if (!runAppType) { message.warning("请选择 App 类型"); return; }
setRunLoading(true);
setRunResult(null);
try {
const res = await runApp(runAppType, {
site_id: runSiteId,
member_id: runMemberId ? Number(runMemberId) : undefined,
});
if (res.success) {
setRunResult({ success: true, text: "执行成功,缓存已更新" });
message.success("执行成功");
} else {
setRunResult({ success: false, text: res.error ?? "执行失败" });
message.error(res.error ?? "执行失败");
}
} catch {
message.error("请求失败");
} finally {
setRunLoading(false);
}
};
// ---- Card 2.6: 手动触发事件链(越过去重)----
const [evtType, setEvtType] = useState<string>("consumption");
const [evtSiteId, setEvtSiteId] = useState<number>(2790685415443269);
const [evtMemberId, setEvtMemberId] = useState<string>("");
const [evtAssistantId, setEvtAssistantId] = useState<string>("");
const [evtForced, setEvtForced] = useState<boolean>(true);
const [evtLoading, setEvtLoading] = useState(false);
const [evtResult, setEvtResult] = useState<number | null>(null);
const handleTriggerEvent = async () => {
if (!evtType) { message.warning("请选择事件类型"); return; }
setEvtLoading(true);
setEvtResult(null);
try {
const res = await triggerEvent({
event_type: evtType,
site_id: evtSiteId,
member_id: evtMemberId ? Number(evtMemberId) : undefined,
assistant_id: evtAssistantId ? Number(evtAssistantId) : undefined,
is_forced: evtForced,
});
setEvtResult(res.trigger_job_id);
message.success(`事件已触发job_id=${res.trigger_job_id}(后台异步执行)`);
} catch {
message.error("触发失败");
} finally {
setEvtLoading(false);
}
};
// ---- Card 3: 批量执行 ----
const [batchAppTypes, setBatchAppTypes] = useState<AppType[]>([]);
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
const [batchSiteId, setBatchSiteId] = useState<number>(2790685415443269);
const [batchLoading, setBatchLoading] = useState(false);
const [batchEstimate, setBatchEstimate] = useState<BatchRunEstimate | null>(null);
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const parseMemberIds = (text: string): number[] =>
text.split(/[\n,;\s]+/).map(Number).filter((n) => !Number.isNaN(n) && n > 0);
const handleBatchEstimate = async () => {
const memberIds = parseMemberIds(batchMemberIds);
if (batchAppTypes.length === 0) { message.warning("请选择至少一个 App"); return; }
if (memberIds.length === 0) { message.warning("请输入有效的会员 ID"); return; }
setBatchLoading(true);
try {
const res = await createBatchRun({ app_types: batchAppTypes, member_ids: memberIds, site_id: batchSiteId });
setBatchEstimate(res);
setConfirmVisible(true);
} catch {
message.error("预估失败");
} finally {
setBatchLoading(false);
}
};
const handleBatchConfirm = async () => {
if (!batchEstimate) return;
setConfirmLoading(true);
try {
await confirmBatchRun(batchEstimate.batch_id);
message.success("批量执行已启动");
setConfirmVisible(false);
setBatchEstimate(null);
setBatchAppTypes([]);
setBatchMemberIds("");
} catch {
message.error("确认执行失败");
} finally {
setConfirmLoading(false);
}
};
// ---- F1-5a: Sandbox 模式提示条 ----
// 沙箱机制 P0-7 主线:让运维进入 AI 操作页前能看到当前 sandbox 状态,
// 避免"以为 live 模式"误触发批量执行实际跑在 sandbox 数据集上的混淆。
const [runtimeCtx, setRuntimeCtx] = useState<RuntimeContext | null>(null);
useEffect(() => {
// 复用 cacheSiteId 作为当前关注 site(默认 2790685415443269,与 cacheSiteId / runSiteId 一致)
let cancelled = false;
(async () => {
try {
const ctx = await fetchRuntimeContext(cacheSiteId);
if (!cancelled) setRuntimeCtx(ctx);
} catch {
// 失败不阻断页面渲染(get_runtime_context 表不存在时后端降级 live)
}
})();
return () => { cancelled = true; };
}, [cacheSiteId]);
// ---- Card 4: 告警管理 ----
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [alertTotal, setAlertTotal] = useState(0);
const [alertLoading, setAlertLoading] = useState(false);
const [alertPage, setAlertPage] = useState(1);
const loadAlerts = useCallback(async () => {
setAlertLoading(true);
try {
const res = await getAlerts({ page: alertPage, page_size: 10 });
setAlerts(res.items);
setAlertTotal(res.total);
} catch {
message.error("加载告警列表失败");
} finally {
setAlertLoading(false);
}
}, [alertPage]);
useEffect(() => { loadAlerts(); }, [loadAlerts]);
const handleAck = async (id: number) => {
try {
await ackAlert(id);
message.success("已确认告警");
loadAlerts();
} catch {
message.error("确认失败");
}
};
const handleIgnore = async (id: number) => {
try {
await ignoreAlert(id);
message.success("已忽略告警");
loadAlerts();
} catch {
message.error("忽略失败");
}
};
const alertColumns: ColumnsType<AlertItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
{ title: "App", dataIndex: "app_type", key: "app_type", width: 150 },
{
title: "状态", dataIndex: "status", key: "status", width: 100,
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 100,
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
},
{ title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true, render: (v) => v ?? "—" },
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 160, render: fmtTime },
{
title: "操作", key: "action", width: 140,
render: (_: unknown, r: AlertItem) => (
<Space>
<Button size="small" onClick={() => handleAck(r.id)} disabled={r.alert_status === "acknowledged"}></Button>
<Button size="small" onClick={() => handleIgnore(r.id)} disabled={r.alert_status === "ignored"}></Button>
</Space>
),
},
];
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>AI </Title>
{runtimeCtx && runtimeCtx.is_sandbox && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={
<span>
<strong></strong> · <strong>{runtimeCtx.sandbox_date ?? "—"}</strong> ·
<code>{runtimeCtx.sandbox_instance_id ?? "—"}</code>
</span>
}
description={
<span>
site_id={cacheSiteId} AI ( / / / )使
({runtimeCtx.sandbox_date}) ;ETL ,/
ai_run_logs runtime_mode=sandbox + sandbox_instance_id ,
live live, <a href="/settings/runtime-context"></a>
</span>
}
/>
)}
<Row gutter={16} style={{ marginBottom: 16 }}>
{/* Card 1: 手动重跑 */}
<Col span={12}>
<Card title="手动重跑" size="small">
<Space direction="vertical" style={{ width: "100%" }}>
<Input
placeholder="调度任务 ID" value={retryJobId}
onChange={(e) => setRetryJobId(e.target.value)}
/>
<Button type="primary" onClick={handleRetry} loading={retryLoading}></Button>
</Space>
</Card>
</Col>
{/* Card 2: 缓存失效 */}
<Col span={12}>
<Card title="缓存失效" size="small">
<Space direction="vertical" style={{ width: "100%" }}>
<Select
allowClear placeholder="App 类型(可选)" style={{ width: "100%" }}
value={cacheAppType} onChange={setCacheAppType}
options={CACHE_TYPE_OPTIONS}
/>
<Input
placeholder="会员 ID可选" value={cacheMemberId}
onChange={(e) => setCacheMemberId(e.target.value)}
/>
<Select
placeholder="门店" style={{ width: "100%" }}
value={cacheSiteId} onChange={setCacheSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<Space>
<Button type="primary" danger onClick={handleInvalidate} loading={cacheLoading}></Button>
{cacheResult != null && <Statistic title="受影响记录" value={cacheResult} />}
</Space>
</Space>
</Card>
</Col>
</Row>
{/* Card 2.5: 按需重新生成 */}
<Card title="按需重新生成" size="small" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Select
allowClear placeholder="App 类型" style={{ width: "100%" }}
value={runAppType} onChange={setRunAppType}
options={RUN_APP_TYPE_OPTIONS}
/>
</Col>
<Col span={6}>
<Input
placeholder="会员 ID部分 App 必填)" value={runMemberId}
onChange={(e) => setRunMemberId(e.target.value)}
/>
</Col>
<Col span={6}>
<Select
placeholder="门店" style={{ width: "100%" }}
value={runSiteId} onChange={setRunSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
</Col>
<Col span={6}>
<Space>
<Button type="primary" onClick={handleRunApp} loading={runLoading}></Button>
{runResult && (
<Tag color={runResult.success ? "success" : "error"}>{runResult.text}</Tag>
)}
</Space>
</Col>
</Row>
</Card>
{/* Card 2.6: 手动触发事件链(越过去重,调试利器)*/}
<Card
title="手动触发事件链(调试用)" size="small" style={{ marginBottom: 16 }}
extra={<Tag color="orange"></Tag>}
>
<Row gutter={12}>
<Col span={6}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}></div>
<Select
value={evtType} onChange={setEvtType}
options={EVENT_TYPE_OPTIONS}
style={{ width: "100%" }}
/>
</Col>
<Col span={5}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}></div>
<Select
value={evtSiteId} onChange={setEvtSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
style={{ width: "100%" }}
/>
</Col>
<Col span={4}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>member_id</div>
<Input
placeholder="consumption/note/task 事件需填"
value={evtMemberId}
onChange={(e) => setEvtMemberId(e.target.value)}
/>
</Col>
<Col span={4}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>assistant_id</div>
<Input
placeholder="task_assigned 事件需填"
value={evtAssistantId}
onChange={(e) => setEvtAssistantId(e.target.value)}
/>
</Col>
<Col span={5}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}> + </div>
<Space>
<Checkbox checked={evtForced} onChange={(e) => setEvtForced(e.target.checked)}></Checkbox>
<Button type="primary" danger onClick={handleTriggerEvent} loading={evtLoading}></Button>
{evtResult != null && <Tag color="processing">job #{evtResult}</Tag>}
</Space>
</Col>
</Row>
</Card>
{/* Card 3: 批量执行 */}
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}> App</div>
<Checkbox.Group
options={RUN_APP_TYPE_OPTIONS}
value={batchAppTypes}
onChange={(v) => setBatchAppTypes(v as AppType[])}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
/>
</Col>
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}> ID</div>
<TextArea
rows={6} value={batchMemberIds}
onChange={(e) => setBatchMemberIds(e.target.value)}
placeholder="例如:&#10;12345&#10;67890"
/>
</Col>
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Select
style={{ width: "100%", marginBottom: 16 }}
value={batchSiteId} onChange={setBatchSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<Button type="primary" onClick={handleBatchEstimate} loading={batchLoading}>
</Button>
</Col>
</Row>
</Card>
{/* 批量执行确认弹窗 */}
<Modal
title="确认批量执行"
open={confirmVisible}
onCancel={() => { setConfirmVisible(false); setBatchEstimate(null); }}
onOk={handleBatchConfirm}
confirmLoading={confirmLoading}
okText="确认执行" cancelText="取消"
>
{batchEstimate && (
<Row gutter={16}>
<Col span={12}>
<Statistic title="预估调用次数" value={batchEstimate.estimated_calls} suffix="次" />
</Col>
<Col span={12}>
<Statistic title="预估 Token 消耗" value={batchEstimate.estimated_tokens} />
</Col>
</Row>
)}
<p style={{ marginTop: 16, color: "#faad14" }}>
</p>
</Modal>
{/* Card 4: 告警管理 */}
<Card
title="告警管理" size="small"
extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadAlerts}></Button>}
>
<Table<AlertItem>
columns={alertColumns}
dataSource={alerts}
rowKey="id" size="small"
loading={alertLoading}
pagination={{
current: alertPage, pageSize: 10, total: alertTotal,
onChange: (p) => setAlertPage(p),
showTotal: (t) => `${t}`,
}}
/>
</Card>
</div>
);
};
export default AIOperations;