This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts
T
xcsandClaude Fable 5 7b85a79323 harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:16 +08:00

422 lines
18 KiB
TypeScript

/**
* human-tool 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type {
AcceptanceWorkOrder,
ChatCard,
ChatCardReply,
ExecutionWorkOrder,
StaffId,
TaskId,
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;
};
/**
* 录入卡上那一格叫什么 —— 键是 `段号.列名`(一张卡可以落好几张表)。
* 按段号找回它属于哪一段;认不出的原样留着(宁可笼统,不可编)。
*/
function entryLabel(card: Extract<ChatCard, { k: 'entry' }>, key: string): string {
const at = /^(\d+)\.(.+)$/.exec(key);
const groups = [{ title: '', fields: card.fields }, ...(card.also ?? [])];
if (at === null) return card.fields.find((f) => f.key === key)?.label ?? key;
const group = groups[Number(at[1])];
const name = at[2] ?? key;
const label = group?.fields.find((f) => f.key === name)?.label ?? name;
return group === undefined || group.title === '' ? label : `${group.title}·${label}`;
}
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
/**
* 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1)。
*
* **验收放行那一张不计预算**(2026-07-31 加)。
*
* 预算管的是「打扰」——Agent 自己选择去问人的那些次数:补全卡、决策卡、执行工单。
* 而放行不是它选的:铁律③终局唯放行,这一单**必须**由人签字才能收口。
* 把它和提问算在同一个额度里,后果是活动可以合规地把自己饿死 ——
* 实测 A4:预算 3 次,两张决策卡加一张补全卡用完,活全干完了,
* 提交放行被「调人预算触顶 3/3」拒回,再提再拒,连拒五拍熔断。
* 该拦的是「又去问人」,不是「办完了要交差」。
*/
async function assertBudget(context: IssueContext, mode?: WorkOrder['mode']): Promise<void> {
if (mode === 'acceptance') return;
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,
/*
* `payload` 里那一个键(对话卡的 `k` / 工单的 `mode`)要一并入痕 ——
* 少了它,日志只知道「发了一张卡」,说不出发的是补全卡还是录入卡,
* 四字动作那一格就只能写「发出卡片」这种等于没说的词。
*/
detail: { kind, id, arrival, recipient, ...payload },
});
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, order.mode);
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,
...(reply.mode === 'acceptance' && reply.checked !== undefined ? { checked: [...reply.checked] } : {}),
});
/*
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*
* 这段话从前只对**返回值**成立:抛回任务的人事件载荷是原始 reply,
* 而放行落地读的恰是事件载荷 —— 于是「routedPort 顶上」只发生在没人看的那一份里,
* 浏览器靠自己把算出的口塞进回传才活着(chat-bubbles 那段注释),
* 换个客户端(脚本、API 调用)就死在「选择组未选出口」(A2 e2e 实测)。
* **服务端说了算的事,不靠每个客户端自觉** —— 事件与返回值现在用同一份有效口。
*/
const routed = (found.order as AcceptanceWorkOrder).routedPort;
const effectivePorts: Readonly<Record<string, string>> =
reply.mode !== 'acceptance' ? {} : routed !== undefined ? { [routed.group]: routed.name } : reply.selectedPorts;
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
/*
* **空答复要说成人话**(2026-08-02 用户实测送来的熔断单):执行卡零输入之后,
* 人「看过、确认、提交」的回传 `values` 就是空的 —— 痕上一个值都没有,
* 在模型眼里像「没答成」,于是把同一张卡再发一遍(连发五拍熔断)。
* 空不是没答:**是人照原样确认了**(件上若改过,那几笔已由保存那一刻落进草稿层)。
*/
const blank = reply.mode === 'execution' && Object.keys(values).length === 0;
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,
mode: reply.mode,
...(blank ? { note: '人已办完并交回(卡上无表单,改动若有已在件上保存时落进草稿层)—— 接着办说明书里「人交回之后」的事' } : {}),
...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}),
},
});
// 回传经统一入口以人事件抛回任务 —— 载荷带的是**有效口**,不是人碰巧发来的那份
await deps.events.publishHumanEvent({
taskId: found.taskId,
cardId: reply.workOrderId,
payload: { ...reply, ...(reply.mode === 'acceptance' ? { selectedPorts: effectivePorts } : {}) },
});
const updated = await deps.cards.getWorkOrder(reply.workOrderId);
const order = updated?.order ?? found.order;
return {
order,
taskId: found.taskId,
producesCompletion: producesCompletion(reply),
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}),
selectedPorts: effectivePorts,
};
},
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }> {
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);
/*
* 标题写**内容**,不写「entry 卡回传」这种行话 —— 日志里动作已有自己一格
* (四字章:员工确认 / 员工补全 …),标题再复述一遍卡的类型就是白占地方。
* 录入卡尤其要说清落到哪张表:人回看时问的是「我那一下写了什么」。
*/
/*
* **日志里不摆台账地址与列名**(2026-08-02 用户指出:
* 「柴浩:写入 customer.profile / customer_code=chchch;name=青岛海洋电子有限公司」
* —— 这为啥没换成纯描述)。
*
* `customer.profile` 与 `customer_code` 是系统内的名字;人回看时问的是
* **「我那一下办了什么」**,答案该是「建了客户档案 · 青岛海洋电子有限公司」。
* 卡上本来就有人话:`ask` 是问他的那句,`fields[].label` 是每一格的中文名 ——
* 用它们,不用地址与列名。
*/
const title = found.card.ask.split('\n')[0] ?? found.card.ask;
await deps.memory.append({
taskId: found.taskId,
kind: 'card-reply',
title,
detail: {
by,
...reply,
...(reply.k === 'entry' && found.card.k === 'entry'
? {
/*
* 列名换成卡上那个中文 label;认不出的原样留着(宁可笼统,不可编)。
* 值的键是 `段号.列名`(一张卡可以落好几张表)—— 按段号找回它属于哪一段,
* 不然「联系人姓名」会显示成公司档那个同名的「公司名」。
*/
summary: Object.entries(reply.values)
.map(([k, v]) => `${found.card.k === 'entry' ? entryLabel(found.card, k) : k}:${v}`)
.join(' · '),
}
: {}),
},
});
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
return { card: updated, taskId: found.taskId };
},
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
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 attachRoutedPort(workOrderId, routed) {
await deps.cards.patchWorkOrder(workOrderId, {
routedPort: {
group: routed.group,
name: routed.name,
...(routed.label === undefined ? {} : { label: routed.label }),
because: routed.because,
},
});
},
async attachPortChoice(workOrderId, choice) {
await deps.cards.patchWorkOrder(workOrderId, {
portChoice: {
group: choice.group,
ports: choice.ports.map((p) => ({ name: p.name, ...(p.label === undefined ? {} : { label: p.label }) })),
},
});
},
async openWorkOrders(taskId) {
return deps.cards.listWorkOrders(taskId, true);
},
async allWorkOrders(taskId) {
return deps.cards.listWorkOrders(taskId, false);
},
async chatCards(taskId) {
return deps.cards.listChatCards(taskId);
},
async pendingCards(taskId) {
const orders = (await deps.cards.listWorkOrders(taskId, true)).filter((o) => o.done === undefined);
const chats = (await deps.cards.listOpenChatCards(taskId)).filter((c) => !chatCardAnswered(c));
return [
...orders.map((o) => ({ id: String(o.id), title: o.title, kind: 'work-order' as const })),
...chats.map((c) => ({ id: String(c.id), title: c.ask, kind: 'chat-card' as const })),
];
},
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 }),
...(reply.checked === undefined ? {} : { checked: [...reply.checked] }),
},
};
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
return {
...card,
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
};
}
if (card.k === 'entry' && reply.k === 'entry') {
return { ...card, done: { at, by, values: reply.values } };
}
if (card.k === 'rating' && reply.k === 'rating') {
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
}
return card;
}
/** 对话卡答没答:决策卡看 chosen,其余(录入 / 补全 / 评价)看 done */
function chatCardAnswered(card: ChatCard): boolean {
if (card.k === 'decision') return card.chosen !== undefined;
return 'done' in card && card.done !== undefined;
}