吸收数字员工诊断成本审计

This commit is contained in:
baiyanyun
2026-06-12 10:52:35 +08:00
parent 3a159e2f5a
commit 9011b91dba
9 changed files with 343 additions and 57 deletions

View File

@@ -2896,7 +2896,41 @@ function doctorSeverityForSync(sync: QimingclawSyncStatus | null): DiagResult['s
}
function buildCostSummary(snapshot: QimingclawSnapshot | null): CostSummary {
const requestCount = runtimeEvents(snapshot).length + runtimeRuns(snapshot).length + runtimeTasks(snapshot).length;
const samples = [
...runtimeEvents(snapshot).map((event) => costSampleFromPayload(asRecord(event.payload), event.occurredAt)),
...runtimeRuns(snapshot).map((run) => costSampleFromPayload(asRecord(run.payload), run.finishedAt ?? run.updatedAt ?? run.createdAt)),
].filter((sample): sample is TokenCostSample => Boolean(sample));
const requestCount = samples.length || runtimeEvents(snapshot).length + runtimeRuns(snapshot).length + runtimeTasks(snapshot).length;
if (samples.length > 0) {
const byModel: CostSummary['by_model'] = {};
let totalTokens = 0;
let totalCostUsd = 0;
let estimated = false;
let updatedAt = snapshot?.generatedAt ?? new Date().toISOString();
for (const sample of samples) {
const model = sample.model || 'unknown';
const current = byModel[model] ?? { model, cost_usd: 0, total_tokens: 0, request_count: 0 };
current.cost_usd += sample.estimated_cost_usd ?? 0;
current.total_tokens += sample.total_tokens ?? 0;
current.request_count += 1;
byModel[model] = current;
totalTokens += sample.total_tokens ?? 0;
totalCostUsd += sample.estimated_cost_usd ?? 0;
estimated = estimated || sample.estimated;
if (sample.updated_at && sample.updated_at > updatedAt) updatedAt = sample.updated_at;
}
return {
session_cost_usd: totalCostUsd,
daily_cost_usd: totalCostUsd,
monthly_cost_usd: totalCostUsd,
total_tokens: totalTokens,
request_count: samples.length,
by_model: byModel,
estimated,
source: 'qimingclaw-runtime-projection',
updated_at: updatedAt,
};
}
return {
session_cost_usd: 0,
daily_cost_usd: 0,
@@ -2917,6 +2951,37 @@ function buildCostSummary(snapshot: QimingclawSnapshot | null): CostSummary {
};
}
interface TokenCostSample {
model: string;
input_tokens: number | null;
output_tokens: number | null;
total_tokens: number | null;
estimated_cost_usd: number | null;
estimated: boolean;
updated_at: string | null;
}
function costSampleFromPayload(payload: Record<string, unknown> | null, updatedAt?: string | null): TokenCostSample | null {
if (!payload) return null;
const usage = asRecord(payload.usage) ?? asRecord(payload.token_usage) ?? asRecord(payload.tokenUsage) ?? payload;
const cost = asRecord(payload.cost) ?? payload;
const inputTokens = numberValue(usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens);
const outputTokens = numberValue(usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens);
const totalTokens = numberValue(usage.total_tokens ?? usage.totalTokens) ?? ((inputTokens ?? 0) + (outputTokens ?? 0) || null);
const estimatedCostUsd = numberValue(cost.estimated_cost_usd ?? cost.estimatedCostUsd ?? cost.cost_usd ?? cost.costUsd ?? cost.usd);
const model = stringValue(payload.model ?? payload.model_name ?? payload.modelName ?? usage.model) || 'unknown';
if (inputTokens === null && outputTokens === null && totalTokens === null && estimatedCostUsd === null) return null;
return {
model,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
estimated_cost_usd: estimatedCostUsd,
estimated: typeof payload.estimated === 'boolean' ? payload.estimated : estimatedCostUsd === null,
updated_at: stringValue(payload.updated_at ?? payload.updatedAt) || updatedAt || null,
};
}
function buildAuditEvents(snapshot: QimingclawSnapshot | null, searchParams: URLSearchParams): Array<Record<string, unknown>> {
const limit = Math.max(1, Math.min(Math.floor(numberValue(searchParams.get('limit')) ?? 100), 300));
const category = stringValue(searchParams.get('category')) || stringValue(searchParams.get('kind'));
@@ -2964,6 +3029,9 @@ function buildDiagnosticSummary(snapshot: QimingclawSnapshot | null): DigitalEmp
},
cost: {
request_count: cost.request_count,
total_tokens: cost.total_tokens,
estimated_cost_usd: cost.session_cost_usd,
by_model_count: Object.keys(cost.by_model ?? {}).length,
estimated: Boolean(cost.estimated),
updated_at: cost.updated_at ?? null,
},
@@ -4169,28 +4237,86 @@ function durationMs(startedAt?: string | null, finishedAt?: string | null): numb
function auditEventFromRuntimeEvent(event: QimingclawEventRecord): Record<string, unknown> {
const payload = asRecord(event.payload) ?? {};
const category = event.kind.startsWith('governance_')
? 'governance'
: event.kind === 'route_decision'
? 'route'
: event.kind === 'approval_decision'
? 'approval'
: 'runtime';
const category = auditCategoryForRuntimeEvent(event.kind);
const diagnosticPayload = diagnosticAuditPayload(event.kind, payload);
const safePayload = diagnosticPayload ?? payload;
const entityType = event.runId ? 'run' : event.taskId ? 'task' : event.planId ? 'plan' : 'event';
return {
audit_id: event.id,
category,
action: event.kind,
level: /fail|error|reject|cancel/i.test(`${event.kind} ${event.message}`) ? 'error' : 'info',
level: auditLevelForRuntimeEvent(event, safePayload),
message: event.message || event.kind,
actor: stringValue(payload.actor) || stringValue(payload.source) || 'qimingclaw-runtime',
entity_type: entityType,
entity_id: event.runId ?? event.taskId ?? event.planId ?? event.id,
occurred_at: event.occurredAt,
payload,
...safePayload,
payload: safePayload,
};
}
function auditCategoryForRuntimeEvent(kind: string): string {
if (kind === 'sandbox_audit') return 'sandbox_audit';
if (kind === 'token_cost') return 'token_cost';
if (kind === 'tool_call_audit') return 'tool_call_audit';
if (kind.startsWith('governance_')) return 'governance';
if (kind === 'route_decision') return 'route';
if (kind === 'approval_decision') return 'approval';
return 'runtime';
}
function auditLevelForRuntimeEvent(event: QimingclawEventRecord, payload: Record<string, unknown>): 'info' | 'warn' | 'error' {
const status = stringValue(payload.status).toLowerCase();
const text = `${event.kind} ${event.message} ${status}`;
if (/fail|error|reject|denied|blocked|cancel/i.test(text)) return 'error';
if (event.kind === 'sandbox_audit' || event.kind === 'tool_call_audit') {
if (/warn|timeout|retry/i.test(text)) return 'warn';
}
return 'info';
}
function diagnosticAuditPayload(kind: string, payload: Record<string, unknown>): Record<string, unknown> | null {
if (kind === 'sandbox_audit') {
return {
session_id: stringValue(payload.session_id ?? payload.sessionId) || null,
operation: stringValue(payload.operation ?? payload.action) || null,
status: stringValue(payload.status) || null,
exit_code: numberValue(payload.exit_code ?? payload.exitCode),
duration_ms: numberValue(payload.duration_ms ?? payload.durationMs),
command_preview: stringValue(payload.command_preview ?? payload.commandPreview) || null,
reason_codes: uniqueStrings(arrayValue(payload.reason_codes ?? payload.reasonCodes).map(stringValue)),
};
}
if (kind === 'token_cost') {
const sample = costSampleFromPayload(payload, stringValue(payload.updated_at ?? payload.updatedAt) || null);
return {
session_id: stringValue(payload.session_id ?? payload.sessionId) || null,
model: sample?.model ?? (stringValue(payload.model) || null),
status: stringValue(payload.status) || null,
input_tokens: sample?.input_tokens ?? null,
output_tokens: sample?.output_tokens ?? null,
total_tokens: sample?.total_tokens ?? null,
estimated_cost_usd: sample?.estimated_cost_usd ?? null,
estimated: sample?.estimated ?? true,
};
}
if (kind === 'tool_call_audit') {
return {
session_id: stringValue(payload.session_id ?? payload.sessionId) || null,
server_id: stringValue(payload.server_id ?? payload.serverId) || null,
tool_name: stringValue(payload.tool_name ?? payload.toolName) || null,
operation: stringValue(payload.operation ?? payload.action) || null,
status: stringValue(payload.status) || null,
exit_code: numberValue(payload.exit_code ?? payload.exitCode),
duration_ms: numberValue(payload.duration_ms ?? payload.durationMs),
input_length: numberValue(payload.input_length ?? payload.inputLength),
reason_codes: uniqueStrings(arrayValue(payload.reason_codes ?? payload.reasonCodes).map(stringValue)),
};
}
return null;
}
function auditEventsFromArtifact(artifact: ArtifactEntry): Array<Record<string, unknown>> {
const deliveryStatus = stringValue(artifact.delivery_status);
if (!deliveryStatus) return [];

View File

@@ -260,6 +260,17 @@ function badgeClass(tone?: string): string {
return 'de-badge-info';
}
function formatCompactNumber(value?: number | null): string {
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0;
return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(numeric);
}
function formatEstimatedCostUsd(value?: number | null): string {
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0;
if (numeric > 0 && numeric < 0.01) return `$${numeric.toFixed(4)}`;
return `$${numeric.toFixed(2)}`;
}
function approvalRiskTone(value?: string | null): string {
const normalized = compactText(value).toLowerCase();
if (['high', 'critical', 'risk', 'marked'].includes(normalized)) return 'danger';
@@ -2478,6 +2489,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span className={diagnosticIssueCount > 0 ? 'is-warning' : ''}><em></em><strong>{diagnosticIssueCount}</strong></span>
<span className={auditRiskCount > 0 ? 'is-warning' : ''}><em></em><strong>{auditRiskCount}</strong></span>
<span><em></em><strong>{diagnosticSummary.cost.request_count}</strong></span>
<span><em>Token</em><strong>{formatCompactNumber(diagnosticSummary.cost.total_tokens)}</strong></span>
<span><em></em><strong>{formatEstimatedCostUsd(diagnosticSummary.cost.estimated_cost_usd)}</strong></span>
<small>{latestDiagnosticIssue ? `${latestDiagnosticIssue.category} · ${latestDiagnosticIssue.message}` : '诊断、审计和成本投影正常'}</small>
</div>
)}

View File

@@ -1122,6 +1122,9 @@ export interface DigitalEmployeeDiagnosticSummary {
};
cost: {
request_count: number;
total_tokens: number;
estimated_cost_usd: number;
by_model_count: number;
estimated: boolean;
updated_at?: string | null;
};

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-DIYwF-B5.js"></script>
<script type="module" crossorigin src="./assets/index-v4rEdqGS.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CEcWFgGW.css">
</head>
<body>

View File

@@ -174,18 +174,41 @@ function checkDigitalDiagnosticAuditProjection() {
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const pagePath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const page = fs.readFileSync(pagePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const requiredSnippets = [
[syncService, "sandboxAuditBusinessView", "sandbox audit sync business view"],
[syncService, "tokenCostBusinessView", "token/cost sync business view"],
[syncService, "toolCallAuditBusinessView", "tool call sync business view"],
[syncService, "sandbox_audit", "sandbox audit business category"],
[syncService, "token_cost", "token cost business category"],
[syncService, "tool_call_audit", "tool call business category"],
[syncService, "estimated_cost_usd", "safe estimated cost field"],
[syncService, "total_tokens", "safe total token field"],
[syncService, "input_length", "sensitive input length redaction"],
[adapter, "'audits'", "audit business view kind"],
[adapter, "buildDiagnosticSummary", "diagnostic summary builder"],
[adapter, "buildCostSummary", "cost summary builder"],
[adapter, "costSampleFromPayload", "runtime token/cost aggregation"],
[adapter, "diagnosticAuditPayload", "diagnostic audit payload redaction"],
[adapter, "sandbox_audit", "sandbox audit row category"],
[adapter, "token_cost", "token cost row category"],
[adapter, "tool_call_audit", "tool call row category"],
[adapter, "estimated_cost_usd", "audit estimated cost field"],
[adapter, "total_tokens", "audit total token field"],
[adapter, "filterAuditRows", "audit filters"],
[adapter, "diagnosticNotifications", "diagnostic notifications"],
[adapter, "auditNotifications", "audit notifications"],
[types, "DigitalEmployeeDiagnosticSummary", "diagnostic summary type"],
[types, "diagnostic_summary?: DigitalEmployeeDiagnosticSummary", "workday diagnostic field"],
[types, "estimated_cost_usd", "diagnostic summary estimated cost type"],
[types, "total_tokens", "diagnostic summary token type"],
[page, "de-diagnostic-summary-strip", "dashboard diagnostic strip"],
[page, "Token", "dashboard Token metric"],
[page, "估算成本", "dashboard estimated cost metric"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))

View File

@@ -1279,6 +1279,39 @@ describe("digital employee sync service", () => {
decision: "rejected",
reason_codes: ["skill_disabled"],
});
insertEventEntity("event-sandbox-audit", "sandbox_audit", {
session_id: "sandbox-session-1",
operation: "execute",
status: "failed",
exit_code: 126,
duration_ms: 2400,
command: "curl https://example.com --header Authorization: Bearer secret-token",
command_preview: "curl https://example.com",
reason_codes: ["permission_denied"],
authorization: "Bearer secret-token",
});
insertEventEntity("event-token-cost", "token_cost", {
session_id: "session-cost-1",
model: "gpt-4.1-mini",
input_tokens: 120,
output_tokens: 80,
total_tokens: 200,
estimated_cost_usd: 0.0125,
prompt: "完整客户资料和敏感输入不应同步",
raw_input: { token: "raw-secret" },
token: "secret-token",
});
insertEventEntity("event-tool-audit", "tool_call_audit", {
session_id: "session-tool-1",
server_id: "crm-server",
tool_name: "crm.search",
operation: "tool_call",
status: "allowed",
duration_ms: 640,
input_length: 36,
raw_input: { customer_phone: "13800000000" },
authorization: "Bearer tool-secret",
});
insertEventEntity("event-dispatch", "plan_step_dispatch_failed", {
step_id: "step-1",
plan_id: "plan-1",
@@ -1538,6 +1571,9 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-approval-decision" },
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-sandbox-audit" },
{ entity_type: "event", entity_id: "event-token-cost" },
{ entity_type: "event", entity_id: "event-tool-audit" },
{ entity_type: "event", entity_id: "event-dispatch" },
{ entity_type: "event", entity_id: "event-lease" },
{ entity_type: "event", entity_id: "event-plan-step-ready" },
@@ -1635,6 +1671,42 @@ describe("digital employee sync service", () => {
decision: "rejected",
reason_codes: ["skill_disabled"],
});
expect(businessView("event-sandbox-audit")).toMatchObject({
category: "sandbox_audit",
session_id: "sandbox-session-1",
operation: "execute",
status: "failed",
exit_code: 126,
duration_ms: 2400,
command_preview: "curl https://example.com",
reason_codes: ["permission_denied"],
});
expect(JSON.stringify(businessView("event-sandbox-audit"))).not.toContain("secret-token");
expect(JSON.stringify(businessView("event-sandbox-audit"))).not.toContain("Authorization");
expect(businessView("event-token-cost")).toMatchObject({
category: "token_cost",
session_id: "session-cost-1",
model: "gpt-4.1-mini",
input_tokens: 120,
output_tokens: 80,
total_tokens: 200,
estimated_cost_usd: 0.0125,
estimated: false,
});
expect(JSON.stringify(businessView("event-token-cost"))).not.toContain("完整客户资料");
expect(JSON.stringify(businessView("event-token-cost"))).not.toContain("raw-secret");
expect(businessView("event-tool-audit")).toMatchObject({
category: "tool_call_audit",
session_id: "session-tool-1",
server_id: "crm-server",
tool_name: "crm.search",
operation: "tool_call",
status: "allowed",
duration_ms: 640,
input_length: 36,
});
expect(JSON.stringify(businessView("event-tool-audit"))).not.toContain("13800000000");
expect(JSON.stringify(businessView("event-tool-audit"))).not.toContain("tool-secret");
expect(businessView("event-dispatch")).toMatchObject({
category: "dispatch",
step_id: "step-1",

View File

@@ -1737,6 +1737,9 @@ function eventSpecificBusinessView(
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
if (kind.startsWith("governance_")) return governanceCommandBusinessView(kind, payload);
if (kind === "sandbox_audit") return sandboxAuditBusinessView(payload);
if (kind === "token_cost") return tokenCostBusinessView(payload);
if (kind === "tool_call_audit") return toolCallAuditBusinessView(payload);
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
return {};
}
@@ -2020,6 +2023,49 @@ function governanceCommandBusinessView(kind: string, payload: Record<string, unk
return view;
}
function sandboxAuditBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
session_id: stringField(payload.session_id ?? payload.sessionId) || null,
operation: stringField(payload.operation ?? payload.action) || null,
status: stringField(payload.status) || null,
exit_code: numberField(payload.exit_code ?? payload.exitCode),
duration_ms: numberField(payload.duration_ms ?? payload.durationMs),
command_preview: compactPreview(stringField(payload.command_preview ?? payload.commandPreview) || ""),
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
};
}
function tokenCostBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const inputTokens = numberField(payload.input_tokens ?? payload.inputTokens);
const outputTokens = numberField(payload.output_tokens ?? payload.outputTokens);
const totalTokens = numberField(payload.total_tokens ?? payload.totalTokens) ?? ((inputTokens ?? 0) + (outputTokens ?? 0) || null);
const estimatedCostUsd = numberField(payload.estimated_cost_usd ?? payload.estimatedCostUsd ?? payload.cost_usd ?? payload.costUsd);
return {
session_id: stringField(payload.session_id ?? payload.sessionId) || null,
model: stringField(payload.model) || null,
status: stringField(payload.status) || null,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
estimated_cost_usd: estimatedCostUsd,
estimated: typeof payload.estimated === "boolean" ? payload.estimated : estimatedCostUsd === null,
};
}
function toolCallAuditBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
session_id: stringField(payload.session_id ?? payload.sessionId) || null,
server_id: stringField(payload.server_id ?? payload.serverId) || null,
tool_name: stringField(payload.tool_name ?? payload.toolName) || null,
operation: stringField(payload.operation ?? payload.action) || null,
status: stringField(payload.status) || null,
exit_code: numberField(payload.exit_code ?? payload.exitCode),
duration_ms: numberField(payload.duration_ms ?? payload.durationMs),
input_length: numberField(payload.input_length ?? payload.inputLength),
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
};
}
function notificationBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};
@@ -2063,6 +2109,9 @@ function eventBusinessCategory(kind: string): string {
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
if (isNotificationEvent(kind)) return "notification";
if (kind.startsWith("governance_")) return "governance";
if (kind === "sandbox_audit") return "sandbox_audit";
if (kind === "token_cost") return "token_cost";
if (kind === "tool_call_audit") return "tool_call_audit";
return "runtime";
}

View File

@@ -604,9 +604,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把入口路由从客户端可运营干预推进到管理端运营台。
11. **诊断 / 成本 / 审计视图**
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成只读诊断、估算成本和审计投影;数字员工首页已展示诊断/审计/成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。
- 缺口sandbox audit 文件、真实 token/cost 采集、CLI/tool 调用审计、诊断修复动作和管理端正式审计视图尚未统一进入数字员工视图和 outbox
- 建议:下一轮围绕 sandbox audit 与 token/cost 采集,把只读投影升级为可追溯的真实审计与成本统计
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成诊断、估算成本和审计投影;sandbox audit、token/cost 和 CLI/tool 调用事件已开始通过 `digital_events` 与 outbox `business_view` 提炼为 `sandbox_audit``token_cost``tool_call_audit` 分类,并只同步 session、tool、server、operation、status、exit code、duration、model、token 计数和估算成本等安全字段;数字员工首页已展示诊断/审计/Token/估算成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。
- 缺口:真实计费账单、独立 sandbox audit 文件归档、诊断修复动作和管理端正式审计视图尚未统一吸收
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置
12. **管理端业务查询模型**
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链embedded adapter 已拆出 `/api/digital-employee/plans``/tasks``/runs``/events``/artifacts``/approvals``/plans/:planId` 只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段,本地业务视图投影已开始闭环。