Files
JX-BPOS/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts
T
JCAI Main AgentandClaude Opus 5 561a2474ae M3(2/5): human-tool 与 release —— 卡协议与放行门
human-tool(L3):
- 三规矩机检:调用必附上下文(裸调用拒发)/ 回传即留痕 / 完成只出自放行通过
- 工单两模式 + 对话卡族的形态校验与回传校验;驳回必附意见;补全卡「没法填」旁路;
  决策卡自定义收尾项必附理由;已答不得再答(只增不改)
- 到达面两路(负责人 → 任务对话流;他人 → 系统消息);驳回重答 = 新卡实例、原卡闭合
- 调人预算由基座在环外强制;落地失败卡 = 执行工单的 preset

release(L4):
- 落地预检五项(入参 schema / 工具可用与授权 / 连接可达 / 目标预校验 / 数据冲突)
- 预检全过才发验收工单,卡面即已验证的动作清单;不过即打回环内,不发卡
- 放行写合并 + 幂等落地(任务 × 意向);落地收敛才发射输出口;
  受阻走落地失败卡且 emittedPorts 恒空;已成动作留存不回滚

215 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 01:28:49 +08:00

274 lines
10 KiB
TypeScript

/**
* human-tool 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type {
AcceptanceWorkOrder,
ChatCard,
ChatCardReply,
ExecutionWorkOrder,
StaffId,
WorkOrder,
WorkOrderReply,
} from '@contracts';
import { cardIdSchema, workOrderIdSchema } from '@contracts';
import type {
HumanToolService,
IssueContext,
IssuedChatCard,
IssuedWorkOrder,
ReplyOutcome,
} from '../../contract.ts';
import { BareCallError, HumanCallBudgetExceededError, ReplyRejectedError } from '../../contract.ts';
import type { CardStore, Clock, HumanEventPublisher, IdGenerator, MemoryRecorder, MessageOutbox } from '../../port.ts';
import {
arrivalOf,
checkChatCard,
checkChatCardReply,
checkWorkOrder,
checkWorkOrderReply,
producesCompletion,
} from '../domain/card-rules.ts';
export type HumanToolServiceDeps = {
readonly cards: CardStore;
readonly outbox: MessageOutbox;
readonly memory: MemoryRecorder;
readonly events: HumanEventPublisher;
readonly clock: Clock;
readonly ids: IdGenerator;
};
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
/** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */
async function assertBudget(context: IssueContext): Promise<void> {
const used = await deps.cards.humanCallCount(context.taskId);
if (used >= context.humanCallCap) {
throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap);
}
}
async function deliver(
context: IssueContext,
kind: 'work-order' | 'chat-card',
id: string,
title: string,
body: string,
payload: Record<string, unknown>,
): Promise<{ arrival: ReturnType<typeof arrivalOf>; messageId?: string }> {
const recipient = context.recipientId ?? context.ownerId;
const arrival = arrivalOf(recipient, context.ownerId);
await deps.memory.append({
taskId: context.taskId,
kind: 'card-sent',
title,
detail: { kind, id, arrival, recipient },
});
if (arrival === 'task-chat') return { arrival };
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
const message = await deps.outbox.sendElicitation({
staffId: recipient,
title,
body,
cardId: id,
sourceTaskId: context.taskId,
payload,
});
return { arrival, messageId: message.id };
}
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
const violations = checkWorkOrder(order);
if (violations.length > 0) throw new BareCallError(violations);
await assertBudget(context);
const recipient = context.recipientId ?? context.ownerId;
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
const delivered = await deliver(
context,
'work-order',
order.id,
order.title,
order.mode === 'execution' ? order.ask : `请验收:${order.title}`,
{ mode: order.mode },
);
return { order, arrival: delivered.arrival, ...(delivered.messageId === undefined ? {} : { messageId: delivered.messageId }) };
}
return {
async issueExecution(context, draft) {
const order: ExecutionWorkOrder = {
...draft,
id: workOrderIdSchema.parse(draft.id ?? deps.ids.next()),
at: draft.at ?? deps.clock.now().toISOString(),
mode: 'execution',
};
return issueOrder(context, order);
},
async issueAcceptance(context, draft) {
const order: AcceptanceWorkOrder = {
...draft,
id: workOrderIdSchema.parse(draft.id ?? deps.ids.next()),
at: draft.at ?? deps.clock.now().toISOString(),
mode: 'acceptance',
};
return issueOrder(context, order);
},
/** 落地失败卡 = 执行工单的系统预置形态(作业指导 = 失败动作与原因,回填 = 处置选择) */
async issueLandingFailure(context, input) {
const order: ExecutionWorkOrder = {
id: workOrderIdSchema.parse(deps.ids.next()),
at: deps.clock.now().toISOString(),
mode: 'execution',
preset: 'landing-failure',
title: '落地受阻:请处置失败动作',
basis: input.failures.map((f) => ({ k: f.label, v: f.reason })),
ask: '以下动作在放行后执行失败。已成动作留存不回滚,请逐项处置。',
standard: '每项给出处置选择;全部处置完毕后任务方可收敛终态。',
steps: input.failures.map((f) => `${f.label}:${f.reason}`),
fields: input.failures.map((f, index) => ({
key: `disposition_${index}`,
label: f.label,
kind: 'radio' as const,
required: true,
options: [
{ value: 'retry', label: '重试' },
{ value: 'offline', label: '线下完成后回填' },
{ value: 'abandon', label: '放弃非必交付项' },
{ value: 'halt', label: '叫停', negative: true },
],
})),
submitLabel: '提交处置',
};
return issueOrder(context, order);
},
async issueChatCard(context, card) {
const violations = checkChatCard(card);
if (violations.length > 0) throw new BareCallError(violations);
await assertBudget(context);
const recipient = context.recipientId ?? context.ownerId;
await deps.cards.saveChatCard(context.taskId, card, recipient);
const delivered = await deliver(context, 'chat-card', card.id, card.ask, card.ask, { k: card.k });
return { card, arrival: delivered.arrival, ...(delivered.messageId === undefined ? {} : { messageId: delivered.messageId }) };
},
async replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome> {
const found = await deps.cards.getWorkOrder(reply.workOrderId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '工单不存在' }]);
const violations = checkWorkOrderReply(found.order, reply);
if (violations.length > 0) throw new ReplyRejectedError(violations);
const at = deps.clock.now().toISOString();
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
await deps.memory.append({
taskId: found.taskId,
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`,
detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
});
// 回传经统一入口以人事件抛回任务
await deps.events.publishHumanEvent({
taskId: found.taskId,
cardId: reply.workOrderId,
payload: { ...reply },
});
const updated = await deps.cards.getWorkOrder(reply.workOrderId);
const order = updated?.order ?? found.order;
return {
order,
producesCompletion: producesCompletion(reply),
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}),
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
};
},
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
const found = await deps.cards.getChatCard(reply.cardId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
const violations = checkChatCardReply(found.card, reply);
if (violations.length > 0) throw new ReplyRejectedError(violations);
const at = deps.clock.now().toISOString();
const updated = applyChatReply(found.card, reply, by, at);
await deps.cards.updateChatCard(updated);
await deps.memory.append({
taskId: found.taskId,
kind: 'card-reply',
title: `${found.card.k} 卡回传`,
detail: { by, ...reply },
});
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
return updated;
},
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
async rejectAndReask(cardId, reason) {
const found = await deps.cards.getChatCard(cardId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
const fresh: ChatCard = {
...found.card,
id: cardIdSchema.parse(deps.ids.next()),
at: deps.clock.now().toISOString(),
ask: `${found.card.ask}\n【驳回重答】${reason}`,
...(found.card.k === 'decision' ? { chosen: undefined } : { done: undefined }),
} as ChatCard;
await deps.cards.saveChatCard(found.taskId, fresh, found.recipientId);
await deps.cards.closeChatCard(cardId, fresh.id, reason);
await deps.memory.append({
taskId: found.taskId,
kind: 'card-sent',
title: `驳回重答:${reason}`,
detail: { original: cardId, reask: fresh.id },
});
return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard;
},
async openWorkOrders(taskId) {
return deps.cards.listWorkOrders(taskId, true);
},
async chatCards(taskId) {
return deps.cards.listChatCards(taskId);
},
async openCardsOf(staffId) {
return deps.cards.openCardsOf(staffId);
},
};
}
function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: string): ChatCard {
if (card.k === 'decision' && reply.k === 'decision') {
return {
...card,
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
};
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
return {
...card,
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
};
}
if (card.k === 'rating' && reply.k === 'rating') {
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
}
return card;
}