吸收数字员工入口路由策略下发

This commit is contained in:
baiyanyun
2026-06-11 17:44:16 +08:00
parent 43418d0453
commit d292c34579
16 changed files with 1195 additions and 190 deletions

View File

@@ -29,6 +29,7 @@ import type {
SkillSpec,
CreatePlanRequest,
RouteDecisionTimelineResponse,
RouteDecisionPolicyPullResult,
IngressRouteRequest,
IngressRouteResponse,
GovernanceCommandRequest,
@@ -631,6 +632,12 @@ export function pullRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?: boole
});
}
export function pullRouteDecisionPolicy(): Promise<RouteDecisionPolicyPullResult> {
return apiFetch<RouteDecisionPolicyPullResult>('/api/route-decision/policy/pull', {
method: 'POST',
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {

View File

@@ -38,6 +38,8 @@ import type {
PlanStepDispatchPolicy,
PlanStepDispatchPolicyPullResult,
PlanStepSummary,
RouteDecisionPolicy,
RouteDecisionPolicyPullResult,
RouteDecisionRecord,
RouteDecisionTimelineResponse,
SkillSpec,
@@ -59,6 +61,8 @@ declare global {
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -100,6 +104,7 @@ interface AdapterState {
notificationPreferences?: NotificationPreferences;
approvalSubscriptionPreferences?: ApprovalSubscriptionPreferences;
planStepDispatchPolicy?: PlanStepDispatchPolicy;
routeDecisionPolicy?: RouteDecisionPolicy;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -360,6 +365,10 @@ interface QimingclawRouteDecisionInput {
createsTaskRun?: boolean;
approvalMode?: string | null;
policyResult?: string | null;
policySource?: string | null;
blockedReason?: string | null;
approvalId?: string | null;
matchedIntent?: string | null;
entrypoint?: string | null;
actor?: string | null;
message?: string | null;
@@ -367,6 +376,38 @@ interface QimingclawRouteDecisionInput {
recordedAt?: string | null;
}
interface QimingclawRouteDecisionPolicyInput {
routeKind?: string | null;
intentKind?: string | null;
reasonCodes?: string[] | null;
candidateSkills?: string[] | null;
contextRefs?: Record<string, unknown> | null;
correlationId?: string | null;
idempotencyKey?: string | null;
entrypoint?: string | null;
actor?: string | null;
source?: string | null;
payload?: Record<string, unknown> | null;
occurredAt?: string | null;
}
interface QimingclawRouteDecisionPolicyResult {
ok?: boolean;
decision?: 'allowed' | 'approval_required' | 'blocked' | string;
status?: 'allowed' | 'approval_required' | 'blocked' | string;
policy_result?: 'accepted' | 'approval_required' | 'blocked' | string;
route_kind?: string;
intent_kind?: string;
reason_codes?: string[];
matched_intent?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
event_id?: string | null;
policy_source?: string | null;
policy?: RouteDecisionPolicy;
error?: string | null;
}
interface QimingclawRiskPolicyInput {
actionKind?: string | null;
actionLabel?: string | null;
@@ -1022,6 +1063,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/risk-approval/policy/pull') {
return await pullBridgeRiskApprovalPolicy() as T;
}
if (method === 'POST' && pathname === '/api/route-decision/policy/pull') {
return await pullBridgeRouteDecisionPolicy() as T;
}
if (method === 'POST' && pathname === '/api/approval/updates/pull') {
return await pullBridgeApprovalUpdates() as T;
}
@@ -1045,6 +1089,7 @@ function defaultState(): AdapterState {
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
routeDecisionPolicy: defaultRouteDecisionPolicy(),
consoleRole: 'sales',
rolePreferences: {},
profile: {
@@ -1072,6 +1117,7 @@ function normalizeAdapterState(value: Partial<AdapterState>): AdapterState {
notificationPreferences: notificationPreferences(value),
approvalSubscriptionPreferences: approvalSubscriptionPreferences(value),
planStepDispatchPolicy: planStepDispatchPolicy(value),
routeDecisionPolicy: routeDecisionPolicy(value),
};
}
@@ -1177,6 +1223,27 @@ async function pullBridgeRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?:
}
}
async function pullBridgeRouteDecisionPolicy(): Promise<RouteDecisionPolicyPullResult> {
const fallbackPolicy = routeDecisionPolicy((await readBridgeUiState()) ?? readState());
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullRouteDecisionPolicy) {
return { ok: false, policy: fallbackPolicy, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullRouteDecisionPolicy();
return {
ok: result?.ok !== false,
skipped: result?.skipped,
endpoint: result?.endpoint ?? null,
pulled_at: result?.pulled_at ?? null,
policy: routeDecisionPolicy({ routeDecisionPolicy: result?.policy ?? fallbackPolicy }),
error: result?.error ?? null,
};
} catch (error) {
return { ok: false, policy: fallbackPolicy, error: error instanceof Error ? error.message : String(error) };
}
}
async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullApprovalUpdates) {
@@ -1223,6 +1290,39 @@ async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promi
}
}
async function evaluateBridgeRouteDecisionPolicy(input: QimingclawRouteDecisionPolicyInput): Promise<QimingclawRouteDecisionPolicyResult> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.evaluateRouteDecisionPolicy) {
return {
ok: true,
decision: 'allowed',
status: 'allowed',
policy_result: 'accepted',
route_kind: input.routeKind || 'ChatOnly',
intent_kind: normalizeRouteIntentKind(input.intentKind),
reason_codes: ['qimingclaw_bridge_unavailable'],
policy_source: 'local',
policy: defaultRouteDecisionPolicy(),
};
}
try {
return await bridge.evaluateRouteDecisionPolicy(input);
} catch (error) {
return {
ok: true,
decision: 'allowed',
status: 'allowed',
policy_result: 'accepted',
route_kind: input.routeKind || 'ChatOnly',
intent_kind: normalizeRouteIntentKind(input.intentKind),
reason_codes: ['route_policy_evaluation_failed'],
policy_source: 'local',
policy: defaultRouteDecisionPolicy(),
error: error instanceof Error ? error.message : String(error),
};
}
}
function riskPolicyBlockedResponse(result: QimingclawRiskPolicyResult): Record<string, unknown> | null {
if (result.decision === 'allowed') return null;
return {
@@ -3097,6 +3197,10 @@ function routeDecisionFromRuntimePayload(payload: Record<string, unknown>, event
creates_task_run: Boolean(decision.creates_task_run ?? decision.createsTaskRun),
approval_mode: stringValue(decision.approval_mode ?? decision.approvalMode) || null,
policy_result: stringValue(decision.policy_result ?? decision.policyResult) || 'accepted',
policy_source: stringValue(decision.policy_source ?? decision.policySource) || null,
blocked_reason: stringValue(decision.blocked_reason ?? decision.blockedReason) || null,
approval_id: stringValue(decision.approval_id ?? decision.approvalId) || null,
matched_intent: stringValue(decision.matched_intent ?? decision.matchedIntent) || null,
recorded_at: stringValue(decision.recorded_at ?? decision.recordedAt) || event.occurredAt,
entrypoint: stringValue(decision.entrypoint) || null,
actor: stringValue(decision.actor) || null,
@@ -3104,6 +3208,7 @@ function routeDecisionFromRuntimePayload(payload: Record<string, unknown>, event
}
function routeDecisionAttentionLevel(decision: RouteDecisionRecord): 'info' | 'action' | 'warn' | 'error' {
if (decision.policy_result === 'approval_required') return 'action';
if (decision.policy_result && decision.policy_result !== 'accepted') return 'error';
if (decision.reason_codes.includes('candidate_skills_disabled') || decision.reason_codes.includes('route_action_missing_context')) return 'warn';
if (decision.created_command_id) return 'action';
@@ -3113,6 +3218,7 @@ function routeDecisionAttentionLevel(decision: RouteDecisionRecord): 'info' | 'a
function routeDecisionStatusLabel(decision: RouteDecisionRecord): string {
const attentionLevel = routeDecisionAttentionLevel(decision);
if (attentionLevel === 'error') return '策略拒绝';
if (decision.policy_result === 'approval_required') return '待审批';
if (attentionLevel === 'warn') return '需要关注';
if (decision.created_command_id) return '已映射命令';
return '已记录';
@@ -3121,10 +3227,16 @@ function routeDecisionStatusLabel(decision: RouteDecisionRecord): string {
function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): DigitalEmployeeRouteSummary {
const rows = routeDecisionRows(snapshot);
const latest = rows[0] ?? null;
const policy = routeDecisionPolicy(snapshot?.uiState ?? readState());
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,
policy_source: policy.source ?? null,
policy_last_pulled_at: policy.last_pulled_at ?? null,
policy_last_pull_error: policy.last_pull_error ?? null,
latest: latest
? {
id: stringValue(latest.route_decision_id),
@@ -3133,6 +3245,9 @@ function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): Digital
status_label: stringValue(latest.status_label),
attention_level: stringValue(latest.attention_level),
created_command_id: stringValue(latest.created_command_id) || null,
policy_source: stringValue(latest.policy_source) || null,
blocked_reason: stringValue(latest.blocked_reason) || null,
approval_id: stringValue(latest.approval_id) || null,
recorded_at: stringValue(latest.recorded_at),
}
: null,
@@ -4152,6 +4267,21 @@ function defaultPlanStepDispatchPolicy(): PlanStepDispatchPolicy {
};
}
function defaultRouteDecisionPolicy(): RouteDecisionPolicy {
return {
enabled: true,
auto_create_governance_commands: true,
approval_required_intents: [],
blocked_intents: [],
preferred_route_kinds: [],
updated_at: null,
source: 'local',
remote_updated_at: null,
last_pulled_at: null,
last_pull_error: null,
};
}
function approvalSubscriptionPreferences(state: Partial<AdapterState> | null | undefined): ApprovalSubscriptionPreferences {
const stored = state?.approvalSubscriptionPreferences;
const fallback = defaultApprovalSubscriptionPreferences();
@@ -4188,6 +4318,28 @@ function planStepDispatchPolicy(state: Partial<AdapterState> | null | undefined)
};
}
function routeDecisionPolicy(state: Partial<AdapterState> | null | undefined): RouteDecisionPolicy {
const stored = state?.routeDecisionPolicy;
const fallback = defaultRouteDecisionPolicy();
if (!stored || typeof stored !== 'object') return fallback;
return {
enabled: stored.enabled !== false,
auto_create_governance_commands: stored.auto_create_governance_commands !== false,
approval_required_intents: uniqueStrings(arrayValue(stored.approval_required_intents).map(normalizeRouteIntentKind)),
blocked_intents: uniqueStrings(arrayValue(stored.blocked_intents).map(normalizeRouteIntentKind)),
preferred_route_kinds: uniqueStrings(arrayValue(stored.preferred_route_kinds)),
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
source: stored.source === 'remote' ? 'remote' : 'local',
remote_updated_at: typeof stored.remote_updated_at === 'string' ? stored.remote_updated_at : null,
last_pulled_at: typeof stored.last_pulled_at === 'string' ? stored.last_pulled_at : null,
last_pull_error: typeof stored.last_pull_error === 'string' ? stored.last_pull_error : null,
};
}
function normalizeRouteIntentKind(value: unknown): string {
return stringValue(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/g, '_') || 'chat';
}
function notificationPreferences(state: Partial<AdapterState> | null | undefined): NotificationPreferences {
const stored = state?.notificationPreferences;
const fallback = defaultNotificationPreferences();
@@ -5720,11 +5872,50 @@ async function handleIngressRoute(
creates_task_run: classification.createsTaskRun,
approval_mode: null,
policy_result: 'accepted',
policy_source: null,
blocked_reason: null,
approval_id: null,
matched_intent: null,
recorded_at: recordedAt,
entrypoint: body.entrypoint ?? null,
actor: body.actor ?? null,
};
const routeCommand = buildGovernanceCommandForRouteDecision(decision, body, snapshot);
const routePolicy = await evaluateBridgeRouteDecisionPolicy({
routeKind: decision.route_kind,
intentKind: decision.intent_kind,
reasonCodes: decision.reason_codes,
candidateSkills: decision.candidate_skills,
contextRefs: decision.context_refs,
correlationId,
idempotencyKey: body.idempotency_key,
entrypoint: body.entrypoint ?? null,
actor: body.actor ?? null,
source: 'qimingclaw-ingress-route',
payload: {
text: body.text ?? null,
payload: body.payload ?? null,
attachments: body.attachments ?? [],
},
occurredAt: recordedAt,
});
const routePolicyDecision = stringValue(routePolicy.decision) || 'allowed';
decision.policy_result = routePolicy.policy_result || (routePolicyDecision === 'allowed' ? 'accepted' : routePolicyDecision) || 'accepted';
decision.policy_source = routePolicy.policy_source ?? routePolicy.policy?.source ?? null;
decision.blocked_reason = routePolicy.blocked_reason ?? null;
decision.approval_id = routePolicy.approval_id ?? null;
decision.matched_intent = routePolicy.matched_intent ?? null;
decision.reason_codes = uniqueStrings([...decision.reason_codes, ...(routePolicy.reason_codes ?? [])]);
const preferredKinds = routePolicy.policy?.preferred_route_kinds ?? [];
if (preferredKinds.length > 0 && !preferredKinds.includes(decision.route_kind)) {
decision.reason_codes = uniqueStrings([...decision.reason_codes, 'route_kind_not_preferred']);
}
const shouldExecuteCommand = routePolicyDecision === 'allowed' && routePolicy.policy?.auto_create_governance_commands !== false;
if (routePolicyDecision === 'allowed' && routePolicy.policy?.auto_create_governance_commands === false) {
decision.reason_codes = uniqueStrings([...decision.reason_codes, 'route_command_auto_create_disabled']);
}
const routeCommand = shouldExecuteCommand
? buildGovernanceCommandForRouteDecision(decision, body, snapshot)
: { command: null, reasonCode: routePolicyDecision === 'allowed' ? 'route_command_auto_create_disabled' : `route_policy_${decision.policy_result}` };
const command = routeCommand.command
? await handleGovernanceCommand(routeCommand.command, snapshot)
: null;
@@ -5750,6 +5941,10 @@ async function handleIngressRoute(
createsTaskRun: decision.creates_task_run,
approvalMode: decision.approval_mode,
policyResult: decision.policy_result,
policySource: decision.policy_source,
blockedReason: decision.blocked_reason,
approvalId: decision.approval_id,
matchedIntent: decision.matched_intent,
entrypoint: decision.entrypoint,
actor: decision.actor,
message: `入口路由决策:${decision.route_kind} / ${decision.intent_kind}`,
@@ -5763,6 +5958,31 @@ async function handleIngressRoute(
: null;
const routeDecision = recorded ?? decision;
const resultIds = routeCommandResultIds(command);
if (routePolicyDecision !== 'allowed') {
return {
accepted: false,
envelope: {
entrypoint: body.entrypoint ?? 'chat',
text: body.text ?? null,
payload: body.payload ?? null,
attachments: body.attachments ?? [],
context_refs: contextRefs,
},
response_kind: 'route_decision',
route_decision: routeDecision,
command: null,
created_plan_id: null,
created_schedule_id: null,
created_task_ids: [],
created_run_ids: [],
projection_name: null,
projection_payload: null,
error: 'policy_blocked',
message: routePolicyDecision === 'approval_required'
? `入口路由需要审批:${routeDecision.route_kind} / ${routeDecision.intent_kind}`
: `入口路由已被策略阻断:${routeDecision.route_kind} / ${routeDecision.intent_kind}`,
};
}
return {
accepted: true,
envelope: {

View File

@@ -14,6 +14,7 @@ import {
pullApprovalUpdates,
pullPlanStepDispatchPolicy,
pullRiskApprovalPolicy,
pullRouteDecisionPolicy,
recordApprovalAction,
restartManagedService,
} from '@/lib/api';
@@ -1414,6 +1415,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const refreshRouteDecisionPolicy = useCallback(async () => {
setActionBusy('pull_route_decision_policy');
setActionError(null);
setActionNotice(null);
try {
const result = await pullRouteDecisionPolicy();
if (!mountedRef.current) return;
await fetchProjection();
if (!mountedRef.current) return;
const policy = result.policy;
setActionNotice(result.ok
? `入口路由策略已拉取:阻断 ${policy.blocked_intents.length} 类,审批 ${policy.approval_required_intents.length} 类。`
: `入口路由策略拉取失败,已沿用本地策略:${result.error || '未知错误'}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '拉取入口路由策略失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const refreshApprovalUpdates = useCallback(async () => {
setActionBusy('pull_approval_updates');
setActionError(null);
@@ -2103,6 +2126,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<ShieldAlert size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_risk_approval_policy' ? '拉取中...' : '风险策略'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading || actionBusy === 'pull_route_decision_policy'}
onClick={refreshRouteDecisionPolicy}
title="从管理端拉取入口路由策略"
>
<MessageSquare size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_route_decision_policy' ? '拉取中...' : '路由策略'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
@@ -2325,7 +2358,13 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span><em></em><strong>{routeSummary.total}</strong></span>
<span><em></em><strong>{routeSummary.mapped_count}</strong></span>
<span className={routeSummary.attention_count > 0 ? 'is-warning' : ''}><em></em><strong>{routeSummary.attention_count}</strong></span>
<small>{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}</small>
{(routeSummary.blocked_count ?? 0) > 0 && <span className="is-warning"><em></em><strong>{routeSummary.blocked_count}</strong></span>}
{(routeSummary.approval_required_count ?? 0) > 0 && <span className="is-warning"><em></em><strong>{routeSummary.approval_required_count}</strong></span>}
<small>
{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}
{routeSummary.policy_source ? ` · 策略${routeSummary.policy_source === 'remote' ? '远端' : '本地'}` : ''}
{routeSummary.policy_last_pull_error ? ` · 拉取失败` : ''}
</small>
</div>
)}

View File

@@ -395,6 +395,10 @@ export interface RouteDecisionRecord {
creates_task_run: boolean;
approval_mode: string | null;
policy_result: string | null;
policy_source?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
matched_intent?: string | null;
recorded_at: string;
entrypoint?: string | null;
actor?: string | null;
@@ -405,6 +409,28 @@ export interface RouteDecisionTimelineResponse {
decisions: RouteDecisionRecord[];
}
export interface RouteDecisionPolicy {
enabled: boolean;
auto_create_governance_commands: boolean;
approval_required_intents: string[];
blocked_intents: string[];
preferred_route_kinds: string[];
updated_at: string | null;
source?: 'local' | 'remote';
remote_updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
}
export interface RouteDecisionPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint?: string | null;
pulled_at?: string | null;
policy: RouteDecisionPolicy;
error?: string | null;
}
export interface IngressRouteRequest {
entrypoint?: 'chat' | 'task_center' | 'cron' | 'webhook' | 'legacy_api' | 'browser_event' | 'file_event';
actor?: string;
@@ -961,6 +987,11 @@ export interface DigitalEmployeeRouteSummary {
total: number;
mapped_count: number;
attention_count: number;
blocked_count?: number;
approval_required_count?: number;
policy_source?: string | null;
policy_last_pulled_at?: string | null;
policy_last_pull_error?: string | null;
latest?: {
id: string;
route_kind: string;
@@ -968,6 +999,9 @@ export interface DigitalEmployeeRouteSummary {
status_label: string;
attention_level: string;
created_command_id?: string | null;
policy_source?: string | null;
blocked_reason?: string | null;
approval_id?: string | null;
recorded_at: string;
} | null;
}