Archived
E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -236,11 +236,27 @@ export function rowReadings(
|
||||
* 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里,
|
||||
* 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。
|
||||
*/
|
||||
/**
|
||||
* **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。
|
||||
*
|
||||
* 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、
|
||||
* 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里**
|
||||
* 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。
|
||||
* `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。
|
||||
*/
|
||||
export function rowLabel(id: Readonly<Record<string, unknown>>, caseKey: string | undefined): string {
|
||||
const parts = Object.entries(id)
|
||||
.filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '')
|
||||
.map(([, v]) => String(v));
|
||||
return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/');
|
||||
}
|
||||
|
||||
export function missingReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
caseKey?: string | undefined;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
label?: string | undefined;
|
||||
@@ -252,7 +268,7 @@ export function missingReadings(
|
||||
}>,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const { domain, object, fields } of declared) {
|
||||
for (const { domain, object, caseKey, fields } of declared) {
|
||||
const rows = mine[`mine:${domain}.${object}`] ?? [];
|
||||
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const blank = (one: Record<string, unknown>, c: string): boolean =>
|
||||
@@ -282,7 +298,7 @@ export function missingReadings(
|
||||
if (gaps.length === 0) continue;
|
||||
const id: Record<string, string> = {};
|
||||
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
|
||||
bad.push(`${Object.values(id).join('/')} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
|
||||
bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
|
||||
}
|
||||
out[`${domain}_${object}_${suffix}`] = bad.length;
|
||||
if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';');
|
||||
@@ -331,6 +347,65 @@ export function tallyReadings(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。
|
||||
*
|
||||
* 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」):
|
||||
* 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据
|
||||
* 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。
|
||||
* 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。
|
||||
* 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数;
|
||||
* 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数
|
||||
* 恰恰就是这次数漏的那一个。
|
||||
*
|
||||
* 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。
|
||||
* 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。
|
||||
*/
|
||||
export function distinctReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>;
|
||||
}>,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const { domain, object, fields } of declared) {
|
||||
for (const field of fields) {
|
||||
if (field.distinct !== true) continue;
|
||||
const rows = mine[`mine:${domain}.${object}`] ?? [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||
const value = String(one[field.name] ?? '').trim();
|
||||
if (value !== '') seen.add(value);
|
||||
}
|
||||
out[`${domain}_${object}_distinct_${field.name}`] = seen.size;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。
|
||||
*
|
||||
* 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿),
|
||||
* 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、
|
||||
* Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。
|
||||
* 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加;
|
||||
* 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤:
|
||||
* 本轮碰过哪几行,就摆那几行的叠加后全貌。
|
||||
*/
|
||||
export function touchedRows(
|
||||
merged: ReadonlyArray<unknown>,
|
||||
draftKeys: ReadonlyArray<Readonly<Record<string, unknown>>>,
|
||||
): unknown[] {
|
||||
const rows = merged.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null);
|
||||
return draftKeys
|
||||
.map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? ''))))
|
||||
.filter((r) => r !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
|
||||
*
|
||||
@@ -782,12 +857,25 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
* (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。
|
||||
*/
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
const draftKeysOf: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const draft of await deps.ledger.taskDrafts(taskId)) {
|
||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||
(draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key });
|
||||
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
|
||||
if (value !== null && value !== undefined) scope[field] = value;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿),
|
||||
* 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有
|
||||
* 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 ——
|
||||
* 内容与 mine/整表重叠,白占截断窗)。
|
||||
*/
|
||||
const touched: Record<string, unknown[]> = {};
|
||||
for (const [address, draftKeys] of Object.entries(draftKeysOf)) {
|
||||
const merged = scope[address];
|
||||
if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys);
|
||||
}
|
||||
|
||||
/*
|
||||
* **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。
|
||||
@@ -826,8 +914,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
...rowKeyList(mine, declared),
|
||||
...missingReadings(mine, declared),
|
||||
...tallyReadings(mine, declared),
|
||||
...distinctReadings(mine, declared),
|
||||
...toolCallReadings(trail, manifest.declarations.tools),
|
||||
...mine,
|
||||
...touched,
|
||||
};
|
||||
return enriched;
|
||||
},
|
||||
@@ -845,7 +935,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: d.object,
|
||||
label: d.label ?? `${d.domain}.${d.object}`,
|
||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type })),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
||||
}));
|
||||
},
|
||||
/**
|
||||
@@ -866,6 +956,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
const allowed = await taskData.writable(input.taskId);
|
||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||
@@ -882,6 +974,39 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
|
||||
}
|
||||
|
||||
/*
|
||||
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
|
||||
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
|
||||
* contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 +
|
||||
* 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住;
|
||||
* 现在写入那一刻当场拒,拒回指路(墙要配路)。
|
||||
*/
|
||||
for (const field of target.fields) {
|
||||
if (field.refersTo === undefined) continue;
|
||||
const raw = input.key[field.name] ?? input.patch[field.name];
|
||||
const value = raw === undefined || raw === null ? '' : String(raw).trim();
|
||||
if (value === '') continue;
|
||||
const [rd, ro, rc] = field.refersTo.split('.');
|
||||
if (rd === undefined || ro === undefined || rc === undefined) continue;
|
||||
const referenced = await deps.ledger.taskView(input.taskId, {
|
||||
address: { domain: rd, object: ro },
|
||||
where: {},
|
||||
orderBy: [],
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
});
|
||||
const exists = referenced.rows.some((r) => String((r as Record<string, unknown>)[rc] ?? '') === value);
|
||||
if (!exists) {
|
||||
const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`;
|
||||
throw new Error(
|
||||
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
|
||||
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
|
||||
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
|
||||
'或者把这一笔挂到已存在的那一行名下',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。
|
||||
*
|
||||
@@ -921,6 +1046,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
before === undefined ||
|
||||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
|
||||
if (input.dryRun === true) return { changed };
|
||||
await deps.ledger.draftWrite({
|
||||
taskId: input.taskId,
|
||||
address: { domain: input.domain, object: input.object },
|
||||
@@ -1933,6 +2059,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
}
|
||||
const domain = input.address.slice(0, at);
|
||||
const object = input.address.slice(at + 1);
|
||||
/*
|
||||
* **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写,
|
||||
* 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」——
|
||||
* 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6),
|
||||
* 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。
|
||||
* 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。
|
||||
*/
|
||||
for (const row of input.rows) {
|
||||
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true });
|
||||
}
|
||||
for (const row of input.rows) {
|
||||
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user