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

手改是**兜 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
+101 -3
View File
@@ -9,10 +9,12 @@ import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { evaluateCondition } from '@sfeel';
import type { Db } from '@shared/db.ts';
import type { TaskId } from '@contracts';
import type { EventService, TriggerMatcher } from '@modules/event';
import { createEventService, createEventStore } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import type { LoopEngineService } from '@modules/loop-engine';
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
import type { OrgService, InFlightWorkProbe } from '@modules/org';
import {
@@ -39,6 +41,7 @@ import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore }
import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox';
import {
createSchemaValidator,
createToolHandoffStore,
createToolInvoker,
createToolRegistry,
createToolReplayStore,
@@ -103,6 +106,12 @@ export type Container = {
readonly registry: ActivityRegistryService;
readonly release: ReleaseService;
readonly task: TaskService;
/** 案例引擎 —— 工作台按它算阶段进度;推进照旧走 task,不从这里走 */
readonly loop: LoopEngineService;
/** 视图闸 —— 人在件上改完数据,按同一张模版用新数据重渲一帧(`views.repush`) */
readonly views: { repush(taskId: TaskId): Promise<boolean> };
/** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */
readonly checks: { of(taskId: TaskId): Promise<ReadonlyArray<{ key: string; label: string; level: string; pass: boolean; settled?: boolean; rationale?: string }>> };
readonly process: ProcessService;
readonly trigger: TriggerService;
readonly minute: MinuteService;
@@ -161,6 +170,14 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
export async function createContainer(config: AppConfig): Promise<Container> {
/*
* 广播器**先建** —— 装配期就要往它上面接线。
*
* 它从前是在 `return` 里现造的,于是装配期谁也拿不到它:
* 对话流推送、视图推送、发消息这三条**全都没有广播**,
* 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。
*/
const broadcaster = createBroadcaster();
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
/*
* 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上;
@@ -233,6 +250,25 @@ export async function createContainer(config: AppConfig): Promise<Container> {
events: { publish: async (envelope) => void (await event.publish(envelope)) },
clock: systemClock,
ids,
/*
* 试跑判定 —— 设计台有**两条**试跑路径,认漏一条就等于没挡:
* 单活动试跑:起的是不挂工作流的单,标 `task.origin = 'testbed'`
* 流程包试跑:节点任务挂在工作流上,标在**流**身上 `workflow.source->>'ref'`
* 一条 SQL 两条都认。认不出就当生产单(宁可少挡也不误挡:误挡会让真单丢主数据)。
*/
simulation: {
isSimulated: async (taskId) => {
const res = await pool.query<{ simulated: boolean }>(
`select exists (
select 1 from sys.task t
left join sys.workflow w on w.id = t.workflow_id
where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
) as simulated`,
[taskId],
);
return res.rows[0]?.simulated === true;
},
},
});
const instances = createLlmInstanceRepository(db);
@@ -278,6 +314,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
secrets,
clock: systemClock,
});
/** 工具转人台(§6.2):系统里没有这件工具时,请人代办 —— 人答回的值就是这次调用的出参 */
const toolHandoff = createToolHandoffStore(db);
const toolInvoker = createToolInvoker({
registry: toolRegistry,
runtime: toolRuntime,
@@ -334,9 +372,41 @@ export async function createContainer(config: AppConfig): Promise<Container> {
ids,
});
/*
* **对话流与视图推送接进广播** —— `interaction.subscribe` 从前一个订阅者都没有:
* AI 说的每一句、推的每一帧画面都广播给了空气,前端只好靠人点刷新。
*
* 这里只做转接,不带业务载荷:推送说「这个任务动了」,画面照旧由服务端投影重建
* (§9.2 前端零过程状态)。收件人是谁不在信封里,所以按任务广播 ——
* 前端按任务键失效,负责人那一屏自然重取。
*/
interaction.onPush((envelope) => {
// invalidate 信封自己说目标(task / workflow / desk);其余信封都以任务为轴
if (envelope.kind === 'invalidate') {
broadcaster.publish({
kind: envelope.target === 'workflow' ? 'workflow' : 'task',
id: envelope.id,
reason: 'invalidate',
});
return;
}
broadcaster.publish({ kind: 'task', id: envelope.taskId, reason: envelope.kind });
});
const humanTool = createHumanToolService({
cards: createCardStore(db),
outbox: { sendElicitation: (input) => message.sendElicitation(input) },
outbox: {
/*
* 跨人卡以系统消息送达对方 —— 送到了就**广播给那个人**:
* 他的消息气泡该立刻多一条,而不是等他自己点刷新。
* 发消息不经事件投递(那条路才有广播),所以这里得自己说一声。
*/
sendElicitation: async (input) => {
const sent = await message.sendElicitation(input);
broadcaster.publish({ kind: 'staff', id: input.staffId, reason: 'elicitation' });
return sent;
},
},
memory: {
append: async (input) =>
void (await memory.append({
@@ -346,6 +416,17 @@ export async function createContainer(config: AppConfig): Promise<Container> {
},
events: {
publishHumanEvent: async (input) => {
/*
* 转人代办的回传**就是工具出参**:先落转人台,再抛事件。
*
* 顺序不能反 —— 事件会唤起下一拍,那一拍 invoke 必须立刻取到答案;
* 先抛后写就会空跑一拍(拍里还是「等人」,而人其实已经答了)。
* 卡不是转人卡时 `answer` 查无此行,静默无事发生。
*/
const values = input.payload['values'];
if (typeof values === 'object' && values !== null && !Array.isArray(values)) {
await toolHandoff.answer(input.cardId, values as Record<string, unknown>);
}
await event.publish({
type: 'human',
correlation: {
@@ -461,7 +542,19 @@ export async function createContainer(config: AppConfig): Promise<Container> {
deliverable: input.deliverable as never,
criteria: [...input.criteria],
actions: input.actions.map((a) => ({ ...a })),
fields: [],
/*
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
* 验收报告于是只能看不能改(第 12 章③)。
*/
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
/*
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
*/
...(input.blocks === undefined || input.blocks.length === 0
? {}
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
},
);
return { id: issued.order.id };
@@ -537,6 +630,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
memory,
toolbox,
toolInvoker,
toolHandoff,
humanTool,
interaction,
message,
@@ -581,7 +675,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
return {
config,
broadcaster: createBroadcaster(),
broadcaster,
pool,
db,
org,
@@ -599,6 +693,10 @@ export async function createContainer(config: AppConfig): Promise<Container> {
registry,
release,
task: engines.task,
loop: engines.loop,
/** 件是投影 —— 人在件上改完数据,得能按同一张模版用新数据重渲一帧(见 `views.repush`) */
views: engines.views,
checks: engines.checks,
process: engines.process,
trigger: engines.trigger,
minute: deskElements.minute,