吸收数字员工入口路由干预回写

This commit is contained in:
baiyanyun
2026-06-11 18:31:41 +08:00
parent d292c34579
commit 4c862ef4b7
16 changed files with 1151 additions and 191 deletions

View File

@@ -30,6 +30,9 @@ import type {
CreatePlanRequest,
RouteDecisionTimelineResponse,
RouteDecisionPolicyPullResult,
RouteInterventionDecisionRequest,
RouteInterventionDecisionResponse,
RouteInterventionTimelineResponse,
IngressRouteRequest,
IngressRouteResponse,
GovernanceCommandRequest,
@@ -496,6 +499,20 @@ export function getRouteDecisionTimeline(): Promise<RouteDecisionTimelineRespons
return apiFetch<RouteDecisionTimelineResponse>('/api/route-decisions');
}
export function getRouteInterventions(): Promise<RouteInterventionTimelineResponse> {
return apiFetch<RouteInterventionTimelineResponse>('/api/route-decisions/interventions');
}
export function decideRouteIntervention(
routeDecisionId: string,
input: RouteInterventionDecisionRequest,
): Promise<RouteInterventionDecisionResponse> {
return apiFetch<RouteInterventionDecisionResponse>(`/api/route-decisions/${encodeURIComponent(routeDecisionId)}/intervention`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export function routeIngress(
body: IngressRouteRequest,
): Promise<IngressRouteResponse> {

View File

@@ -42,6 +42,10 @@ import type {
RouteDecisionPolicyPullResult,
RouteDecisionRecord,
RouteDecisionTimelineResponse,
RouteInterventionDecisionRequest,
RouteInterventionDecisionResponse,
RouteInterventionRecord,
RouteInterventionTimelineResponse,
SkillSpec,
UserNotification,
} from '@/types/api';
@@ -78,7 +82,9 @@ declare global {
dispatchPlanReadySteps?: (planId: string) => Promise<ReadyStepDispatchResult>;
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
listRouteInterventions?: (options?: { limit?: number }) => Promise<RouteInterventionRecord[]>;
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
recordRouteInterventionResolution?: (input: QimingclawRouteInterventionResolutionInput) => Promise<RouteInterventionRecord>;
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
@@ -377,6 +383,7 @@ interface QimingclawRouteDecisionInput {
}
interface QimingclawRouteDecisionPolicyInput {
routeDecisionId?: string | null;
routeKind?: string | null;
intentKind?: string | null;
reasonCodes?: string[] | null;
@@ -408,6 +415,30 @@ interface QimingclawRouteDecisionPolicyResult {
error?: string | null;
}
interface QimingclawRouteInterventionResolutionInput {
routeDecisionId: string;
approvalId?: string | null;
decision: string;
createdCommandId?: string | null;
commandAccepted?: boolean | null;
commandError?: string | null;
blockedReason?: string | null;
policyResult?: string | null;
policySource?: string | null;
matchedIntent?: string | null;
routeKind?: string | null;
intentKind?: string | null;
reasonCodes?: string[] | null;
candidateSkills?: string[] | null;
contextRefs?: Record<string, unknown> | null;
entrypoint?: string | null;
actor?: string | null;
correlationId?: string | null;
message?: string | null;
payload?: Record<string, unknown> | null;
occurredAt?: string | null;
}
interface QimingclawRiskPolicyInput {
actionKind?: string | null;
actionLabel?: string | null;
@@ -903,6 +934,17 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/route-decisions') {
return await readRouteDecisions(Number(url.searchParams.get('limit')) || 80) as T;
}
if (method === 'GET' && pathname === '/api/route-decisions/interventions') {
return await readRouteInterventions(Number(url.searchParams.get('limit')) || 80) as T;
}
const routeInterventionMatch = pathname.match(/^\/api\/route-decisions\/([^/]+)\/intervention$/);
if (method === 'POST' && routeInterventionMatch) {
return await handleRouteInterventionDecision(
decodeURIComponent(routeInterventionMatch[1] || ''),
parseJsonBody<RouteInterventionDecisionRequest>(options.body),
await readQimingclawSnapshot(),
) as T;
}
if (method === 'POST' && pathname === '/api/ingress/route') {
return await handleIngressRoute(parseJsonBody<IngressRouteRequest>(options.body), await readQimingclawSnapshot()) as T;
}
@@ -1251,6 +1293,9 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
}
try {
const result = await bridge.pullApprovalUpdates();
if (result?.ok !== false) {
await resolveApprovedRouteInterventions(await readQimingclawSnapshot());
}
return {
ok: result?.ok !== false,
applied: Number(result?.applied ?? 0),
@@ -1263,6 +1308,24 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
}
}
async function resolveApprovedRouteInterventions(snapshot: QimingclawSnapshot | null): Promise<void> {
const timeline = await readRouteInterventions(120);
const approved = timeline.interventions.filter((item) => (
item.intervention_status === 'approved'
&& item.approval_id
&& !item.resolved_at
)).slice(0, 5);
for (const intervention of approved) {
await handleRouteInterventionDecision(intervention.route_decision_id, {
approval_id: intervention.approval_id,
decision: 'approved',
actor: 'management-api',
}, snapshot).catch((error) => {
console.warn('[qimingclawAdapter] auto route intervention resolution failed:', error);
});
}
}
async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promise<QimingclawRiskPolicyResult> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.evaluateRiskPolicy) {
@@ -3228,12 +3291,19 @@ function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): Digital
const rows = routeDecisionRows(snapshot);
const latest = rows[0] ?? null;
const policy = routeDecisionPolicy(snapshot?.uiState ?? readState());
const resolutionEvents = runtimeEvents(snapshot).filter((event) => event.kind === 'route_decision_intervention_resolved');
const resolvedRouteIds = new Set(resolutionEvents.map((event) => stringValue(asRecord(event.payload)?.route_decision_id)).filter(Boolean));
const latestResolution = resolutionEvents.sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))[0] ?? null;
const latestResolutionPayload = asRecord(latestResolution?.payload) ?? null;
return {
total: rows.length,
mapped_count: rows.filter((row) => stringValue(row.created_command_id)).length,
attention_count: rows.filter((row) => ['warn', 'error'].includes(stringValue(row.attention_level))).length,
blocked_count: rows.filter((row) => stringValue(row.policy_result) === 'blocked').length,
approval_required_count: rows.filter((row) => stringValue(row.policy_result) === 'approval_required').length,
pending_intervention_count: rows.filter((row) => stringValue(row.policy_result) !== 'accepted' && !resolvedRouteIds.has(stringValue(row.route_decision_id))).length,
restored_count: resolutionEvents.filter((event) => asRecord(event.payload)?.command_accepted === true).length,
latest_intervention_status: stringValue(latestResolutionPayload?.status ?? latestResolutionPayload?.decision) || null,
policy_source: policy.source ?? null,
policy_last_pulled_at: policy.last_pulled_at ?? null,
policy_last_pull_error: policy.last_pull_error ?? null,
@@ -5846,6 +5916,224 @@ async function readRouteDecisions(limit = 80): Promise<RouteDecisionTimelineResp
}
}
async function readRouteInterventions(limit = 80): Promise<RouteInterventionTimelineResponse> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.listRouteInterventions) {
return { has_route_intervention_state: false, interventions: [] };
}
try {
const interventions = await bridge.listRouteInterventions({ limit });
return { has_route_intervention_state: true, interventions: interventions ?? [] };
} catch (error) {
console.warn('[qimingclawAdapter] list route interventions failed:', error);
return { has_route_intervention_state: false, interventions: [] };
}
}
async function handleRouteInterventionDecision(
routeDecisionId: string,
body: RouteInterventionDecisionRequest,
snapshot: QimingclawSnapshot | null,
): Promise<RouteInterventionDecisionResponse> {
const decisionText = stringValue(body.decision) || 'reject';
const approvalId = stringValue(body.approval_id) || routeInterventionApprovalId(routeDecisionId, snapshot);
const existing = await readRouteInterventions(160);
const existingRecord = existing.interventions.find((item) => item.route_decision_id === routeDecisionId) ?? null;
const detail = routeDecisionDetail(routeDecisionId, snapshot);
const routeDecision = detail?.decision ?? routeInterventionToRouteDecision(existingRecord, routeDecisionId);
if (!routeDecision) {
const intervention = await recordRouteInterventionResolution({
routeDecisionId,
approvalId,
decision: decisionText,
commandAccepted: false,
commandError: 'route_intervention_missing_context',
actor: body.actor ?? 'digital_employee_operator',
occurredAt: new Date().toISOString(),
});
return { accepted: false, route_decision_id: routeDecisionId, intervention, error: 'invalid_context', message: '入口路由上下文缺失,无法恢复执行。' };
}
await recordLocalRouteApprovalDecision(approvalId, decisionText, body.actor ?? 'digital_employee_operator', snapshot);
let command: GovernanceCommandResponse | null = null;
let commandError: string | null = null;
if (isApprovedInterventionDecision(decisionText)) {
const routeCommand = buildGovernanceCommandForRouteDecision(routeDecision, detail?.body ?? routeDecisionToIngressBody(routeDecision), snapshot);
if (routeCommand.command) {
command = await handleGovernanceCommand(routeCommand.command, snapshot);
if (!command.accepted) commandError = command.error || 'route_intervention_command_rejected';
} else {
commandError = routeCommand.reasonCode || 'route_intervention_missing_context';
}
}
const intervention = await recordRouteInterventionResolution({
routeDecisionId,
approvalId,
decision: decisionText,
createdCommandId: command?.command_id ?? null,
commandAccepted: command?.accepted === true,
commandError,
blockedReason: routeDecision.blocked_reason ?? null,
policyResult: routeDecision.policy_result ?? null,
policySource: routeDecision.policy_source ?? null,
matchedIntent: routeDecision.matched_intent ?? null,
routeKind: routeDecision.route_kind,
intentKind: routeDecision.intent_kind,
reasonCodes: routeDecision.reason_codes,
candidateSkills: routeDecision.candidate_skills,
contextRefs: routeDecision.context_refs,
entrypoint: routeDecision.entrypoint ?? null,
actor: body.actor ?? 'digital_employee_operator',
correlationId: routeDecision.correlation_id,
payload: { comment: body.comment ?? null },
occurredAt: new Date().toISOString(),
});
return {
accepted: intervention.intervention_status !== 'failed',
route_decision_id: routeDecisionId,
intervention,
command,
error: commandError || undefined,
message: intervention.resolution_duplicate
? '入口路由干预已处理。'
: isApprovedInterventionDecision(decisionText)
? command?.command_id ? '入口路由干预已通过并恢复执行。' : '入口路由干预已通过。'
: '入口路由干预已拒绝。',
};
}
async function recordRouteInterventionResolution(input: QimingclawRouteInterventionResolutionInput): Promise<RouteInterventionRecord> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.recordRouteInterventionResolution) {
return {
route_decision_id: input.routeDecisionId,
approval_id: input.approvalId ?? null,
route_kind: input.routeKind ?? 'ChatOnly',
intent_kind: input.intentKind ?? 'chat',
policy_result: input.policyResult ?? null,
intervention_status: input.commandError ? 'failed' : isApprovedInterventionDecision(input.decision) ? 'approved' : 'rejected',
decision: input.decision,
created_command_id: input.createdCommandId ?? null,
blocked_reason: input.blockedReason ?? null,
matched_intent: input.matchedIntent ?? null,
policy_source: input.policySource ?? null,
reason_codes: input.reasonCodes ?? [],
candidate_skills: input.candidateSkills ?? [],
context_refs: input.contextRefs ?? {},
entrypoint: input.entrypoint ?? null,
actor: input.actor ?? null,
correlation_id: input.correlationId ?? null,
approval_status: null,
recorded_at: input.occurredAt ?? new Date().toISOString(),
resolved_at: input.occurredAt ?? new Date().toISOString(),
resolution_duplicate: false,
};
}
return bridge.recordRouteInterventionResolution(input);
}
function routeDecisionDetail(routeDecisionId: string, snapshot: QimingclawSnapshot | null): { decision: RouteDecisionRecord; body: IngressRouteRequest } | null {
const event = runtimeEvents(snapshot).find((item) => {
if (item.kind !== 'route_decision') return false;
const payload = asRecord(item.payload) ?? {};
const decision = routeDecisionFromRuntimePayload(payload, item);
return decision.id === routeDecisionId;
});
if (!event) return null;
const payload = asRecord(event.payload) ?? {};
const decision = routeDecisionFromRuntimePayload(payload, event);
const envelope = asRecord(payload.envelope) ?? {};
return {
decision,
body: {
entrypoint: decision.entrypoint as IngressRouteRequest['entrypoint'] ?? 'chat',
actor: decision.actor ?? undefined,
text: stringValue(envelope.text) || undefined,
payload: asRecord(envelope.payload) ?? {},
attachments: arrayValue(envelope.attachments).filter((item): item is string => typeof item === 'string'),
timestamp: decision.recorded_at,
correlation_id: decision.correlation_id,
idempotency_key: `route-intervention:${decision.id}`,
context_refs: decision.context_refs,
},
};
}
function routeInterventionToRouteDecision(record: RouteInterventionRecord | null, routeDecisionId: string): RouteDecisionRecord | null {
if (!record) return null;
return {
id: routeDecisionId,
route_kind: record.route_kind,
intent_kind: record.intent_kind,
reason_codes: record.reason_codes ?? [],
candidate_skills: record.candidate_skills ?? [],
context_refs: record.context_refs ?? {},
created_command_id: record.created_command_id,
correlation_id: record.correlation_id || routeDecisionId,
creates_plan: record.route_kind === 'PlanControl' || record.route_kind === 'PlanExecution',
creates_task_run: record.route_kind === 'DirectRun' || record.route_kind === 'PlanExecution',
approval_mode: record.approval_id ? 'manual' : null,
policy_result: record.policy_result,
policy_source: record.policy_source,
blocked_reason: record.blocked_reason,
approval_id: record.approval_id,
matched_intent: record.matched_intent,
recorded_at: record.recorded_at,
entrypoint: record.entrypoint,
actor: record.actor,
};
}
function routeDecisionToIngressBody(decision: RouteDecisionRecord): IngressRouteRequest {
return {
entrypoint: decision.entrypoint as IngressRouteRequest['entrypoint'] ?? 'chat',
actor: decision.actor ?? undefined,
payload: {},
attachments: [],
timestamp: decision.recorded_at,
correlation_id: decision.correlation_id,
idempotency_key: `route-intervention:${decision.id}`,
context_refs: decision.context_refs,
};
}
function routeInterventionApprovalId(routeDecisionId: string, snapshot: QimingclawSnapshot | null): string | null {
const detail = routeDecisionDetail(routeDecisionId, snapshot);
if (detail?.decision.approval_id) return detail.decision.approval_id;
const row = routeDecisionRows(snapshot).find((item) => stringValue(item.route_decision_id) === routeDecisionId);
return stringValue(row?.approval_id) || null;
}
async function recordLocalRouteApprovalDecision(
approvalId: string | null,
decision: string,
actor: string,
snapshot: QimingclawSnapshot | null,
): Promise<void> {
if (!approvalId) return;
const date = todayInputDate();
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
state.decisionResultsByDate = state.decisionResultsByDate ?? {};
state.decisionResultsByDate[date] = {
...(state.decisionResultsByDate[date] ?? {}),
[approvalId]: decision,
};
await saveState(state, 'decide_approval', date, { decision_id: approvalId, decision, actor });
await window.QimingClawBridge?.digital?.submitApprovalDecision?.({
approval_id: approvalId,
decision,
date,
actor,
source: 'sgrobot-route-intervention',
}).catch(() => null);
}
function isApprovedInterventionDecision(decision: string): boolean {
return ['approve', 'approved', 'accept', 'accepted'].includes(decision.toLowerCase());
}
async function handleIngressRoute(
body: IngressRouteRequest,
snapshot: QimingclawSnapshot | null,
@@ -5881,6 +6169,7 @@ async function handleIngressRoute(
actor: body.actor ?? null,
};
const routePolicy = await evaluateBridgeRouteDecisionPolicy({
routeDecisionId: decision.id,
routeKind: decision.route_kind,
intentKind: decision.intent_kind,
reasonCodes: decision.reason_codes,