Files
JX-BPOS/packages/contracts/src/card.ts
T

518 lines
18 KiB
TypeScript

/**
* 人类工具的卡协议(编码规范 §8.1 / §9;语义出处 OS 05,AM 06/07)。
*
* **双层边界**:范式(loop / flow / skill)是后端实现逻辑的模型,只存在于引擎层;
* 本文件是**前端呈现协议**,前端永远感知不到范式。旧文「四卡」的映射:
* 执行卡 → 执行工单(work order · execution)
* 放行卡 → 验收(工作区可修订报告 + 对话区验收单)(work order · acceptance)
* 决策 / 补全 → 对话卡(decision / elicitation)
* 落地失败卡 = 执行工单的系统预置形态,不构成第五种卡(OS 7.3)。
*
* 形状以 2026-07-26 原型定稿为准(`~/coding/JCAI_PRD/workbench/_data/model.ts`);
* 枚举值改用英文标识符 + 本文件的 *_LABEL 字典,界面文案由字典给出、与原型逐字一致。
*/
import { z } from 'zod';
import {
cardIdSchema,
deliverableIdSchema,
stageIdSchema,
staffIdSchema,
workOrderIdSchema,
} from './id.ts';
import { acceptanceLevelSchema, rejectionKindSchema } from './terminal.ts';
/* ── 基础原语 ─────────────────────────────────────────────────────────── */
export const TONES = ['ok', 'warn', 'danger', 'brand', 'muted'] as const;
export const toneSchema = z.enum(TONES);
export type Tone = z.infer<typeof toneSchema>;
/** 依据快照:调用必附上下文(OS 05)—— 裸调用基座必须拒发 */
export const basisSchema = z.array(z.object({ k: z.string(), v: z.string() }));
export type Basis = z.infer<typeof basisSchema>;
export const formOptionSchema = z.object({
value: z.string(),
label: z.string(),
negative: z.boolean().optional(),
});
export type FormOption = z.infer<typeof formOptionSchema>;
export const FORM_FIELD_KINDS = [
'text',
'textarea',
'radio',
'number',
'select',
'check',
'date',
'money',
'file',
] as const;
export const formFieldKindSchema = z.enum(FORM_FIELD_KINDS);
export type FormFieldKind = z.infer<typeof formFieldKindSchema>;
/** 表单字段:结构化交互的原子。卡片表单由本 schema 驱动渲染与校验,后端回传校验同 schema(§9.2) */
export const formFieldSchema = z.object({
key: z.string().min(1),
label: z.string(),
kind: formFieldKindSchema,
placeholder: z.string().optional(),
options: z.array(formOptionSchema).optional(),
/** 字段分组(渲染为表单小节) */
group: z.string().optional(),
hint: z.string().optional(),
/** AI 拟值 —— 验收工单落地时已填好,人可改全部字段 */
ai: z.string().optional(),
unit: z.string().optional(),
required: z.boolean().optional(),
cols: z.union([z.literal(1), z.literal(2)]).optional(),
});
export type FormField = z.infer<typeof formFieldSchema>;
/* ── 视图块:视图区的结构化呈现(活动推送,只读) ────────────────────── */
const fileDocSchema = z.discriminatedUnion('kind', [
/**
* HTML 汇报报告 —— 成果文件的**主要形态**:活动按当下局面组织的一屏。
* 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。
*/
z.object({ kind: z.literal('html'), name: z.string(), html: z.string() }),
z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string() }),
z.object({
kind: z.literal('excel'),
name: z.string(),
sheet: z.string(),
rows: z.array(z.array(z.string())),
}),
z.object({
kind: z.literal('word'),
name: z.string(),
blocks: z.array(
z.union([
z.object({ t: z.enum(['h1', 'h2', 'p']), text: z.string() }),
z.object({ t: z.literal('ul'), items: z.array(z.string()) }),
]),
),
}),
z.object({ kind: z.literal('pdf'), name: z.string(), src: z.string() }),
]);
export type FileDoc = z.infer<typeof fileDocSchema>;
export { fileDocSchema };
export const viewBlockSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('callout'), tone: toneSchema, text: z.string() }),
z.object({
t: z.literal('kv'),
title: z.string().optional(),
items: z.array(
z.object({
k: z.string(),
v: z.string(),
/** 绑定某工作事项的回填字段:未答显示占位,作答后实时落定 */
fromItem: z.object({ item: z.string(), field: z.string() }).optional(),
}),
),
}),
z.object({
t: z.literal('badges'),
title: z.string().optional(),
items: z.array(z.object({ label: z.string(), on: z.boolean() })),
}),
z.object({
t: z.literal('table'),
title: z.string().optional(),
cols: z.array(z.string()),
rows: z.array(z.array(z.string())),
editable: z.boolean().optional(),
editableCols: z.array(z.number().int()).optional(),
linkDonut: z
.object({
col: z.number().int(),
title: z.string().optional(),
unit: z.string().optional(),
map: z.array(z.object({ match: z.string(), label: z.string(), tone: toneSchema })),
})
.optional(),
}),
z.object({
t: z.literal('bars'),
title: z.string().optional(),
max: z.number().optional(),
redline: z.number().optional(),
items: z.array(
z.object({
label: z.string(),
value: z.number(),
text: z.string().optional(),
tone: toneSchema.optional(),
}),
),
}),
z.object({
t: z.literal('donut'),
title: z.string().optional(),
unit: z.string().optional(),
items: z.array(z.object({ label: z.string(), value: z.number(), tone: toneSchema })),
}),
z.object({
t: z.literal('mail'),
dir: z.enum(['in', 'out']),
statusLabel: z.string().optional(),
editable: z.boolean().optional(),
from: z.string(),
fromAddr: z.string().optional(),
to: z.string(),
cc: z.string().optional(),
subject: z.string(),
time: z.string(),
body: z.string(),
attachments: z
.array(z.object({ name: z.string(), meta: z.string().optional(), file: fileDocSchema.optional() }))
.optional(),
}),
z.object({
t: z.literal('cards'),
title: z.string().optional(),
items: z.array(
z.object({
tag: z.string(),
icon: z.string().optional(),
tone: toneSchema.optional(),
title: z.string(),
code: z.string().optional(),
swap: z.object({ from: z.string(), to: z.string(), hint: z.string().optional() }).optional(),
big: z.string(),
bigLabel: z.string().optional(),
meta: z.array(z.object({ k: z.string(), v: z.string() })).optional(),
note: z.string().optional(),
from: z.string().optional(),
time: z.string().optional(),
}),
),
}),
z.object({ t: z.literal('waiting'), label: z.string(), hint: z.string().optional() }),
z.object({
t: z.literal('files'),
title: z.string().optional(),
items: z.array(z.object({ label: z.string(), desc: z.string(), file: fileDocSchema })),
}),
]);
export type ViewBlock = z.infer<typeof viewBlockSchema>;
/* ── 阶段与过程成果 ───────────────────────────────────────────────────── */
/**
* 阶段:交互层的统一进展刻度。
* 引擎层的来源随范式而异(loop = CMMN Stage / 里程碑,flow = 步序位置),前端不感知。
*/
export const stageSchema = z.object({
id: stageIdSchema,
label: z.string(),
round: z.number().int().min(1).optional(),
});
export type Stage = z.infer<typeof stageSchema>;
export const DELIVERABLE_STATES = ['draft', 'pending-acceptance', 'released', 'rejected'] as const;
export const deliverableStateSchema = z.enum(DELIVERABLE_STATES);
export type DeliverableState = z.infer<typeof deliverableStateSchema>;
export const DELIVERABLE_STATE_LABEL: Readonly<Record<DeliverableState, string>> = {
draft: '草稿',
'pending-acceptance': '待验收',
released: '已放行',
rejected: '已驳回',
};
/** 过程成果:每个成果都要经验收放行才落地(放行门,OS 09) */
export const deliverableSchema = z.object({
id: deliverableIdSchema,
stage: stageIdSchema,
round: z.number().int().min(1).optional(),
title: z.string(),
desc: z.string().optional(),
blocks: z.array(viewBlockSchema),
state: deliverableStateSchema,
at: z.string().optional(),
});
export type Deliverable = z.infer<typeof deliverableSchema>;
/* ── 工单两模式(执行 / 验收) ────────────────────────────────────────── */
export const WORK_ORDER_MODES = ['execution', 'acceptance'] as const;
export const workOrderModeSchema = z.enum(WORK_ORDER_MODES);
export type WorkOrderMode = z.infer<typeof workOrderModeSchema>;
export const WORK_ORDER_MODE_LABEL: Readonly<Record<WorkOrderMode, string>> = {
execution: '执行',
acceptance: '验收',
};
/**
* 办结口径:执行件只有「已提交」,验收件是放行三选一 —— 与回传协议同一套标识符。
* 界面文案由字典给出(与原型逐字一致),不在组件里散落中文。
*/
export const WORK_ORDER_RESULTS = ['submitted', 'pass', 'redo', 'revise'] as const;
export const workOrderResultSchema = z.enum(WORK_ORDER_RESULTS);
export type WorkOrderResult = z.infer<typeof workOrderResultSchema>;
export const WORK_ORDER_RESULT_LABEL: Readonly<Record<WorkOrderResult, string>> = {
submitted: '已提交',
pass: '验收通过',
redo: '驳回重做',
revise: '驳回迭代',
};
/** 驳回两式 —— 退回即重来,判定处只此一处 */
export function isRejection(result: WorkOrderResult): boolean {
return result === 'redo' || result === 'revise';
}
/** 完成回传:谁、何时、答了什么(回传即留痕) */
export const workDoneSchema = z.object({
at: z.iso.datetime(),
by: staffIdSchema,
result: workOrderResultSchema,
values: z.record(z.string(), z.string()).optional(),
});
export type WorkDone = z.infer<typeof workDoneSchema>;
const workOrderBase = {
id: workOrderIdSchema,
at: z.iso.datetime(),
title: z.string(),
/** 调用必附上下文 —— basis 为空的工单基座必须拒发 */
basis: basisSchema,
stage: stageIdSchema.optional(),
done: workDoneSchema.optional(),
};
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
export const executionWorkOrderSchema = z.object({
...workOrderBase,
mode: z.literal('execution'),
/** 系统预置形态标记:landing-failure = 落地失败卡(OS 7.3) */
preset: z.enum(['landing-failure']).optional(),
ask: z.string(),
standard: z.string().optional(),
steps: z.array(z.string()).optional(),
/** 报告正文取自哪份过程成果 —— 人要在数据上作判断,数据就得摆在表单旁边 */
basedOn: deliverableIdSchema.optional(),
blocks: z.array(viewBlockSchema).optional(),
fields: z.array(formFieldSchema),
submitLabel: z.string().optional(),
});
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
export const acceptanceCriterionCheckSchema = z.object({
label: z.string(),
level: acceptanceLevelSchema,
pass: z.boolean(),
});
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
export const actionIntentViewSchema = z.object({
label: z.string(),
effect: z.enum(['read', 'write']),
detail: z.string(),
});
export type ActionIntentView = z.infer<typeof actionIntentViewSchema>;
export const ACTION_EFFECT_LABEL: Readonly<Record<'read' | 'write', string>> = {
read: '读',
write: '写',
};
/** 验收工单:AI 拟好的可修订报告 + 判据逐条核对 + 放行三选一 */
export const acceptanceWorkOrderSchema = z.object({
...workOrderBase,
mode: z.literal('acceptance'),
deliverable: deliverableIdSchema,
criteria: z.array(acceptanceCriterionCheckSchema),
actions: z.array(actionIntentViewSchema).optional(),
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
fields: z.array(formFieldSchema).default([]),
passLabel: z.string().optional(),
});
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
export const workOrderSchema = z.discriminatedUnion('mode', [
executionWorkOrderSchema,
acceptanceWorkOrderSchema,
]);
export type WorkOrder = z.infer<typeof workOrderSchema>;
/* ── 对话卡族(决策 / 补全 / 评价) ──────────────────────────────────── */
/** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */
export const AI_TAGS = ['conclusion', 'guidance', 'criterion'] as const;
export const aiTagSchema = z.enum(AI_TAGS);
export type AiTag = z.infer<typeof aiTagSchema>;
export const AI_TAG_LABEL: Readonly<Record<AiTag, string>> = {
conclusion: '结论',
guidance: '指导',
criterion: '判据',
};
const chatBase = { id: cardIdSchema, at: z.iso.datetime() };
/** 决策卡:分支条件选择表;含自定义收尾项 */
export const decisionCardSchema = z.object({
...chatBase,
k: z.literal('decision'),
ask: z.string(),
basis: basisSchema,
recommend: z.string().optional(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
detail: z.string().optional(),
consequence: z.string().optional(),
recommended: z.boolean().optional(),
}),
),
/** 自定义收尾项:人可给出选项之外的处置(附理由) */
allowCustom: z.boolean().default(false),
chosen: z
.object({
value: z.string(),
at: z.iso.datetime(),
by: staffIdSchema,
note: z.string().optional(),
})
.optional(),
});
export type DecisionCard = z.infer<typeof decisionCardSchema>;
/** 补全卡:就地要几个小信息;含「没法填」旁路 —— 两条路必走其一 */
export const elicitationCardSchema = z.object({
...chatBase,
k: z.literal('elicitation'),
ask: z.string(),
basis: basisSchema.optional(),
fields: z.array(
z.object({ key: z.string(), label: z.string(), placeholder: z.string().optional() }),
),
done: z
.object({
at: z.iso.datetime(),
by: staffIdSchema,
values: z.record(z.string(), z.string()),
/** 没法填的理由 —— 人让 AI 迭代一轮需求 */
reason: z.string().optional(),
})
.optional(),
});
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
export const ratingCardSchema = z.object({
...chatBase,
k: z.literal('rating'),
ask: z.string(),
basis: basisSchema.optional(),
done: z
.object({
at: z.iso.datetime(),
by: staffIdSchema,
score: z.number().int().min(1).max(5),
note: z.string().optional(),
})
.optional(),
});
export type RatingCard = z.infer<typeof ratingCardSchema>;
export const chatCardSchema = z.discriminatedUnion('k', [
decisionCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
export type ChatCard = z.infer<typeof chatCardSchema>;
/* ── 对话流消息 ───────────────────────────────────────────────────────── */
export const chatMessageSchema = z.discriminatedUnion('k', [
z.object({
...chatBase,
k: z.literal('ai'),
text: z.string(),
tag: aiTagSchema.optional(),
/** 指涉的工单 id —— 对话区点击即让视图区定位到那张工单 */
ref: workOrderIdSchema.optional(),
}),
z.object({ ...chatBase, k: z.literal('human'), text: z.string(), by: staffIdSchema }),
z.object({
...chatBase,
k: z.literal('system'),
type: z.enum(['data', 'time', 'human', 'control']),
text: z.string(),
}),
decisionCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
export type ChatMessage = z.infer<typeof chatMessageSchema>;
/* ── 回传协议(前端 → 后端;后端以同一 schema 校验,§9.2) ───────────── */
export const workOrderReplySchema = z.discriminatedUnion('mode', [
z.object({
mode: z.literal('execution'),
workOrderId: workOrderIdSchema,
values: z.record(z.string(), z.string()),
}),
z.object({
mode: z.literal('acceptance'),
workOrderId: workOrderIdSchema,
/** 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 驳回必附意见 */
verdict: z.union([z.literal('pass'), rejectionKindSchema]),
/** 报告的人工修订(可空 = 全盘采信 AI 拟值) */
values: z.record(z.string(), z.string()).default({}),
comment: z.string().optional(),
/** 选择组的出口选择:恰发一口,随放行一并确认(AM 1.2) */
selectedPorts: z.record(z.string(), z.string()).default({}),
}),
]);
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
export const chatCardReplySchema = z.discriminatedUnion('k', [
z.object({
k: z.literal('decision'),
cardId: cardIdSchema,
value: z.string(),
note: z.string().optional(),
}),
z.object({
k: z.literal('elicitation'),
cardId: cardIdSchema,
values: z.record(z.string(), z.string()).default({}),
/** 「没法填」旁路 */
reason: z.string().optional(),
}),
z.object({
k: z.literal('rating'),
cardId: cardIdSchema,
score: z.number().int().min(1).max(5),
note: z.string().optional(),
}),
]);
export type ChatCardReply = z.infer<typeof chatCardReplySchema>;
/**
* 驳回必附意见 —— 三规矩之一的机检形态(OS 05)。
* 后端回传校验与前端提交校验共用本函数。
*/
export function rejectionNeedsComment(reply: WorkOrderReply): boolean {
return reply.mode === 'acceptance' && reply.verdict !== 'pass' && !reply.comment?.trim();
}
/** 补全卡两条路必走其一:要么填值,要么给「没法填」的理由 */
export function elicitationReplyIsEmpty(reply: ChatCardReply): boolean {
if (reply.k !== 'elicitation') return false;
const hasValue = Object.values(reply.values).some((v) => v.trim() !== '');
return !hasValue && !reply.reason?.trim();
}