Archived
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 —— 脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试 「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0 (第五例停在大模型账户余额不足,不是代码问题)。 平台侧(六条): - L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。 契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算), `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。 - M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**; 现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。 - N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** —— 下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把 「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。 - O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。 - P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。 - Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有 = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。 活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、 ⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账; S0_bind 写明发录入卡那一拍别抢写需求行。 e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。 门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/* (工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
447 lines
20 KiB
TypeScript
447 lines
20 KiB
TypeScript
/**
|
||
* CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。
|
||
*
|
||
* 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**;
|
||
* 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」
|
||
*/
|
||
import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts';
|
||
|
||
/* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */
|
||
|
||
/** 案计划(整案)五态 */
|
||
export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const;
|
||
export type CaseState = (typeof CASE_STATES)[number];
|
||
|
||
/** 计划项八态 */
|
||
export const ITEM_STATES = [
|
||
'Available',
|
||
'Enabled',
|
||
'Disabled',
|
||
'Active',
|
||
'Suspended',
|
||
'Failed',
|
||
'Completed',
|
||
'Terminated',
|
||
] as const;
|
||
export type ItemState = (typeof ITEM_STATES)[number];
|
||
|
||
/** 迁移拓扑 —— 引擎不得私改 */
|
||
const ITEM_TRANSITIONS: Readonly<Record<ItemState, readonly ItemState[]>> = {
|
||
Available: ['Enabled', 'Active', 'Terminated', 'Suspended'],
|
||
Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'],
|
||
Disabled: ['Enabled', 'Terminated'],
|
||
Active: ['Completed', 'Failed', 'Suspended', 'Terminated'],
|
||
Suspended: ['Available', 'Enabled', 'Active', 'Terminated'],
|
||
Failed: ['Active', 'Terminated'],
|
||
Completed: [],
|
||
Terminated: [],
|
||
};
|
||
|
||
const CASE_TRANSITIONS: Readonly<Record<CaseState, readonly CaseState[]>> = {
|
||
Active: ['Completed', 'Suspended', 'Terminated', 'Failed'],
|
||
Suspended: ['Active', 'Terminated'],
|
||
Failed: ['Active', 'Terminated'],
|
||
Completed: [],
|
||
Terminated: [],
|
||
};
|
||
|
||
export class IllegalTransitionError extends Error {
|
||
constructor(
|
||
readonly from: string,
|
||
readonly to: string,
|
||
) {
|
||
super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`);
|
||
this.name = 'IllegalTransitionError';
|
||
}
|
||
}
|
||
|
||
export function canTransitionItem(from: ItemState, to: ItemState): boolean {
|
||
return ITEM_TRANSITIONS[from].includes(to);
|
||
}
|
||
|
||
export function assertItemTransition(from: ItemState, to: ItemState): void {
|
||
if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to);
|
||
}
|
||
|
||
export function canTransitionCase(from: CaseState, to: CaseState): boolean {
|
||
return CASE_TRANSITIONS[from].includes(to);
|
||
}
|
||
|
||
/* ── 运行时快照 ─────────────────────────────────────────────────────── */
|
||
|
||
export type ItemRuntime = {
|
||
readonly id: string;
|
||
readonly state: ItemState;
|
||
/** repetitionRule 的实例序号 */
|
||
readonly instance: number;
|
||
/** 语义性前提待裁量评估的标记 */
|
||
readonly awaitingJudgement?: boolean;
|
||
/**
|
||
* **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。
|
||
*
|
||
* 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否
|
||
* 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡
|
||
* 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 ——
|
||
* 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」,
|
||
* 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单,
|
||
* 覆盖度收口直接用了空回执重算,人根本没被问过)。
|
||
*/
|
||
readonly worldTouched?: boolean;
|
||
};
|
||
|
||
export type CaseRuntime = {
|
||
readonly state: CaseState;
|
||
readonly items: readonly ItemRuntime[];
|
||
/** 已达成的里程碑 */
|
||
readonly milestones: readonly string[];
|
||
};
|
||
|
||
export function initialRuntime(model: CaseModel): CaseRuntime {
|
||
return {
|
||
state: 'Active',
|
||
items: model.items.map((item) => ({
|
||
id: item.id,
|
||
// 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划
|
||
state: item.discretionary ? 'Available' : 'Available',
|
||
instance: 1,
|
||
})),
|
||
milestones: [],
|
||
};
|
||
}
|
||
|
||
/* ── 前提求值 ───────────────────────────────────────────────────────── */
|
||
|
||
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||
|
||
export type CriterionVerdict =
|
||
| { readonly kind: 'satisfied' }
|
||
| { readonly kind: 'unsatisfied' }
|
||
/** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */
|
||
| { readonly kind: 'needs-judgement'; readonly statement: string };
|
||
|
||
export function evaluateCriterion(
|
||
criterion: Criterion,
|
||
scope: Readonly<Record<string, unknown>>,
|
||
changedRefs: ReadonlySet<string>,
|
||
evaluate: ConditionEvaluator,
|
||
): CriterionVerdict {
|
||
// onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」
|
||
if (criterion.onParts.length > 0) {
|
||
const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref));
|
||
if (!allArrived) return { kind: 'unsatisfied' };
|
||
}
|
||
if (criterion.ifPart === undefined) return { kind: 'satisfied' };
|
||
if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart };
|
||
return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' };
|
||
}
|
||
|
||
/* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */
|
||
|
||
export type EnabledEntry = {
|
||
readonly id: string;
|
||
readonly name: string;
|
||
readonly type: PlanItem['type'];
|
||
/** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */
|
||
readonly requiresManualStart: boolean;
|
||
readonly discretionary: boolean;
|
||
};
|
||
|
||
export type Redlight = { readonly criterion: string; readonly statement: string };
|
||
|
||
export type EnabledSet = {
|
||
readonly entries: readonly EnabledEntry[];
|
||
/**
|
||
* **进行中的项** —— 已开工、还没交出成果的那些。
|
||
*
|
||
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
|
||
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
|
||
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
|
||
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
|
||
*
|
||
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
|
||
*/
|
||
readonly inProgress: readonly EnabledEntry[];
|
||
/** 待裁量评估的语义性前提 */
|
||
readonly judgements: readonly Redlight[];
|
||
/** 目标缺口:未绿的判据 */
|
||
readonly gaps: readonly string[];
|
||
/** 阶段位置:Active 的阶段 */
|
||
readonly activeStages: readonly string[];
|
||
};
|
||
|
||
/**
|
||
* 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。
|
||
* 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止;
|
||
* 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。
|
||
*/
|
||
export function publishEnabled(
|
||
model: CaseModel,
|
||
runtime: CaseRuntime,
|
||
scope: Readonly<Record<string, unknown>>,
|
||
changedRefs: ReadonlySet<string>,
|
||
evaluate: ConditionEvaluator,
|
||
unmetCriteria: readonly string[] = [],
|
||
): EnabledSet {
|
||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||
const entries: EnabledEntry[] = [];
|
||
const judgements: Redlight[] = [];
|
||
|
||
for (const item of model.items) {
|
||
const state = byId.get(item.id)?.state ?? 'Available';
|
||
if (state !== 'Available' && state !== 'Enabled') continue;
|
||
// 父阶段未激活的项不入场
|
||
if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue;
|
||
|
||
let satisfied = true;
|
||
for (const criterion of item.entryCriteria) {
|
||
const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate);
|
||
if (verdict.kind === 'needs-judgement') {
|
||
judgements.push({ criterion: criterion.id, statement: verdict.statement });
|
||
satisfied = false;
|
||
break;
|
||
}
|
||
if (verdict.kind === 'unsatisfied') {
|
||
satisfied = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!satisfied) continue;
|
||
|
||
entries.push({
|
||
id: item.id,
|
||
name: item.name,
|
||
type: item.type,
|
||
requiresManualStart: item.manualActivation,
|
||
discretionary: item.discretionary,
|
||
});
|
||
}
|
||
|
||
return {
|
||
entries,
|
||
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
|
||
inProgress: model.items
|
||
.filter((item) => byId.get(item.id)?.state === 'Active')
|
||
.map((item) => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
type: item.type,
|
||
requiresManualStart: item.manualActivation,
|
||
discretionary: item.discretionary,
|
||
})),
|
||
judgements,
|
||
gaps: unmetCriteria,
|
||
activeStages: runtime.items
|
||
.filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage')
|
||
.map((i) => i.id),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。
|
||
* 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需;
|
||
* **交集为空即断点** —— 调人或立监听的判定时机。」
|
||
*/
|
||
export type Navigation =
|
||
| { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] }
|
||
/** 断点:目标所需的缺口无法由任何 Enabled 项补足 */
|
||
| { readonly kind: 'breakpoint'; readonly gaps: readonly string[] }
|
||
| { readonly kind: 'converged' };
|
||
|
||
export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Navigation {
|
||
if (enabled.gaps.length === 0) return { kind: 'converged' };
|
||
const intersection =
|
||
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
|
||
/*
|
||
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
|
||
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
|
||
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
|
||
*/
|
||
if (intersection.length === 0) {
|
||
return enabled.inProgress.length > 0
|
||
? { kind: 'proceed', candidates: enabled.inProgress }
|
||
: { kind: 'breakpoint', gaps: enabled.gaps };
|
||
}
|
||
return { kind: 'proceed', candidates: intersection };
|
||
}
|
||
|
||
/* ── 自动完成与终局 ─────────────────────────────────────────────────── */
|
||
|
||
/** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */
|
||
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
|
||
if (!model.autoComplete) return false;
|
||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||
/*
|
||
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
|
||
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
|
||
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
|
||
*/
|
||
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
|
||
const allDone = required.every((i) => {
|
||
const state = byId.get(i.id)?.state;
|
||
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
|
||
});
|
||
const noneRunning = runtime.items.every((i) => i.state !== 'Active');
|
||
return allDone && noneRunning;
|
||
}
|
||
|
||
/**
|
||
* 终局唯放行与 Harness(必须):
|
||
* 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人;
|
||
* **AI 不得产生终局迁移**。
|
||
*/
|
||
export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const;
|
||
export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number];
|
||
|
||
export function mayCompleteCase(by: TerminalAuthority): boolean {
|
||
return by === 'release';
|
||
}
|
||
|
||
export function mayTerminateCase(by: TerminalAuthority): boolean {
|
||
return by === 'harness' || by === 'owner' || by === 'governance';
|
||
}
|
||
|
||
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
|
||
/**
|
||
* 前提达成即达成的里程碑 —— **不需要谁去按**。
|
||
*
|
||
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
|
||
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
|
||
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
|
||
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
|
||
* 让 Agent 每拍都看到一堆自己按不动的东西。
|
||
*
|
||
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
|
||
*/
|
||
export function achieveMilestones(
|
||
model: CaseModel,
|
||
runtime: CaseRuntime,
|
||
scope: Readonly<Record<string, unknown>>,
|
||
changedRefs: ReadonlySet<string>,
|
||
evaluate: ConditionEvaluator,
|
||
): { runtime: CaseRuntime; achieved: string[] } {
|
||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||
let next = runtime;
|
||
const achieved: string[] = [];
|
||
|
||
for (const item of model.items) {
|
||
if (item.type !== 'milestone') continue;
|
||
if (byId.get(item.id)?.state !== 'Available') continue;
|
||
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
|
||
if (item.entryCriteria.length === 0) continue;
|
||
const met = item.entryCriteria.every(
|
||
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
|
||
);
|
||
if (!met) continue;
|
||
next = occur(next, item.id);
|
||
achieved.push(item.id);
|
||
}
|
||
return { runtime: next, achieved };
|
||
}
|
||
|
||
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||
return {
|
||
...runtime,
|
||
items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)),
|
||
milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId],
|
||
};
|
||
}
|
||
|
||
export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime {
|
||
const current = runtime.items.find((i) => i.id === itemId);
|
||
if (current === undefined) throw new IllegalTransitionError('(不存在)', to);
|
||
assertItemTransition(current.state, to);
|
||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
|
||
}
|
||
|
||
/** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */
|
||
export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) };
|
||
}
|
||
|
||
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
|
||
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||
return {
|
||
...runtime,
|
||
items: runtime.items.map((i) =>
|
||
i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
|
||
*
|
||
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
|
||
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
|
||
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
|
||
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
|
||
*
|
||
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
|
||
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
|
||
*/
|
||
export function criterionKey(raw: string): string {
|
||
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
|
||
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
|
||
/*
|
||
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
|
||
*
|
||
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
|
||
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
|
||
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
|
||
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
|
||
*
|
||
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
|
||
*/
|
||
return tight.replace(/\(l[1-5]\)$/, '');
|
||
}
|
||
|
||
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||
/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
|
||
* `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
|
||
* 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
|
||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
|
||
|
||
/**
|
||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||
*
|
||
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
|
||
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
|
||
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
|
||
*/
|
||
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
|
||
const names = new Set<string>();
|
||
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
||
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
|
||
}
|
||
if (names.size === 0) return '';
|
||
const readings = [...names].map((name) => {
|
||
const value = scope[name];
|
||
if (value === undefined || value === null) return `${name}=(没写过)`;
|
||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
|
||
});
|
||
/*
|
||
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
|
||
*
|
||
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
|
||
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
|
||
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
|
||
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
|
||
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
|
||
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
|
||
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
|
||
*
|
||
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
|
||
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
|
||
*/
|
||
const rowKeys = [...names]
|
||
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
|
||
.map((n) => {
|
||
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
|
||
if (typeof listed !== 'string' || listed === '') return '';
|
||
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
|
||
})
|
||
.filter((s) => s !== '');
|
||
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
|
||
}
|