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

手改是**兜 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
+259 -5
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import type { RouteContract } from './http.ts';
import { disableCheckSchema, grantSchema, positionSchema, staffSchema, workGroupSchema } from '../org.ts';
import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts';
import { ledgerObjectKindSchema } from '../ledger.ts';
import { approvalTemplateSchema, closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts';
import { controlSemanticSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
import { activityManifestSchema } from '../manifest.ts';
@@ -230,7 +231,10 @@ export const govTaskLogRoute = {
path: '/api/gov/tasks/:taskId/log',
params: z.object({ taskId: taskIdSchema }),
output: z.object({
log: z.array(z.object({ id: z.string(), at: z.string(), sideLabel: z.string(), title: z.string() })),
// 与工作台同一份日志:章是四字动作,内容带主语(见 workbench 的 log 项)
log: z.array(
z.object({ id: z.string(), at: z.string(), sideLabel: z.string(), action: z.string(), title: z.string() }),
),
}),
} as const satisfies RouteContract;
@@ -511,7 +515,23 @@ export const listActivitiesRoute = {
*/
contract: z.object({
requiredInputs: z.array(z.string()),
inputFields: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })),
inputFields: z.array(
z.object({
name: z.string(),
type: z.string(),
required: z.boolean(),
/** 人读名(清单里的 title)—— 表单上摆中文,不摆字段名 */
label: z.string().optional(),
/** `file` = 这一格收**真文件**(界面给文件选择器);缺席 = 普通文本格 */
format: z.string().optional(),
}),
),
/**
* 包自带的样例集 —— 试跑一个活动时**照它预填**。
* 「首装审以静态审 + 样例集替代回放审」的那份样例,本来就是这个活动
* 「一单长什么样」的权威说法;让人对着空表单自己编,编出来的多半跑不通。
*/
samples: z.array(z.object({ name: z.string(), input: z.record(z.string(), z.unknown()) })).default([]),
outputPorts: z.array(
z.object({
name: z.string(),
@@ -559,7 +579,24 @@ export const listLedgerObjectsRoute = {
path: '/api/gov/data/objects',
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
output: z.object({
objects: z.array(z.object({ domain: z.string(), object: z.string(), label: z.string().optional() })),
objects: z.array(
z.object({
domain: z.string(),
object: z.string(),
label: z.string().optional(),
/**
* 这张表的**性质**(单据 / 主数据 / 派生)—— 取自装在库里的活动清单声明。
*
* 物化出来的物理表本身不知道自己是什么:表里只有列和主键。
* 而「该不该管它、谁有权改它」全看性质,所以这一维必须摆到治理台上 ——
* 从前没有它,一屏 19 张表看着一模一样,人分不出哪几张是要治理的主数据。
* 缺省 = 库里有这张表,却没有哪个在装的活动声明它(多半是退役活动留下的壳)。
*/
kind: ledgerObjectKindSchema.optional(),
/** 单据的单标识列名 —— 呈现上摆出来,人一眼看得出「这张表按什么分单」 */
caseKey: z.string().optional(),
}),
),
}),
} as const satisfies RouteContract;
@@ -713,6 +750,31 @@ export const testRunRoute = {
output: z.object({ workflowId: z.string(), state: workflowStateSchema }),
} as const satisfies RouteContract;
/**
* 仿真测试台 · 试跑**一个活动** —— 与试跑流程包并列的第二种试法。
*
* 为什么要单有一条:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
* 走流程包试,前面几个节点的噪音全得先趟一遍,而且每改一次活动就要重跑整条流。
*
* 起出来的是**真任务**(不挂工作流),照旧落到被指派人的工作台、照旧过 Harness 与放行门 ——
* 试跑不等于假跑:假跑试不出任何东西。区别只在它带 `origin='testbed'`,可以连根拔掉。
*/
export const testRunActivityRoute = {
method: 'POST',
path: '/api/gov/testbed/run-activity',
body: z.object({
activityId: z.string().min(1),
/** 起单入参 —— 按该活动契约的 `input.data` 填;可用包自带样例预填 */
data: z.record(z.string(), z.unknown()).default({}),
/** 委托消息(可空)—— 契约允许时才有意义 */
message: z.string().default(''),
/** 派给谁;留空 = 操作者自己(试跑不该派到别人工作台上) */
ownerId: z.string().default(''),
}),
output: z.object({ taskId: z.string(), title: z.string() }),
} as const satisfies RouteContract;
/**
* 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。
* 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。
@@ -720,15 +782,207 @@ export const testRunRoute = {
export const testStopRoute = {
method: 'POST',
path: '/api/gov/testbed/stop',
body: z.object({ workflowId: z.string() }),
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
body: z.object({ workflowId: z.string().default(''), taskId: z.string().default('') }),
output: z.object({ destroyed: z.boolean(), tasks: z.number().int() }),
} as const satisfies RouteContract;
/** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */
/**
* 当前这一台仪器上的试跑单(测试台一次只跑一条)—— 刷新页面也认得回来。
*
* **返回还在跑的那一单,以及刚跑完的那一单** —— 两者要分得开:
* `live` 为真才锁旋钮(在跑的不许换台);为假表示它已收尾,观测面照旧摆着结果给人看,
* 但可以再起一单了。从前不分,已终结的单照样被当成「在跑」——
* 跑完之后旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
*/
export const testCurrentRoute = {
method: 'GET',
path: '/api/gov/testbed/current',
output: z.object({ workflowId: z.string().optional(), subject: z.string().optional() }),
output: z.object({
workflowId: z.string().optional(),
subject: z.string().optional(),
/** 单活动试跑:没有工作流,认的是任务 */
taskId: z.string().optional(),
activityId: z.string().optional(),
/** 还在跑(未终结)—— 只有它为真才算「占着这台仪器」 */
live: z.boolean().default(false),
/** 已收尾时的终态名(released / blocked / terminated …),给观测面收灯用 */
terminal: z.string().optional(),
}),
} as const satisfies RouteContract;
/**
* 单活动试跑的**观测面** —— 一屏看穿这一单:
* 轨迹(每一拍干了什么)· 台账草稿(成果落到哪儿了)· 卡(问了谁什么)· 预算读数。
*
* 流程包那侧看的是 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**,
* 拿 trace 树看不出「第三拍为什么被拒」。
*/
export const testActivityStateRoute = {
method: 'GET',
path: '/api/gov/testbed/activity/:taskId',
params: z.object({ taskId: z.string() }),
output: z.object({
taskId: z.string(),
activityId: z.string(),
title: z.string(),
runtime: taskRuntimeStateSchema,
terminal: terminalStateSchema.optional(),
reason: z.string().optional(),
used: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }),
caps: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }),
/** 每一拍的轨迹:干了什么、拿回了什么、被拒的理由 */
trail: z.array(
z.object({
at: z.string(),
kind: z.string(),
title: z.string(),
detail: z.record(z.string(), z.unknown()).optional(),
}),
),
/** 本单写下的台账草稿 —— 成果就是这些行 */
drafts: z.array(
z.object({
address: z.string(),
key: z.record(z.string(), z.unknown()),
patch: z.record(z.string(), z.unknown()),
}),
),
/** 待答与已答的卡 */
cards: z.array(
z.object({
id: z.string(),
mode: z.enum(['execution', 'acceptance']),
title: z.string(),
done: z.boolean(),
result: z.string().optional(),
}),
),
}),
} as const satisfies RouteContract;
/* ── 试跑日志:运行数据不过夜,观测记录留下来 ─────────────────────────── */
/* ── 上传件:附件要能真正进活动,不能只剩一个文件名 ────────────────── */
/**
* 上传一个文件 —— 走 base64 而不是 multipart。
*
* 这一层所有路由都由同一套 zod 契约与 typed-fetch 收着,multipart 得在它旁边另开一条
* 不受契约管的路;而这里要传的是 BOM、规格书这类几十 KB 的件,base64 那点膨胀
* 换来「照旧一条契约管到底」是划算的。真大的件不该走界面上传,该走通道。
*/
export const uploadFileRoute = {
method: 'POST',
path: '/api/gov/upload',
body: z.object({
name: z.string().min(1),
contentType: z.string().default(''),
/** 文件字节的 base64 —— 上限 8MB(超了当场拒,不静默截断) */
dataBase64: z.string().min(1),
}),
output: z.object({
ref: z.string(),
name: z.string(),
contentType: z.string(),
size: z.number().int(),
/** 读出来的文本;缺席 = 这类文件读不出文本(理由在 note 里) */
text: z.string().optional(),
note: z.string(),
}),
} as const satisfies RouteContract;
export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
/** 取回原件 —— 存的是真字节,人点得开才算真留住了 */
export const downloadFileRoute = {
method: 'GET',
path: '/api/gov/upload/:id',
params: z.object({ id: z.string() }),
output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }),
} as const satisfies RouteContract;
/**
* 一次试跑的存档 —— **销毁那一刻拍的快照**。
*
* 「停止仿真 = 连根拔掉」这条不变(试跑起的是真任务,不该在别人工作台上过夜);
* 拔掉之前把这一单的观测面存下来。开发活动是反复试的活,「上一版跑了几拍、
* 卡在哪一步、台账写成什么样」正是要拿来比对的东西 —— 拔干净等于每改一次
* 就把上一次的账一并烧了。
*/
export const testLogRowSchema = z.object({
id: z.string(),
kind: z.enum(['activity', 'package']),
subject: z.string(),
/** 试的是谁:activityId 或 packageId */
target: z.string(),
taskId: z.string().optional(),
workflowId: z.string().optional(),
/** 终态名;缺省 = 人中途停的 */
terminal: z.string().optional(),
reason: z.string().optional(),
used: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }).partial(),
caps: z.object({ steps: z.number().int(), tokens: z.number().int(), humanCalls: z.number().int() }).partial(),
/** 列表页只要计数,详情才给正文 —— 一次试跑的轨迹有上百条 */
trailCount: z.number().int(),
draftCount: z.number().int(),
cardCount: z.number().int(),
startedAt: z.string().optional(),
archivedAt: z.string(),
});
export type TestLogRow = z.infer<typeof testLogRowSchema>;
export const listTestLogsRoute = {
method: 'GET',
path: '/api/gov/testbed/logs',
output: z.object({ runs: z.array(testLogRowSchema) }),
} as const satisfies RouteContract;
/** 一次试跑的正文 —— 逐拍轨迹 / 台账草稿 / 发过的卡 */
export const testLogDetailRoute = {
method: 'GET',
path: '/api/gov/testbed/logs/:id',
params: z.object({ id: z.string() }),
output: testLogRowSchema.extend({
trail: z.array(
z.object({
at: z.string(),
kind: z.string(),
title: z.string(),
detail: z.record(z.string(), z.unknown()).optional(),
}),
),
drafts: z.array(
z.object({
address: z.string(),
key: z.record(z.string(), z.unknown()),
patch: z.record(z.string(), z.unknown()),
}),
),
cards: z.array(
z.object({
id: z.string(),
mode: z.string(),
title: z.string(),
done: z.boolean(),
result: z.string().optional(),
}),
),
}),
} as const satisfies RouteContract;
/** 删一条 / 清空 —— 日志是给人比对用的,留多久由人说了算 */
export const deleteTestLogRoute = {
method: 'DELETE',
path: '/api/gov/testbed/logs/:id',
params: z.object({ id: z.string() }),
output: z.object({ deleted: z.number().int() }),
} as const satisfies RouteContract;
export const clearTestLogsRoute = {
method: 'DELETE',
path: '/api/gov/testbed/logs',
output: z.object({ deleted: z.number().int() }),
} as const satisfies RouteContract;
/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */
+125 -1
View File
@@ -53,9 +53,21 @@ export const taskCardSchema = z.object({
title: z.string(),
runtime: taskRuntimeStateSchema,
terminal: terminalStateSchema.optional(),
/** 它在工作流上的序位 */
/** 它在工作流上的序位 —— **与下面的阶段进度不是一回事**:这是「第几个活动」 */
position: z.number().int().min(1),
total: z.number().int().min(1),
/**
* 活动**自己**的阶段进度 —— 进度条读的是它。
*
* 从前进度条读的是上面那对序位:A1 在七节点流里是 1/7,一开跑就画成 14%,
* 跑到收口还是 14%(节点没换,序位当然不动);单活动试跑更荒唐 —— 恒 1/1,
* 起单那一刻就满格。序位答的是「这单在整条流的哪一环」,不是「这单办到哪了」。
*
* 活动没声明阶段就没有这个键:界面照此不画进度条,不编一个出来。
*/
phase: z
.object({ index: z.number().int().min(1), total: z.number().int().min(1), name: z.string() })
.optional(),
workflowSubject: z.string(),
workflowState: workflowStateSchema,
startedAt: z.string(),
@@ -111,6 +123,39 @@ export const taskDetailSchema = z.object({
file: fileDocSchema,
}),
),
/**
* 沟通记录 —— 跟客户/供应商往来的**原件**,按单归档。
* 进来的那一封是开案的那份料(活动的入参就是它);出去的是放行后系统真发的件。
*/
comms: z
.array(
z.object({
id: z.string(),
at: z.string(),
direction: z.enum(['in', 'out']),
channel: z.string(),
party: z.string(),
subject: z.string(),
body: z.string(),
attachments: z.array(
z.object({
name: z.string(),
size: z.number().int().optional(),
ref: z.string().optional(),
/** 基座读出来的说明:读出多少字 / 为什么读不出 —— 界面照抄,不改写 */
note: z.string().optional(),
readable: z.boolean().optional(),
}),
),
note: z.string(),
/**
* **未读**(2026-07-31)——「看没看过」由人按按钮说了算,不由打开页签自动清。
* 自动清等于替人宣布「你看过了」,而他可能只是路过那个页签。
*/
unread: z.boolean().default(false),
}),
)
.default([]),
workOrders: z.array(workOrderSchema),
chat: z.array(chatMessageSchema),
view: z.array(viewBlockSchema),
@@ -122,6 +167,17 @@ export const taskDetailSchema = z.object({
at: z.string(),
side: attributionSideSchema,
sideLabel: z.string(),
/**
* 这一条**是谁干的** —— 日志只认三档:`owner` 负责人 · `agent` 智能体 · `system` 系统。
*
* 与 `side`(五种归侧)分工不同:那一维是**度量**用的(自主率要把「等外面」
* 与「机器干的」分开);这一维是**呈现**用的 —— 人扫这一屏只问
* 「这一步是我办的、AI 办的,还是流程自己走到的」,三档各一个颜色。
*/
who: z.enum(['owner', 'agent', 'system']),
/** 类型章:**四字短语**,与对话区同词(触发事件 / 阶段结论 / 数据录入 / 员工确认 …) */
action: z.string(),
/** 内容,**带主语**:`任务助手:…` / `柴浩:…` —— 日志不写没头脑的流水账 */
title: z.string(),
summary: z.string().optional(),
}),
@@ -155,6 +211,74 @@ export const replyChatCardRoute = {
output: z.object({ ok: z.boolean() }),
} as const satisfies RouteContract;
/**
* 标记沟通记录已读 / 未读 —— **气泡计数跟着这一下走**。
* 不做「打开即已读」:那是替人宣布他看过了。已读还能改回未读(看了一半想留个记号)。
*/
export const markCommReadRoute = {
method: 'POST',
path: '/api/desk/comms/:commId/read',
params: z.object({ commId: z.string() }),
body: z.object({ read: z.boolean() }),
output: z.object({ ok: z.boolean(), unread: z.boolean() }),
} as const satisfies RouteContract;
/**
* **就地改完按下「保存」** —— 把件上那张表写回**本单草稿层**,工单不办结。
*
* 为什么非要立刻落库、而不是攒到提交那一下:件是**服务端投影**。
* 人改了几格,只读那份跟着变了(那是他自己敲的),而由数据算出来的东西
* ——类徽 A/B/C、分布条、结论横幅那条去向——全是渲染那一刻的旧值。
* 于是屏上一半新一半旧:**人把三条料全改成 A,底下的去向还写着「去选型」**
* (用户实测)。那不是没更新,那是界面在撒谎。
*
* 落库之后前端重取任务投影,服务端拿新数据重渲染整份件 —— 算出来的那些跟着一起变,
* 去向也由同一次谓词求值重算。**放行门的位置没变**:写的仍是草稿层,
* 合并进正式版依旧只发生在人签字那一刻。
*
* ── 按**地址**收,不按「当下那张卡的第几块」收(2026-08-02 用户实测逼出来)──────
*
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由**那张工单的 `blocks[0]`**
* 给出,于是「这份件能不能改」被绑在了「当下开着的那张卡长什么样」上。
* 实测的下场:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
* 而屏上摆着的正是受理记录那份件,闸也在、编辑也进得去 —— 人把那条料的年用量补上、
* 按了保存,前端找不到匹配的块,**一格都没往下送**。他要补的恰恰就是把这一单
* 卡在那儿的那个必填格(判据 `quote_intake_record_missing = 0`),
* 而屏上什么反应都没有 —— 界面提供了一个落不了地的编辑口。
*
* 现在收的是件自己说的话:每张可改的表挂着 `data-edit="域.对象"`,格子挂着
* `data-col="台账列名"` —— 地址与列名**本来就在件里**,不必再绕一趟工单。
* 授权面改由**活动清单的台账声明**把关(不在声明里的地址一概不写),
* 比「当下那张卡碰巧带了哪一块」既严格又稳定。
*
* **改、增、删是同一件事的三种样子**(2026-08-02 用户:「编辑列表应该还能增删行」):
* 人手改本来就是**兜 AI 的错**的那一手 —— 而错不只有「这一格填错了」,
* 还有「这一行根本不该在」(客户点名本轮跳过的料被写了进来)与「漏了一行」。
* 只给改格子,前两种就没有出口:那一单会一路走到放行门被判据拦住,
* 而人明明一眼就看出问题在哪。
*/
export const saveTableRoute = {
method: 'POST',
path: '/api/desk/tasks/:taskId/table',
params: z.object({ taskId: taskIdSchema }),
body: z.object({
/** `{ '域.对象': [ { 台账列名: 值 } ] }` —— 主键列用来认行,不参与改行;台账里没有的行就是**新增** */
tables: z.record(z.string(), z.array(z.record(z.string(), z.string()))),
/**
* **人在件上删掉的那几行** —— 摆主键,一行一条。
*
* 为什么删要单独说,而不是「`tables` 里没有的就当删了」:那种口径把**一次不完整的
* 回传**变成一次删表 —— 件上摆的是不是整张表,服务端看不见(模版绑哪条路径是活动的事)。
* 说出来的删才删:回传缺了什么一律当「这次没提到」,不当「要撤掉」。
*
* 撤的只是**本单草稿层**那一笔(`draftDrop`),正式版一个字都动不了、不抛事件 ——
* 与 Agent 的 `drop` 同一条路子。放行门的位置没变。
*/
drops: z.record(z.string(), z.array(z.record(z.string(), z.string()))).optional(),
}),
output: z.object({ ok: z.boolean(), rows: z.number().int().min(0) }),
} as const satisfies RouteContract;
/** 负责人的自由输入 —— 人事件的第二形态 */
export const sayRoute = {
method: 'POST',
+233 -5
View File
@@ -64,6 +64,14 @@ export const formFieldSchema = z.object({
hint: z.string().optional(),
/** AI 拟值 —— 验收工单落地时已填好,人可改全部字段 */
ai: z.string().optional(),
/**
* 这个值改完**落回哪个台账地址**(`域.对象`)—— 只有验收单的修订字段需要。
*
* 没有它,人在验收报告上改的值只留在工单留痕里,落地时用的还是 AI 的原值:
* 「验收报告可编辑」就成了摆设(第 12 章③)。
* 口径:只改**本单在该对象的那一行**;该对象有多行时不猜是哪一行,如实拒改。
*/
ledger: z.string().optional(),
unit: z.string().optional(),
required: z.boolean().optional(),
cols: z.union([z.literal(1), z.literal(2)]).optional(),
@@ -126,6 +134,23 @@ export const viewBlockSchema = z.discriminatedUnion('t', [
rows: z.array(z.array(z.string())),
editable: z.boolean().optional(),
editableCols: z.array(z.number().int()).optional(),
/*
* ── 绑台账:**行由基座铺,不由活动塞**(2026-08-01)────────────────────
*
* 给了 `ledger` + `fields`,发卡那一刻基座就从这一单的草稿层把行铺进来
* (`rows` 写不写都会被铺出来的行盖掉)。
*
* 为什么必须这样:人要在卡上改的是**那份清单本身**(PRD 的执行页就是整份 9 行
* 逐格可改),而让活动把 9 行塞进回复里有两处死结 ——
* · 回复有长度上限,铺长了半途截断,整拍作废(实测);
* · 那些行**台账里已经有了**,让模型再抄一遍是拿 token 换一次抄错的机会。
*
* `fields` 与 `cols` 一一对应:`cols` 是给人看的列名,`fields` 是台账列名。
* 人提交之后,基座按同样的对应关系把改动写回草稿层并留痕 —— AI 不经手
* (与数据录入卡同一条规矩:该由人亲手落下的,就别在中间加一道传话)。
*/
ledger: z.string().optional(),
fields: z.array(z.string()).optional(),
linkDonut: z
.object({
col: z.number().int(),
@@ -301,6 +326,28 @@ export const executionWorkOrderSchema = z.object({
blocks: z.array(viewBlockSchema).optional(),
fields: z.array(formFieldSchema),
submitLabel: z.string().optional(),
/**
* **必填还空着就按不下提交**(2026-08-02 用户定)—— 由基座按台账声明**现算**。
*
* 起因是实测的一单:受理需求那张卡上有一条料缺年用量(必填),人照样交了回来;
* 这一单于是一路走到放行门,被 L1 机检 `quote_intake_record_missing = 0` 拦住,
* 模型连拒五拍熔断 —— **同一件事,在提交那一刻拦下比在第 10 拍拦下便宜得多**:
* 那时缺的是哪一格就摆在他眼前;到了放行门,他只看见一条红判据和一个停住的单。
*
* 「必填」是**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)——
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
* 按钮照常能按:**没有依据的时候不拦人**。
*/
gate: z
.object({
/** 能交了吗 */
ready: z.boolean(),
/** 交不了的话,一句话说清差在哪 */
why: z.string(),
/** 差在哪几行哪一格 —— 摆主键 + 中文列名,人照着就能找到那一行 */
blanks: z.array(z.string()).default([]),
})
.optional(),
});
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
@@ -308,6 +355,20 @@ export const acceptanceCriterionCheckSchema = z.object({
label: z.string(),
level: acceptanceLevelSchema,
pass: z.boolean(),
/**
* 裁量项(L3/L4)**裁过没有** —— 机检项与 L5 不带这一维(它们不是裁出来的)。
*
* 「裁了但裁成不满足」与「压根没裁」是两件完全不同的事:前者是 AI 办过、
* 办不到,把判断交给人;后者是活没干完。从前卡上只有一个 `pass`,
* 两者长得一模一样(都是红叉「未达标」)—— 人看到一条永远不绿的项,
* 既不知道 AI 有没有办过,也不知道它为什么办不到,只能凭感觉签。
*/
settled: z.boolean().optional(),
/**
* 裁断的理由 —— **裁成「不满足」的那一条,这句话才是签字的人要看的东西**。
* 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。
*/
rationale: z.string().optional(),
});
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
@@ -334,10 +395,21 @@ export const acceptanceWorkOrderSchema = z.object({
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
fields: z.array(formFieldSchema).default([]),
/**
* 选择组(活动契约的 selective 出口):**恰发一口,随放行一并确认**(AM 1.2)。
* 人选的那一口就是这次运行的分支结论(如议价的 成交/有异议/拒绝),
* 下游异或网关判的正是它 —— 所以放行时必须恰选一个,选口即裁分支。
* 没有选择组的活动此字段缺省,验收单照旧只有通过/重做。
* 验收页的正文块 —— 与执行工单同一套件(可含**绑台账的可编辑表**)。
*
* 验收是这一单最重的一道门:人得能在**成果本身**上改,而不是只在几个整单级字段上改。
* 绑了台账的表由基座铺行、由基座写回(`blocks[].ledger` + `fields`),AI 不经手 ——
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
*/
blocks: z.array(viewBlockSchema).optional(),
/**
* 选择组(活动契约的 selective 出口)—— **只在算不出去向时才摆给人选**。
*
* 去向能算就别问:契约里每个出口都写了 `when` 谓词时,基座按序求值(首个命中即出),
* 结论随放行一并落定,卡上只如实**告诉**人走哪一口、凭什么(见 `routedPort`)。
* 「这一单往哪走」是分类表算出来的事实,不是一道让人凭印象点的选择题 ——
* 让人选,等于把一个可回放的判定改成一次不可回放的手感。
* 只有当出口没写全谓词(算不出来)时,才退回这张选择题。
*/
portChoice: z
.object({
@@ -345,6 +417,42 @@ export const acceptanceWorkOrderSchema = z.object({
ports: z.array(z.object({ name: z.string(), label: z.string().optional() })).min(2),
})
.optional(),
/**
* **这一签下去,世界上发生了什么**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
*
* 只在办结之后才有。放行卡上摆的是**签字前要核的东西**(判据);
* 签完之后人关心的换成了另一件事:落了几行、进了哪几张表、转去哪儿了。
* 从前这两件事共用一屏判据表 —— 签完再回看,那一摞对勾说的还是签之前的话。
*
* 数据来自放行那一刻的结构化留痕(逐表计数 + 发出的口),不是把日志句子再解析一遍。
*/
landing: z
.object({
/** 合并进正式版的总行数 */
rows: z.number().int().min(0),
/** 试跑挡下的主数据行数 —— 非 0 就得说,不然「合并 N 行」是假的 */
simulated: z.number().int().min(0).default(0),
/** 逐表:进了哪张表、几行 */
tables: z.array(z.object({ label: z.string(), rows: z.number().int().min(0) })).default([]),
/** 转去哪儿了 —— 摆出口的人读名;一个都没有就是这条流到此为止 */
ports: z.array(z.string()).default([]),
/** 带了哪些字段过去 —— 下游活动读到的就是这几个(摆名字不摆值) */
carried: z.array(z.string()).default([]),
})
.optional(),
/**
* **算出来的去向** —— 契约的出口谓词按序求值,首个命中即出。
* 卡上只告诉人「走哪一口、凭哪一条」,不给他改;放行时按它发口。
*/
routedPort: z
.object({
group: z.string(),
name: z.string(),
label: z.string().optional(),
/** 命中的那条谓词原文 —— 人要回核「凭什么走这条」 */
because: z.string(),
})
.optional(),
passLabel: z.string().optional(),
});
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
@@ -406,7 +514,17 @@ export const elicitationCardSchema = z.object({
ask: z.string(),
basis: basisSchema.optional(),
fields: z.array(
z.object({ key: z.string(), label: z.string(), placeholder: z.string().optional() }),
z.object({
key: z.string(),
label: z.string(),
placeholder: z.string().optional(),
/**
* 预填值 —— AI 从来件里读得出的先替人填上(公司名、联系人姓名)。
* 人核对一眼、补上填不出的那个即可。**读不出来的别给**:
* 填错了人得先擦掉再写,比空着更费事。
*/
value: z.string().optional(),
}),
),
done: z
.object({
@@ -420,6 +538,99 @@ export const elicitationCardSchema = z.object({
});
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
/**
* **数据录入卡** —— 人类工具的第五类(2026-07-31 用户定)。
*
* 与补全卡的差别是**谁写库**:
* · 补全卡:人填的值进轨迹 → **AI 再转写进台账**。中间隔一道传话,
* 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。
* · 数据录入卡:**人按下确认那一刻就是写入** —— 卡上写着落哪张表、哪个主键,
* 确认即落草稿层。AI 只负责把能填的先填好,填错了人当场改。
*
* 所以它适合「建档」这类事:客户档案是主数据,一旦建错后面每一单都跟着错;
* 那一笔该由人亲手落下,不该经 AI 的手转述一遍。
*
* 仍守铁律②:确认写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。
*/
/** 录入卡的一格 —— 主段与 `also` 各段同一形状 */
const entryFieldSchema = z.object({
key: z.string(),
label: z.string(),
/** AI 预填的值 —— 人可改;填不出的留空,界面标「待填写」 */
value: z.string().optional(),
/** 必填的空着不让确认 —— 缺了这一格这张档就是残的 */
required: z.boolean().default(false),
placeholder: z.string().optional(),
});
export const dataEntryCardSchema = z.object({
...chatBase,
k: z.literal('entry'),
ask: z.string(),
basis: basisSchema.optional(),
/** 落哪张表(`域.对象`)—— 确认那一下写的就是它 */
ledger: z.string().min(1),
/**
* 那张表**给人看的名字**(台账登记的表注释,如「客户档案」)。
*
* 卡面上摆的是它,不是 `customer.profile` —— 界面不摆系统内的英文名
* (用户 2026-08-02:「这种卡片里说:确认后写入 xxx数据库」)。
* 登记里没有人读名时不给这一项,卡面退回对象地址:宁可笼统,不可编。
*/
ledgerLabel: z.string().optional(),
/** 主键值:确认时按它定位那一行 */
key: z.record(z.string(), z.string()),
/** 主键那几列的中文列名 —— 卡面摆它,不摆 `customer_code`(界面不摆系统内的英文名) */
keyLabels: z.record(z.string(), z.string()).optional(),
fields: z.array(entryFieldSchema),
/**
* **这一段有几列是基座自己生成的** —— 摆一句话告诉人「这个不用你填」。
*
* 声明里标了 `auto` 的列(如联系人编码)压根不进 `fields`:
* 让人填是白占一格他答不上来的表单。但**不能一声不吭地拿掉** ——
* 那样他会以为这张档少了一格。
*/
autoNote: z.string().optional(),
/**
* **同一次确认还要落的另几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
*
* 建一位新客户落的是**两条主数据**:公司档(`customer.profile`)与
* 联系人档(`customer.contact`)。从前卡上只有公司那一半,联系人由 AI 自己写 ——
* 而联系人档里的 `contact_code` 是它**编**出来的,人一眼没看过。
* 主数据编错一次,后面每一单都跟着错,这正是数据录入卡存在的理由。
*
* 分成两张卡也能办,但那是把**一件事**(建档)拆成两次打断;
* 一张卡两段,人一次看全、一次确认,基座一次落两行。
*
* 值按段隔开:`values` 的键是 `段号.列名`(主段是 `0.`)—— 两张表都有 `name` 这一列,
* 平铺就会互相盖掉,而盖掉之后没人看得出来。
*/
also: z
.array(
z.object({
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
title: z.string(),
ledger: z.string().min(1),
ledgerLabel: z.string().optional(),
key: z.record(z.string(), z.string()),
keyLabels: z.record(z.string(), z.string()).optional(),
fields: z.array(entryFieldSchema),
autoNote: z.string().optional(),
}),
)
.optional(),
done: z
.object({
at: z.iso.datetime(),
by: staffIdSchema,
/** 键是 `段号.列名`(主段 `0.`)—— 见 `also` 的注释 */
values: z.record(z.string(), z.string()),
})
.optional(),
});
export type DataEntryCard = z.infer<typeof dataEntryCardSchema>;
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
export const ratingCardSchema = z.object({
...chatBase,
@@ -439,6 +650,7 @@ export type RatingCard = z.infer<typeof ratingCardSchema>;
export const chatCardSchema = z.discriminatedUnion('k', [
decisionCardSchema,
dataEntryCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
@@ -461,8 +673,14 @@ export const chatMessageSchema = z.discriminatedUnion('k', [
k: z.literal('system'),
type: z.enum(['data', 'time', 'human', 'control']),
text: z.string(),
/**
* 这条系统消息带一个「查看邮件」的去处 —— 原件在沟通记录里,一步跳过去。
* **点不点都不挡路**:开场白之后 AI 照常开跑,不等人按。
*/
openComms: z.boolean().optional(),
}),
decisionCardSchema,
dataEntryCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
@@ -504,6 +722,16 @@ export const chatCardReplySchema = z.discriminatedUnion('k', [
/** 「没法填」旁路 */
reason: z.string().optional(),
}),
/**
* 数据录入卡的确认 —— **这一下就是写入**。
* 基座按卡上声明的 `ledger` + `key` 把 `values` 落进草稿层,并留一条痕:
* 谁、何时、往哪张表写了什么。AI 不经手。
*/
z.object({
k: z.literal('entry'),
cardId: cardIdSchema,
values: z.record(z.string(), z.string()),
}),
z.object({
k: z.literal('rating'),
cardId: cardIdSchema,
+84
View File
@@ -24,6 +24,27 @@ export const contractInputSchema = z.object({
/** message:委托消息(自然语言),可为空;**不得携带扩大授权的效力** */
messageAllowed: z.boolean().default(true),
messageHint: z.string().optional(),
/**
* **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件。
*
* 声明了,基座在起单那一刻就把它归进任务的**沟通记录**(工作台那一屏),
* 原件与附件一并存下:谈过什么、什么时候谈的,回头查得到。
*
* 为什么是**声明**而不是活动跑起来之后再做一遍:这件事没有临场判断的成分 ——
* 「入参的 subject 就是来件主题」是活动作者写清单时就知道的事实。
* 交给模型每单认一遍,是拿一整拍的 token 去换一个常量。
* 值填的是**入参里的字段名**,不是字面内容。
*/
inboundComm: z
.object({
channel: z.string().default('mail'),
/** 对方是谁 —— 填入参里那个字段的名字(如 `customerEmail`) */
partyField: z.string().min(1),
subjectField: z.string().optional(),
bodyField: z.string().optional(),
attachmentsField: z.string().optional(),
})
.optional(),
});
export type ContractInput = z.infer<typeof contractInputSchema>;
@@ -34,6 +55,19 @@ export const outputPortSchema = z.object({
/** 交付物 schema —— 调用方仅凭口名与 schema 即可衔接下游 */
payload: jsonSchemaSchema,
description: z.string().optional(),
/**
* **这一口什么时候走** —— 对任务视图求值的 S-FEEL 谓词(选择组内**按序求值,首个命中即出**)。
*
* 组内每一口都写了 `when`,去向就是**算出来的**:放行时基座自己判,
* 卡上只如实告诉人走哪一口、凭哪一条,不摆成选择题。
* 「这一单往哪走」多半是台账里那几个数的函数(有没有 C 类、有没有 B 类)——
* 让人凭印象点一下,等于把一个可回放的判定换成一次不可回放的手感;
* 让模型选,又多一处它可以说了算的地方。
*
* 有一口没写,整组就算不出来,退回「人选一口」那条老路(不是所有分支都算得出来:
* 议价的 成交/有异议/拒绝 就得人裁)。
*/
when: z.string().optional(),
});
export type OutputPort = z.infer<typeof outputPortSchema>;
@@ -59,6 +93,35 @@ export const requiredDeliverableSchema = z.object({
label: z.string(),
/** 缺一不得提交放行 —— 验收工单「成果清单」的来源 */
schema: jsonSchemaSchema.optional(),
/**
* 「有没有产出」的**可机检依据**:台账地址 `域.对象`,该地址在任务视图里有行即算有。
*
* 不给依据的话,「缺一不得提交放行」就只能靠活动自报 —— 自报等于没有门。
* 声明了地址,预检就能自己数行数:活动说交付了、台账里一行没有,当场拒收。
*/
ledger: z.string().optional(),
/**
* **这份成果由哪几张表承载**(2026-08-02 用户当场指出:「料号分类表和受理结论
* 不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)。
*
* 一份成果常常不止一张表:A1 交出的是**一份询价受理记录**,而它由受理记录、
* 料号分类表(判据来源)、受理结论(结论栏)三张表共同承载。
* 从前三张表各占一条 `requiredDeliverables`,人在成果面上就看到三样东西 ——
* 而在业务上他只拿到一份。
*
* 声明了它,落地那一屏按**交付物**点名,三张表的行数并成一行;
* 不声明就退回 `ledger` 那一张 —— 老清单不必改一个字。
*/
ledgers: z.array(z.string()).optional(),
/**
* **验收页上让人就地改的那几列** —— 台账列名,顺序即列序。
*
* 验收是这一单最重的一道门:人得能在**成果本身**上改,而不只是在旁边几个整单级字段上改。
* 给了这几列,基座在放行卡上铺一张绑着 `ledger` 的可编辑表(整份行都在),
* 人改完由基座写回草稿层、随放行合并 —— AI 不经手。
* 不给就没有这张表:哪些列该让人动,是写清单时就该定的事,不是临场发挥。
*/
editableAtRelease: z.array(z.string()).optional(),
});
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
@@ -133,6 +196,27 @@ export const sevenPartContractSchema = z.object({
output: outputPortsSchema,
requiredDeliverables: z.array(requiredDeliverableSchema).default([]),
acceptanceCriteria: z.array(acceptanceCriterionSchema).default([]),
/**
* **放行时人可当场改的值** —— 验收报告「AI 已填好、可编辑」改的就是这几个。
*
* 它是**设计期的事实**,和验收判据一样:哪几个值该让负责人有机会覆盖、
* 改完落回哪个台账地址,由做这个活动的人一次定死,过安装审。
* 让 Agent 每单临场重述一遍是不可靠的 —— 实测它会漏(说了「可直接编辑」而没给字段),
* 而漏掉的后果是验收报告只能看不能改,人工修订形同虚设。
*
* `ai` 不在这儿:拟值是**运行期**的事实 —— 放行前由基座从台账现读活动写下的那个值,
* 不让模型重述一遍(重述就会和台账里的值不一致,而以哪个为准说不清)。
*/
revisableAtRelease: z
.array(
z.object({
key: z.string().min(1),
label: z.string(),
/** 改完落回哪儿:`域.对象`;只改本单在该对象的那一行 */
ledger: z.string().min(1),
}),
)
.default([]),
prohibitions: z.array(prohibitionSchema).default([]),
authorization: authorizationBoundarySchema,
resources: resourceDeclarationSchema,
+111
View File
@@ -64,13 +64,124 @@ export const fieldDeclarationSchema = z.object({
primaryKey: z.boolean().default(false),
/** 治理:脱敏 / 保留期 / 可学习标记(OS 8.1 治理先行) */
masked: z.boolean().default(false),
/**
* **这一列缺了就是活没干完** —— 于是基座数得出「本单这张表缺必填的有几行」。
*
* 不建 NOT NULL:草稿层的本分就是「过程中允许缺」,缺口要摆给人看、由人补,
* 而不是让写入当场炸掉。这一维只служ两件事:
* · 读数 `域_对象_missing` / `_missing_keys` —— 基座数出来的数,判据可以放心读;
* · 报告模版按它标红胶囊(A1 那张清单的「必填 / 选填」两色就是它)。
*
* 为什么要有它(2026-08-01,连跑十轮里三轮死在这):A1 的
* 「必填齐(PN / 年用量)」是 L1 机检,读的却是**活动自己写的** `missing_key_total` ——
* 人在卡上补了年用量、基座写回了草稿层,而那个汇总列要模型自己重算;
* 它算漏一次,这条判据就永远绿不了,连拒五拍熔断。
* **机检判据只许读基座数得出来的数** —— 这一维就是让基座数得出来的那把尺。
*
* 用 `optional` 不用 `default(false)`:后者会把它变成出参必填,
* 六个活动两百来行字段声明都得补一个 `required: false` —— 那是给纪律收噪音税。
* 不写就是不必填,读的那一侧一律 `=== true` 才当真。
*/
required: z.boolean().optional(),
/**
* **这一列是分类列** —— 给出它的取值域,基座就按值现数本单每一类几行。
*
* 出的读数是 `域_对象_<列>_<值>`(值消毒成小写标识符),例如
* `quote_intake_record_type_tag_a`。**没出现的值也铺 0** —— 取值域是声明给的,
* 所以数得全;零条也是一个数,判据才算得下去。
*
* 为什么非要基座来数(2026-08-01 用户当场指出):三分类的条数从前是活动自己写的
* `type_a_total / type_b_total / type_c_total`,而分布条、放行时的去向谓词、验收判据
* 读的全是它们。于是**人在放行页把九条料全改成 A,类徽变了,分布条还是 5/3/1、
* 去向还是「去选型」** —— 逐行数据变了,汇总数没人重算。
* 而**手工修改的全部意义就是纠正 AI 的判断**:改了类标而流向不跟着变,
* 这个兜底功能就是假的。能由数据算出来的,一律别让活动自报。
*/
tally: z.array(z.string().min(1)).optional(),
/**
* **这一列缺了不拦单,但那一问不能省** —— 基座据此数「还有几行缺选填」。
*
* 与 `required` 的分工:必填缺一条都不许放行(它是验收标准);
* 选填缺照样往下走,只是**必须经过人**——问过客户、或放行时确认可以先受理。
*
* 为什么也得基座数(2026-08-02 用户实测:「我这次补了目标价,为啥 L4 还在那乱判」):
* 那条 L4 裁的依据是活动自己写的 `missing_minor_total`。人在件上补了目标价、
* 基座已经写回草稿层,而那个数要模型自己重算 —— 它没算,于是照旧裁「不满足」,
* 人明明补齐了却被告知还缺。**裁量项可以由人来判,但它判的事实必须是真的。**
*/
expect: z.boolean().optional(),
/**
* **这一列由基座生成,既不问人也不让模型编**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
*
* 用在**内部流水号**那一类主键上:联系人编码、行号 —— 它们不承载业务含义,
* 只是「同一位客户下的第几位」。让人填是白占一格他答不上来的表单
* (他哪知道这家客户已经有几位联系人);让模型编更糟,编重了就是覆盖别人那一行。
*
* 生成规则是**声明出来的一条**,不是每处各写一套:
* 取同组其余主键的值作前缀,后面跟两位序号 —— `CU-9001` + `-01`、`-02`……
* 序号数的是**同组已有的行**(正式版 + 本单草稿),所以同一单里加两位联系人也不撞。
* 没有其余主键(它自己就是唯一那把键)时退回纯序号 `01`。
*
* 落在哪一层:人按下确认那一刻由基座补上 —— 与「该由人亲手落下的别经 AI 转述」
* 同一条规矩的另一半:**人答不上来的,也别摆到他面前假装他该答**。
*/
auto: z.boolean().optional(),
});
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
/**
* 台账对象的**性质** —— 这条数据归谁管、活多久、谁有权改它。
*
* 从前台账只有「地址 + 列」,没有这一维,于是**没人需要回答「这张表是什么」**——
* 后果不是不好看,是主键写歪了还没人发现:A4 的报价草案本该一单一份,
* 主键却只有 `pn`,第二单报同一个料号直接 upsert 覆盖上一单
* (实测:跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
* 而主键带了单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少)。
*
* 分三类,判据是**机械的**,不靠感觉:
*
* · `record` 单据 —— 一单一份的过程与结论。主键**必含单标识**(`caseKey`),
* 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出。
* · `master` 主数据 —— 跨单积累的经营资产(客户、供应商、料号规格、历史价)。
* 主键是**业务实体**,会被反复更新,下一单读它做判断。治理都在这一类上:
* 谁能改、依据是什么、怎么去重、多久算过期。
* · `derived` 派生 —— 能由前两类算出来的指标(落地率、命中率)。
* **本不该手写**:手写的派生值能和它的来源打架,而没人会发现。
* 标出来至少让它可被重算校验。
*/
export const LEDGER_OBJECT_KINDS = ['record', 'master', 'derived'] as const;
export const ledgerObjectKindSchema = z.enum(LEDGER_OBJECT_KINDS);
export type LedgerObjectKind = z.infer<typeof ledgerObjectKindSchema>;
export const LEDGER_KIND_LABEL: Readonly<Record<LedgerObjectKind, string>> = {
record: '单据',
master: '主数据',
derived: '派生',
};
export const LEDGER_KIND_DESC: Readonly<Record<LedgerObjectKind, string>> = {
record: '一单一份的过程与结论 —— 放行即定稿,只增不改;查得到、回放得出就够了',
master: '跨单积累的经营资产 —— 会被反复更新,下一单读它做判断;治理都在这一类上',
derived: '能由单据与主数据算出来的指标 —— 不该手写,手写的派生值会和来源打架',
};
export const objectDeclarationSchema = z.object({
domain: identifier,
object: identifier,
label: z.string().optional(),
/**
* 这张表是什么性质 —— 缺省 `record`,因为活动写下的绝大多数是单据。
* 缺省不是「随便」:安装静态审会按 kind 各自核一遍(见 `ledgerKindFindings`)。
*/
kind: ledgerObjectKindSchema.default('record'),
/**
* 单标识字段名 —— **只有 `record` 需要**,且必须是它主键的一部分。
*
* 为什么要显式声明而不去猜:猜的规则(比如「主键里得有个 `_ref` 结尾的列」)
* 迟早猜错,而错的那一次是**静默覆盖数据**。让活动自己说清哪一列是「单」,
* 安装期就能核:它在不在 fields 里、是不是主键的一部分。
*/
caseKey: identifier.optional(),
fields: z.array(fieldDeclarationSchema).min(1),
});
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
+93
View File
@@ -95,12 +95,102 @@ export const dmnModelSchema = z.object({
});
export type DmnModel = z.infer<typeof dmnModelSchema>;
/**
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
*
* 为什么要有它(2026-07-31 加,起因是 A1 反复空转):
* 从前每一拍喂给模型的是**整本说明书 + 全部工具 + 全部可写台账**,
* 模型得自己从六步里认出「我现在在第几步」。认对了还好,认岔了就开始转圈 ——
* 实测 S1 已经把八字段写齐了,而说明书里 S3 写着「把八字段补到齐」,
* 模型于是把同样三行又写一遍,被「写了等于没写」拒回,下一拍再写一遍,如此往复。
*
* 治法不是把说明书写得更啰嗦,是**把状态挪进引擎**:
* ① `doneWhen` 让引擎自己算得出「这一步的成果已经在那儿了」—— 是就直接收掉,
* **不问模型**(能算的不问 LLM:同输入必同结论、零 token);
* ② `guide` / `writes` / `tools` 让每一拍的上下文**只装当前这一步要用的东西** ——
* 上下文小了,模型跑偏的余地也就小了。
*
* 不写剧本的活动照旧跑(整本说明书 + 全部工具),只是没有这两样好处。
*/
export const planStepSchema = z.object({
/** 对应 CMMN 里的计划项 id —— 对不上的条目安装期即拒 */
item: z.string().min(1),
/** 一句话:这一步交出什么 */
intent: z.string().min(1),
/** 这一步的做法(从说明书里搬过来的那一段)—— 只在轮到它时进上下文 */
guide: z.string().min(1),
/** 这一步可写哪几个台账地址「域.对象」;空 = 这一步不写台账 */
writes: z.array(z.string()).default([]),
/**
* 这一步**要读哪几个台账地址** —— 与 `writes` 对称,同样是「域.对象」。
*
* 为什么非声明不可:`域.对象` 整表那一段进提示词前会**按字符截断**
* (整表里躺着历史所有已放行单的行,只会越来越长,不截会把上下文吃光)。
* 截断是盲的 —— 砍掉的可能恰是这一步要用的那张表。实测:A1 第一步要拿发件邮箱
* 去 \`customer.contact\` 认客户,而这一单的来函正文与 BOM 附件就占了近 2000 字,
* 客户联系人表整个落在刀口外;模型看不见,只能报「主数据里没找到」,
* 于是老客户被当成新客户 —— **而库里那一行一直好端端在**。
*
* 声明了的表**排在最前且不参与截断**:这一步该看见的,一定看得见。
*/
reads: z.array(z.string()).default([]),
/**
* 这一步的**工作区画面**(视图模版名)—— 人办这一步时看的那份件。
*
* 不写就由模型临场挑。而「这一步该看哪张」是**写清单时就定死的事**:
* 阶段二派执行单,人要看的是那份询价受理需求;模型挑了「缺口清单」,
* 人拿到的就不是那份件了(实测:两份目标件都在包里,推出来的却是旧的那张)。
* 画面归活动定,不归模型临场发挥 —— 模型仍可用 `say.view` 换画面,
* 但**没换的时候按这里推**,而不是不推或推上一张。
*/
view: z.string().optional(),
/** 这一步可调哪几件工具;空 = 这一步不调工具 */
tools: z.array(z.string()).default([]),
/**
* **这一步算干完了没有** —— 对任务视图求值的 S-FEEL 谓词。
*
* 真了引擎就自己把这一项收掉,不问模型。所以它必须是「成果已存在」的判据,
* 不是「该不该做」的判据:写成 `missing_key_total = 0` 这种**开局就为真**的式子,
* 会让那一步在还没干之前就被收掉。
*
* **`X != null` 是个坑**:S-FEEL 里未绑定的名字 `!= null` 求值为 **true**,
* 写它等于让那一步开局即收。要判「这个键写过没有」,用正向式子(`X >= 0`);
* 布尔列更要绕开 —— 它合法值含 false,拿它分不清「没写」与「写了 false」,
* 该改判同一步写下的计数列。
*/
doneWhen: z.string().optional(),
});
export type PlanStep = z.infer<typeof planStepSchema>;
/**
* 阶段 —— **给人看的进度刻度**,引擎一眼都不看。
*
* 计划项是给引擎的:一件事拆到能判「干完没有」的粒度,A1 有六项。
* 可六格进度条对人没有意义 —— 「查历史价 / 打标 / 定去向」在业务上是**一件事**
* (判这单该怎么走),人关心的是「到哪一步了」,不是引擎拆了几项。
* 所以另立一层:几个计划项合成一个阶段,进度条按阶段走。
*
* **只声明激活态的阶段**。结束态不在这里 —— 每个活动都以放行收口(铁律③),
* 那是系统的规矩不是活动的阶段;进度条自己在末尾加这一格。
*/
export const casePhaseSchema = z.object({
/** 阶段名 —— 进度条上显示的就是它 */
name: z.string().min(1),
/** 这一阶段含哪几个计划项(CMMN item id);**全收完** = 这一阶段过了 */
items: z.array(z.string().min(1)).min(1),
});
export type CasePhase = z.infer<typeof casePhaseSchema>;
export const loopModelSchema = z.object({
/** 案例模型 · 机器面:CMMN XML,语义引擎以它为运行结构 */
cmmnXml: z.string().min(1),
/** 案例模型 · 叙事面:说明书,与 XML **同版本发布,不允许分叉**(AM 12.2) */
narrative: z.string().min(1),
dmn: dmnModelSchema,
/** 步骤剧本:一个计划项一条(见 `planStepSchema`);**不写照旧跑**(整本说明书 + 全部工具) */
steps: z.array(planStepSchema).optional(),
/** 阶段:进度条的刻度(见 `casePhaseSchema`);**不写就没有进度条**,不编 */
phases: z.array(casePhaseSchema).optional(),
});
export type LoopModel = z.infer<typeof loopModelSchema>;
@@ -186,6 +276,9 @@ export const staticAuditFindingSchema = z.object({
'dynamic-eval',
'ledger-type-conflict',
'acceptance-level-misreported',
'acceptance-predicate-unbound',
'deliverable-address-unbound',
'ledger-kind-invalid',
]),
severity: z.enum(['error', 'warn']),
message: z.string(),
+21 -3
View File
@@ -94,15 +94,33 @@ export const WORKFLOW_STATE_LABEL: Readonly<Record<WorkflowState, string>> = {
/* ── 归侧四标签(OS 8.1):度量的口径来源,零埋点 ─────────────────────── */
export const ATTRIBUTION_SIDES = ['machine', 'human', 'human-verdict', 'world-wait'] as const;
export const ATTRIBUTION_SIDES = ['machine', 'human', 'human-verdict', 'world-wait', 'trigger'] as const;
export const attributionSideSchema = z.enum(ATTRIBUTION_SIDES);
export type AttributionSide = z.infer<typeof attributionSideSchema>;
export const ATTRIBUTION_SIDE_LABEL: Readonly<Record<AttributionSide, string>> = {
machine: '机器产出',
human: '人产出',
/*
* 「员工动作」而不是「人产出」(2026-07-31 用户定)。
* 枚举键 `human` 是协议不动;改的是给人看的那一句 ——
* 「产出」听着像交付物,而这一类装的是**人做了什么动作**:
* 填了工单、看了来件、留了一句话。
*/
human: '员工动作',
'human-verdict': '人裁决',
'world-wait': '世界等待',
/*
* 「系统等待」而不是「世界等待」(2026-07-31 用户定)。
* 枚举键 `world-wait` 是协议不动 —— 改键要动所有留痕数据;
* 改的是**给人看的那一句**:人在工作日志里读到的是"在等谁",
* 「世界」太玄,他要的答案是"系统这边挂着在等"。
*/
'world-wait': '系统等待',
/*
* 触发事件 —— 这一单是被什么叫起来的(一封信到了、时限到了、人登记了)。
* 单列一类不是分得细:它**既不是机器产出也不是员工动作**,
* 混进任何一边都会把自主率算歪(分母里多一条谁也没干的事)。
*/
trigger: '触发事件',
};
/* ── 验收分级 L1–L5(AM 10.2) ──────────────────────────────────────── */