/** * 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 { 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, ): Promise<{ arrival: ReturnType; 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 { 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 { 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 { 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; }