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

手改是**兜 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',