人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -228,3 +228,50 @@ describe('完成只出自放行通过', () => {
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
});
});
/**
* **一张录入卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
*
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉,
* **而盖掉之后没人看得出来**。
*/
describe('录入卡分段:段号.列名', () => {
/** 与 `routes/desk.ts` 那段拆分同一口径 —— 改一处这里要跟着改 */
const split = (values: Record<string, string>, at: number): Record<string, string> => {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
const mark = `${at}.`;
if (k.startsWith(mark)) out[k.slice(mark.length)] = v;
else if (at === 0 && !/^\d+\./.test(k)) out[k] = v;
}
return out;
};
const values = {
'0.customer_code': 'CU-9001',
'0.name': '上海精密电子有限公司',
'1.customer_code': 'CU-9001',
'1.contact_code': 'CT-01',
'1.name': '陈刚',
'1.email': 'chen@sh-elec.com',
};
it('**同名列不互相盖** —— 公司叫「上海精密电子」,联系人叫「陈刚」', () => {
expect(split(values, 0)['name']).toBe('上海精密电子有限公司');
expect(split(values, 1)['name']).toBe('陈刚');
});
it('每段只拿自己那几格 —— 联系人编码不该落进公司档', () => {
expect(split(values, 0)['contact_code']).toBeUndefined();
expect(Object.keys(split(values, 1)).sort()).toEqual(['contact_code', 'customer_code', 'email', 'name']);
});
it('**老形状(没段号的平键)仍旧管用** —— 一张卡只有一段时照旧落主段', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 0)).toEqual({ customer_code: 'CU-1', name: '老写法' });
});
it('老形状不喂给后面的段 —— 它说不清自己是哪一张表的', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
});
});
@@ -0,0 +1,134 @@
/**
* 调人预算 —— 拦的是「又去问人」,不是「办完了要交差」。
*
* 这条是拿一次熔断换来的:A4 的预算是 3 次,两张决策卡加一张补全卡用完;
* 三条料的价全算好了、门禁也过了,提交放行却被「调人预算触顶 3/3」拒回 ——
* 再提再拒,连拒五拍熔断。活动可以完全合规地把自己饿死在终点线上。
*
* 放行不是 Agent 选择的打扰:铁律③终局唯放行,这一单**必须**有人签字才收得了口。
* 所以验收单不计预算;补全卡、决策卡、执行工单照算。
*/
import { describe, expect, it } from 'vitest';
import type { ChatCard, DeliverableId, StaffId, TaskId, WorkOrder } from '@contracts';
import { cardIdSchema } from '@contracts';
import type { CardStore, HumanEventPublisher, MemoryRecorder, MessageOutbox } from '../port.ts';
import type { IssueContext } from '../contract.ts';
import { createHumanToolService } from '../internal/usecase/human-tool-service.ts';
import { HumanCallBudgetExceededError } from '../contract.ts';
const TASK = 'task-1' as TaskId;
const OWNER = 'staff-1' as StaffId;
/** 记下「发出去了什么」的假台账 —— 已用次数由测试给定,其余按端口如实实现 */
function cardStore(used: number, sent: string[]): CardStore {
return {
saveWorkOrder: async (_taskId, order) => {
sent.push(`order:${order.mode}`);
},
getWorkOrder: async () => undefined,
listWorkOrders: async () => [],
markWorkOrderDone: async () => undefined,
patchWorkOrder: async () => undefined,
saveChatCard: async (_taskId, card) => {
sent.push(`card:${card.k}`);
},
getChatCard: async () => undefined,
listChatCards: async () => [],
updateChatCard: async () => undefined,
closeChatCard: async () => undefined,
humanCallCount: async () => used,
openCardsOf: async () => [],
};
}
const outbox: MessageOutbox = { sendElicitation: async () => ({ id: 'm-1' }) };
const memory: MemoryRecorder = { append: async () => undefined };
const events: HumanEventPublisher = { publishHumanEvent: async () => undefined };
function service(used: number) {
const sent: string[] = [];
let seq = 0;
const svc = createHumanToolService({
cards: cardStore(used, sent),
outbox,
memory,
events,
clock: { now: () => new Date('2026-07-31T00:00:00.000Z') },
ids: {
next: () => {
seq += 1;
return `id-${seq}`;
},
},
});
return { svc, sent };
}
const context: IssueContext = { taskId: TASK, ownerId: OWNER, humanCallCap: 3 };
const executionDraft = {
title: '请补齐字段',
basis: [{ k: '缺口', v: 'PN' }],
ask: '请向客户追问',
fields: [{ key: 'pn', label: 'PN', kind: 'text' as const, required: true }],
} satisfies Parameters<ReturnType<typeof service>['svc']['issueExecution']>[1];
const acceptanceDraft = {
title: '收口验收',
basis: [{ k: '条目数', v: '3' }],
deliverable: 'd-1' as DeliverableId,
criteria: [{ label: '草案完整', level: 'L1' as const, pass: true }],
fields: [],
} satisfies Parameters<ReturnType<typeof service>['svc']['issueAcceptance']>[1];
const decisionCard: ChatCard = {
id: cardIdSchema.parse('c-1'),
at: '2026-07-31T00:00:00.000Z',
k: 'decision',
ask: '选一个处置',
basis: [{ k: '偏离', v: '-30%' }],
options: [
{ value: 'a', label: '照原价报' },
{ value: 'b', label: '按底价报' },
],
allowCustom: false,
};
describe('调人预算', () => {
it('没到顶 —— 执行单发得出去', async () => {
const { svc, sent } = service(2);
await svc.issueExecution(context, executionDraft);
expect(sent).toEqual(['order:execution']);
});
it('到顶了 —— 执行单拒发', async () => {
const { svc } = service(3);
await expect(svc.issueExecution(context, executionDraft)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
});
it('到顶了 —— 决策卡拒发', async () => {
const { svc } = service(3);
await expect(svc.issueChatCard(context, decisionCard)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
});
it('到顶了 —— **验收放行照发**:终局唯放行,不该跟提问抢额度', async () => {
const { svc, sent } = service(3);
await svc.issueAcceptance(context, acceptanceDraft);
expect(sent).toEqual(['order:acceptance']);
});
it('超顶很多也照发验收单 —— 已经办完的活不该因为问过几次人就交不了差', async () => {
const { svc, sent } = service(99);
await svc.issueAcceptance(context, acceptanceDraft);
expect(sent).toEqual(['order:acceptance']);
});
it('验收单免的是预算,不是三规矩 —— 裸卡照旧拒发', async () => {
const { svc } = service(0);
const bare = { ...acceptanceDraft, basis: [] } satisfies Omit<
Extract<WorkOrder, { mode: 'acceptance' }>,
'id' | 'at' | 'mode'
>;
await expect(svc.issueAcceptance(context, bare)).rejects.toThrow();
});
});
+26 -1
View File
@@ -89,6 +89,8 @@ export type IssuedChatCard = {
export type ReplyOutcome = {
readonly order: WorkOrder;
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
readonly taskId: TaskId;
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
readonly producesCompletion: boolean;
/** 驳回:环内回转,任务继续 */
@@ -110,10 +112,33 @@ export type HumanToolService = {
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
/**
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
*/
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }>;
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
/**
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
*
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
*/
attachPortChoice(
workOrderId: string,
choice: { group: string; ports: ReadonlyArray<{ name: string; label?: string | undefined }> },
): Promise<void>;
/**
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
*/
attachRoutedPort(
workOrderId: string,
routed: { group: string; name: string; label?: string | undefined; because: string },
): Promise<void>;
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
@@ -54,6 +54,20 @@ export function createCardStore(db: Db): CardStore {
await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id));
},
/*
* 局部改卡面:整卡存在 `payload` 里,所以读回来合并再写回。
* 只对**未回传**的卡开放(调用方保证)—— 已答的卡是留痕,只增不改。
*/
async patchWorkOrder(id, patch) {
const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1);
const row = rows[0];
if (row === undefined) return;
await db
.update(workOrderTable)
.set({ payload: { ...(row.payload as Record<string, unknown>), ...patch } })
.where(eq(workOrderTable.id, id));
},
async saveChatCard(taskId, card, recipientId) {
await db.insert(chatCardTable).values({
id: card.id,
@@ -164,6 +164,29 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
}
}
/*
* 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住:
* 必填空着不让确认(缺一格这张档就是残的,而它是主数据);
* 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。
*/
if (card.k === 'entry' && reply.k === 'entry') {
if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }];
/*
* **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 ——
* 两张表都有 `name` 这一列,平铺就会互相盖掉。
* 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。
*/
const groups = [{ fields: card.fields }, ...(card.also ?? [])];
const blank = groups.flatMap((g, at) =>
g.fields
.filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '')
.map((f) => f.label),
);
if (blank.length > 0) {
out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` });
}
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }];
// 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一
@@ -7,6 +7,7 @@ import type {
ChatCardReply,
ExecutionWorkOrder,
StaffId,
TaskId,
WorkOrder,
WorkOrderReply,
} from '@contracts';
@@ -38,9 +39,35 @@ export type HumanToolServiceDeps = {
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) */
async function assertBudget(context: IssueContext): Promise<void> {
/**
* 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(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);
@@ -61,7 +88,12 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
taskId: context.taskId,
kind: 'card-sent',
title,
detail: { kind, id, arrival, recipient },
/*
* `payload` 里那一个键(对话卡的 `k` / 工单的 `mode`)要一并入痕 ——
* 少了它,日志只知道「发了一张卡」,说不出发的是补全卡还是录入卡,
* 四字动作那一格就只能写「发出卡片」这种等于没说的词。
*/
detail: { kind, id, arrival, recipient, ...payload },
});
if (arrival === 'task-chat') return { arrival };
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
@@ -79,7 +111,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
const violations = checkWorkOrder(order);
if (violations.length > 0) throw new BareCallError(violations);
await assertBudget(context);
await assertBudget(context, order.mode);
const recipient = context.recipientId ?? context.ownerId;
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
@@ -171,8 +203,8 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
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 } : {}) },
title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title,
detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
});
// 回传经统一入口以人事件抛回任务
await deps.events.publishHumanEvent({
@@ -186,15 +218,28 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
return {
order,
taskId: found.taskId,
producesCompletion: producesCompletion(reply),
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}),
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
/*
* 出口:**算出来的优先**。
*
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
*/
selectedPorts:
reply.mode !== 'acceptance'
? {}
: (order as AcceptanceWorkOrder).routedPort !== undefined
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
: reply.selectedPorts,
};
},
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
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: '卡不存在' }]);
@@ -205,14 +250,45 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
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: `${found.card.k} 卡回传`,
detail: { by, ...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 updated;
return { card: updated, taskId: found.taskId };
},
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
@@ -239,6 +315,26 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
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);
},
@@ -270,6 +366,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
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 }) } };
}
+2
View File
@@ -11,6 +11,8 @@ export type CardStore = {
getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>;
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;