吸收数字员工入口路由干预回写
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user