Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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:
@@ -139,6 +139,17 @@ 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[];
|
||||
/** 目标缺口:未绿的判据 */
|
||||
@@ -196,6 +207,16 @@ export function publishEnabled(
|
||||
|
||||
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
|
||||
@@ -219,7 +240,16 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
|
||||
if (enabled.gaps.length === 0) return { kind: 'converged' };
|
||||
const intersection =
|
||||
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
|
||||
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
|
||||
/*
|
||||
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
|
||||
* 从前只看 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 };
|
||||
}
|
||||
|
||||
@@ -229,7 +259,12 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
|
||||
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
|
||||
if (!model.autoComplete) return false;
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
|
||||
/*
|
||||
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
|
||||
* 从前只排除 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';
|
||||
@@ -255,6 +290,43 @@ export function mayTerminateCase(by: TerminalAuthority): boolean {
|
||||
}
|
||||
|
||||
/** 里程碑与事件监听器只有一条主迁移: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,
|
||||
@@ -279,3 +351,77 @@ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
|
||||
*
|
||||
* `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 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||||
|
||||
/**
|
||||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||||
*
|
||||
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
|
||||
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 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(';')}`})`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。
|
||||
* 两层分开,是因为它们回答的是两个问题:
|
||||
* · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细;
|
||||
* · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。
|
||||
* 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。
|
||||
*/
|
||||
import type { CasePhase } from '@contracts';
|
||||
import type { ItemRuntime } from './lifecycle.ts';
|
||||
|
||||
export type PhaseProgress = {
|
||||
/** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */
|
||||
readonly index: number;
|
||||
/** 总格数 = 声明的阶段数 **+ 1**(结束态) */
|
||||
readonly total: number;
|
||||
/** 这一格叫什么 */
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
/** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */
|
||||
export const CLOSING_PHASE = '放行收口';
|
||||
|
||||
/**
|
||||
* 算这一单在第几阶段。
|
||||
*
|
||||
* 判据是「这一阶段的计划项**全部收完**」——
|
||||
* · 收完(Completed / Terminated,后者是被跳过的裁量项)算过;
|
||||
* · 没收完就停在这一阶段,哪怕其中一项已经在办。
|
||||
* 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。
|
||||
*
|
||||
* `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态,
|
||||
* 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。
|
||||
*/
|
||||
export function phaseProgress(
|
||||
phases: readonly CasePhase[] | undefined,
|
||||
items: readonly ItemRuntime[],
|
||||
terminal: boolean,
|
||||
): PhaseProgress | undefined {
|
||||
if (phases === undefined || phases.length === 0) return undefined;
|
||||
const total = phases.length + 1;
|
||||
if (terminal) return { index: total, total, name: CLOSING_PHASE };
|
||||
|
||||
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
|
||||
for (const [at, phase] of phases.entries()) {
|
||||
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
|
||||
}
|
||||
return { index: total, total, name: CLOSING_PHASE };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user