Archived
feat(w05): 重做的意见交回给 AI 重跑那一步 —— 不再只是把派生列重算一遍
用户:「唯一调用 ai 的这一步,使用重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」 确实没有:S3 那一轮只跑三笔纯投影重算,人写的那句话除了留痕之外无人读 —— AI 判过的四态一个字不会变,除非他自己在件上逐格改。 平台:引擎把「人这次打回写了什么」注给配方(`case_redo_note`)。 · 下划线不是 case: 前缀 —— S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 when 条件用; · **没打回也永远绑着空串**:未绑定的名字 `!= ""` 求值为真,不绑就等于那几笔每一轮都跑 (拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住); · 静态审认这个名字 —— 它当场拦下过一次未登记的引用,这道门做对了事。 W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— 带意见重判(入参多带上一轮四态/影响/依据/UNKNOWN 去向 + 他那句话;判法与第一轮同一份 ASSESS_RULES,后接复核纪律:没被点到、也确实没判错的行原样带回,别趁重判把人改过的冲掉) + 判完落回明细(与第一轮同一笔投影,只多一个 when)。判法、出参形状、落明细抽成三个 共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。 验收:e2e w05 5/5 released、被拒 0;案例 0(先点名驳回、再通过)模型调用恰 2 次, 其余四例仍是 1 次 —— 没被打回就不多花一次模型。auto-runner 3 测(含变异验证)、包内 +1。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -1834,6 +1834,24 @@
|
||||
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
|
||||
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
|
||||
|
||||
- **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后
|
||||
是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍,
|
||||
人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。
|
||||
平台加一样东西:引擎把「人这次打回写了什么」注给配方,名字 **`case_redo_note`** ——
|
||||
下划线不是 `case:` 前缀,因为 S-FEEL 的名字不许带冒号(`lexer.ts` 的 NAME_PART),
|
||||
而这句话最要紧的用法恰恰是当 `when` 条件用;**没打回也永远绑着空串** ——
|
||||
未绑定的名字 `!= ""` 求值为**真**,不绑就等于「只在被打回时才跑」的那几笔每一轮都跑
|
||||
(拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住)。静态审同批认这个名字
|
||||
(它当场拦下过一次:「配方的 when 引用了 case_redo_note,而台账、案卷、起单入参里都没有这个名字」——
|
||||
这道门做对了事)。
|
||||
W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— **带意见重判**(入参比第一轮多两样:
|
||||
上一轮的四态/影响/依据/UNKNOWN 去向,以及他写的那句话;判法与第一轮**同一份** `ASSESS_RULES`,
|
||||
后面只接一段复核纪律:**没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉)
|
||||
+ **判完落回明细**(与第一轮同一笔投影,只多一个 `when`)。为此把判法、出参形状、落明细那一笔
|
||||
抽成三个共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。
|
||||
**验收**:e2e w05 **5/5 released、被拒 0**,案例 0(先点名驳回、再通过)**模型调用恰 2 次**,
|
||||
其余四例仍是 1 次 —— 没被打回就不多花一次模型。
|
||||
|
||||
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
|
||||
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
|
||||
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
|
||||
|
||||
@@ -406,11 +406,41 @@ describe('S3 / S4 配方', () => {
|
||||
const s4 = stepOf('T_release')?.auto?.do ?? [];
|
||||
const s2 = stepOf('S2_assess')?.auto?.do ?? [];
|
||||
|
||||
it('S3 五笔:差异明细重算 → 关系 → 验证记录 → 卡 → 交代 —— 人改的四态先落地,结论才有真数据可读', () => {
|
||||
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'project', 'card', 'say']);
|
||||
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.difference_item');
|
||||
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.relation');
|
||||
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('validation.record');
|
||||
it('S3 七笔:复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 卡 → 交代', () => {
|
||||
expect(s3.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'card', 'say']);
|
||||
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.difference_item');
|
||||
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('substitution.difference_item');
|
||||
expect(s3[3]?.kind === 'project' && s3[3].into).toBe('substitution.relation');
|
||||
expect(s3[4]?.kind === 'project' && s3[4].into).toBe('validation.record');
|
||||
});
|
||||
|
||||
/*
|
||||
* **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
|
||||
* 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕
|
||||
* 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。
|
||||
*
|
||||
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)——
|
||||
* 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。
|
||||
*/
|
||||
it('复核轮:两笔都钉在「人打回了才跑」上,判法与第一轮同一份规矩,入参多带上一轮结论与他的意见', () => {
|
||||
const redo = s3[0];
|
||||
const land = s3[1];
|
||||
expect(redo?.kind === 'analyze' && redo.when).toBe('case_redo_note != ""');
|
||||
expect(land?.kind === 'project' && land.when).toBe('case_redo_note != ""');
|
||||
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
|
||||
const first = s2[0];
|
||||
const rules = first?.kind === 'analyze' ? first.instruction : '';
|
||||
expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true);
|
||||
expect(redo?.kind === 'analyze' && redo.instruction).toContain('原样带回');
|
||||
/* 入参:上一轮四态 + 他写的意见 —— 少了任何一样,重判要么冲掉人改过的行,要么读不到意见 */
|
||||
const fields = Object.keys(redo?.kind === 'analyze' ? redo.input.fields : {});
|
||||
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
|
||||
expect(fields).toContain(key);
|
||||
}
|
||||
expect(redo?.kind === 'analyze' && redo.input.fields['reviewer_note']).toEqual({ in: 'case_redo_note' });
|
||||
/* 落明细那一笔与第一轮同一份投影(只多一个 when)—— 各写一份早晚分叉 */
|
||||
const s2Land = s2[2];
|
||||
expect(land?.kind === 'project' && land.fields).toBe(s2Land?.kind === 'project' ? s2Land.fields : undefined);
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -420,7 +450,7 @@ describe('S3 / S4 配方', () => {
|
||||
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
|
||||
*/
|
||||
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
|
||||
const recompute = s3[0];
|
||||
const recompute = s3[2];
|
||||
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
|
||||
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
|
||||
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
|
||||
@@ -438,7 +468,7 @@ describe('S3 / S4 配方', () => {
|
||||
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
|
||||
*/
|
||||
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
|
||||
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined;
|
||||
const s3Fields = s3[2]?.kind === 'project' ? s3[2].fields : undefined;
|
||||
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
|
||||
expect(s2Fields?.['judgement_class']).toBeDefined();
|
||||
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
|
||||
@@ -459,7 +489,7 @@ describe('S3 / S4 配方', () => {
|
||||
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
|
||||
*/
|
||||
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
|
||||
const relation = s3[1];
|
||||
const relation = s3[3];
|
||||
expect(relation?.kind).toBe('project');
|
||||
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
|
||||
act?.kind === 'project' ? act.fields : {};
|
||||
@@ -474,7 +504,7 @@ describe('S3 / S4 配方', () => {
|
||||
/* 主键带回:同 PK 覆盖,不是新开一行 */
|
||||
expect(fields['substitution_relation_id']).toBeDefined();
|
||||
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
|
||||
expect(s3[2]).toBe(s2[4]);
|
||||
expect(s3[4]).toBe(s2[4]);
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -490,7 +520,7 @@ describe('S3 / S4 配方', () => {
|
||||
});
|
||||
|
||||
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
|
||||
const card = s3[3];
|
||||
const card = s3[5];
|
||||
expect(card?.kind === 'card' && card.as).toBe('decide');
|
||||
expect(card?.kind === 'card' && card.variant).toBe('review');
|
||||
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
|
||||
|
||||
@@ -349,6 +349,136 @@ const GATE_OPEN_WHEN =
|
||||
'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or ' +
|
||||
'sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0';
|
||||
|
||||
/**
|
||||
* **逐参数差异判定的规矩** —— S2 第一轮判、S3 被人打回后复核,**两处共用同一份**:
|
||||
* 各写一份早晚分叉,而分叉的那天,屏上会同时摆着两套判法判出来的结论。
|
||||
*/
|
||||
const ASSESS_RULES = [
|
||||
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
|
||||
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
|
||||
'',
|
||||
'对**每一条**给出四态之一:',
|
||||
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
|
||||
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
|
||||
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
|
||||
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
|
||||
'',
|
||||
'三条硬规矩:',
|
||||
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
|
||||
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
|
||||
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
|
||||
'',
|
||||
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
|
||||
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
|
||||
'',
|
||||
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
|
||||
].join('\n');
|
||||
|
||||
/** 判定出参的形状 —— 同上,两处共用一份 */
|
||||
const ASSESS_OUTPUT = {
|
||||
type: 'object',
|
||||
required: ['items'],
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
|
||||
properties: {
|
||||
param_name: { type: 'string' },
|
||||
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
|
||||
impact_statement: { type: 'string' },
|
||||
evidence_note: { type: 'string' },
|
||||
unknown_disposition: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* **AI 判完之后落明细的那一笔** —— S2 第一轮、S3 被人打回后的复核轮,**共用同一份**。
|
||||
* 行由字典 `each` 出来,AI 的出参只是拿 `param_name` 去查的一张表:它漏一条,那一行的四态
|
||||
* 就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住(从前 `from each(AI 出参)`,
|
||||
* 它少给两条就少两行,**而每一行看着都合规**)。
|
||||
*/
|
||||
const DIFFERENCE_ITEM_FROM_RESULT = {
|
||||
kind: 'project' as const,
|
||||
into: 'substitution.difference_item',
|
||||
prune: true,
|
||||
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
|
||||
fields: {
|
||||
substitution_relation_id: { in: 'relationId' },
|
||||
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
|
||||
parameter_name: { col: 'param_name' },
|
||||
parameter_desc: { col: 'param_desc' },
|
||||
parameter_unit: { col: 'param_unit' },
|
||||
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
|
||||
constraint_type: { col: 'constraint_type' },
|
||||
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
|
||||
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
|
||||
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
|
||||
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
|
||||
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
|
||||
...JUDGEMENT_DERIVED_FIELDS,
|
||||
rule_version: RULE_VERSION,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* **人把结论打回时,意见要交回给 AI 重判 —— 这是整个活动第二次(也是最后一次)出场**
|
||||
* (2026-09-01 用户定:「重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」——
|
||||
* 当时确实没有:S3 那一轮只把派生列重算一遍,人写的那句话除了留痕之外无人读)。
|
||||
*
|
||||
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的「人这次打回写了什么」
|
||||
* (没打回就是空串,永远绑得上 —— 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。
|
||||
*
|
||||
* **入参比第一轮多两样**:①上一轮的结论(四态 / 影响 / 依据 / UNKNOWN 去向)——
|
||||
* 人可能已经就地改过几行,那也是「上一轮结论」的一部分;②他这次写的意见。
|
||||
* 判法本身与第一轮**同一份** `ASSESS_RULES`,后面只接一段复核纪律:
|
||||
* **没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉。
|
||||
*/
|
||||
const REVIEW_REDO_ANALYZE = {
|
||||
kind: 'analyze' as const,
|
||||
when: 'case_redo_note != ""',
|
||||
model: 'deepseek-v4-flash',
|
||||
into: 'items',
|
||||
instruction: [
|
||||
ASSESS_RULES,
|
||||
'',
|
||||
'【这一轮是复核,不是从头判】上一轮的结论已经摆在每一条的 last_* 里,审核人写下的意见在 reviewer_note。',
|
||||
'· 意见点到的那几条:按他说的复核,该改就改,并在 evidence_note 里写清这一轮改了什么、依据是什么;',
|
||||
'· **意见没点到、你也确实没判错的**:把 last_* 原样带回来 —— 人可能已经在件上亲手改过几行,重判不是把它们冲掉的机会;',
|
||||
'· 他说的若与硬规矩冲突(比如要把证据不足的硬约束判成 match),照硬规矩judge,并在 evidence_note 里说明为什么不能照办。',
|
||||
].join('\n'),
|
||||
input: {
|
||||
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
|
||||
fields: {
|
||||
param_name: { col: 'param_name' },
|
||||
param_desc: { col: 'param_desc' },
|
||||
constraint_type: { col: 'constraint_type' },
|
||||
unit: { col: 'param_unit' },
|
||||
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
|
||||
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
|
||||
/* 上一轮的结论 —— 含人就地改过的那几行(读的是本单草稿的当下值) */
|
||||
last_status: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
|
||||
last_impact: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
|
||||
last_evidence: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
|
||||
last_unknown_disposition: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
|
||||
/* 人这次打回写的那句话 —— 每一行都带一份(入参只有行这一种形状),指令里说明它是整批的意见 */
|
||||
reviewer_note: { in: 'case_redo_note' },
|
||||
},
|
||||
},
|
||||
output: ASSESS_OUTPUT,
|
||||
} as const;
|
||||
|
||||
/** 复核轮判完之后落回明细 —— 与第一轮同一笔投影,同样只在被打回的那一轮跑 */
|
||||
const DIFFERENCE_ITEM_FROM_REDO = { ...DIFFERENCE_ITEM_FROM_RESULT, when: 'case_redo_note != ""' } as const;
|
||||
|
||||
export const ASSESSMENT_STEPS = [
|
||||
{
|
||||
item: 'S1_inputs' as const,
|
||||
@@ -648,26 +778,7 @@ export const ASSESSMENT_STEPS = [
|
||||
kind: 'analyze' as const,
|
||||
model: 'deepseek-v4-flash',
|
||||
into: 'items',
|
||||
instruction: [
|
||||
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
|
||||
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
|
||||
'',
|
||||
'对**每一条**给出四态之一:',
|
||||
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
|
||||
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
|
||||
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
|
||||
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
|
||||
'',
|
||||
'三条硬规矩:',
|
||||
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
|
||||
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
|
||||
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
|
||||
'',
|
||||
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
|
||||
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
|
||||
'',
|
||||
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
|
||||
].join('\n'),
|
||||
instruction: ASSESS_RULES,
|
||||
input: {
|
||||
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
|
||||
fields: {
|
||||
@@ -682,26 +793,7 @@ export const ASSESSMENT_STEPS = [
|
||||
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
required: ['items'],
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
|
||||
properties: {
|
||||
param_name: { type: 'string' },
|
||||
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
|
||||
impact_statement: { type: 'string' },
|
||||
evidence_note: { type: 'string' },
|
||||
unknown_disposition: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: ASSESS_OUTPUT,
|
||||
},
|
||||
/*
|
||||
* ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误
|
||||
@@ -730,30 +822,7 @@ export const ASSESSMENT_STEPS = [
|
||||
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
|
||||
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
|
||||
*/
|
||||
{
|
||||
kind: 'project' as const,
|
||||
into: 'substitution.difference_item',
|
||||
prune: true,
|
||||
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
|
||||
fields: {
|
||||
substitution_relation_id: { in: 'relationId' },
|
||||
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
|
||||
parameter_name: { col: 'param_name' },
|
||||
parameter_desc: { col: 'param_desc' },
|
||||
parameter_unit: { col: 'param_unit' },
|
||||
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
|
||||
constraint_type: { col: 'constraint_type' },
|
||||
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
|
||||
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
|
||||
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
|
||||
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
|
||||
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
|
||||
...JUDGEMENT_DERIVED_FIELDS,
|
||||
rule_version: RULE_VERSION,
|
||||
},
|
||||
},
|
||||
DIFFERENCE_ITEM_FROM_RESULT,
|
||||
RELATION_PROJECTION,
|
||||
VALIDATION_PROJECTION,
|
||||
{ kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
|
||||
@@ -798,6 +867,13 @@ export const ASSESSMENT_STEPS = [
|
||||
*/
|
||||
let: SHARED_LET,
|
||||
do: [
|
||||
/*
|
||||
* ①② **人打回时,把他的意见连同上一轮结论交回给 AI 重判**(2026-09-01 用户定)——
|
||||
* 只在被打回的那一轮跑(`case_redo_note != ""`),常路(第一次送审)这两笔跳过。
|
||||
* 判完落回明细,后面三笔重算照旧走:派生列跟着新四态变,等级、风险、验证记录跟着变。
|
||||
*/
|
||||
REVIEW_REDO_ANALYZE,
|
||||
DIFFERENCE_ITEM_FROM_REDO,
|
||||
/*
|
||||
* **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有
|
||||
* 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE`
|
||||
|
||||
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
|
||||
packageVersion: '2.16.0',
|
||||
packageVersion: '2.17.0',
|
||||
category: '需求分析与方案匹配',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-part-substitution-assessment',
|
||||
|
||||
@@ -316,6 +316,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
||||
}
|
||||
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
|
||||
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
|
||||
/*
|
||||
* **人把这一步打回重做时写下的那句话**(2026-09-01)—— 引擎每一拍都注给配方(没打回就是空串,
|
||||
* 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。活动据它决定「这一轮要不要把意见交回给 AI 重判」。
|
||||
* 名字用下划线而不是 `case:` 前缀:S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 `when` 条件用。
|
||||
*/
|
||||
out.add('case_redo_note');
|
||||
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
||||
const m = /id="([^"]+)"/.exec(id);
|
||||
if (m?.[1] !== undefined) out.add(m[1]);
|
||||
|
||||
@@ -134,6 +134,12 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
|
||||
if (expr === 'never') return false;
|
||||
if (expr === 'always') return true;
|
||||
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
|
||||
/*
|
||||
* 人打回时写的那句话:空串 = 没打回(2026-09-01)。**照真 S-FEEL 的语义写**——
|
||||
* 未绑定的名字 `!= ""` 求值为 **true**(见本 describe 里那条直接拿 @sfeel 验的用例);
|
||||
* 桩若在这儿手滑写成「没有就当空串」,「必须永远绑上」这条就永远钉不住。
|
||||
*/
|
||||
if (expr === 'case_redo_note != ""') return !('case_redo_note' in scope) || String(scope['case_redo_note']) !== '';
|
||||
throw new Error(`没教过这句:${expr}`);
|
||||
},
|
||||
expression: () => undefined,
|
||||
@@ -383,6 +389,57 @@ describe('analyze 动词', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **人打回时写的那句话要到得了配方**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
|
||||
* 重新让 ai 重跑这一步?」)—— 配方据它决定这一轮要不要把意见交回给 AI 重判。
|
||||
* 名字是 `case_redo_note`(下划线,不是 `case:` 前缀):S-FEEL 的名字不许带冒号,
|
||||
* 而这句话最要紧的用法恰恰是当 `when` 条件用。
|
||||
*/
|
||||
describe('case_redo_note —— 人打回写的那句话', () => {
|
||||
const ROW = { kind: 'project' as const, into: 'sub_eval.part', from: { once: true as const } };
|
||||
|
||||
it('打回了:那句话进作用域,`when` 与 `{in:}` 都读得到', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
const out = await runner.run(
|
||||
{
|
||||
do: [{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0', note: { in: 'case_redo_note' } } }],
|
||||
},
|
||||
{ ...ctx, redoNote: '使能阈值那条请写明验证任务号' },
|
||||
);
|
||||
expect(out.wrote).toBe(1);
|
||||
expect([...drafts.values()][0]?.patch['note']).toBe('使能阈值那条请写明验证任务号');
|
||||
});
|
||||
|
||||
it('没打回:这个名字**仍然绑着**(空串)—— 只在被打回时才跑的那几笔因此关得住', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
const out = await runner.run(
|
||||
{
|
||||
do: [
|
||||
{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'REDO-ONLY', role: 'x' } },
|
||||
{ ...ROW, fields: { eval_ref: 'W05-1', internal_pn: 'ALWAYS', note: { in: 'case_redo_note' } } },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
/* 第一笔被 when 挡住(绑了空串);第二笔照跑,那句话取出来是空串 */
|
||||
expect(out.wrote).toBe(1);
|
||||
const rows = [...drafts.values()];
|
||||
expect(rows.map((d) => d.key['internal_pn'])).toEqual(['ALWAYS']);
|
||||
expect(rows[0]?.patch['note']).toBe('');
|
||||
});
|
||||
|
||||
/*
|
||||
* **为什么非绑不可**——用**真** S-FEEL(不是本文件顶部那个只认几句话的桩)把两种下场摆出来:
|
||||
* 不绑,「只在被打回时才跑」的那几笔**每一轮都会跑**(未绑定的名字 `!= ""` 求值为真);
|
||||
* 绑上空串才关得住。本仓踩过同一个坑不止一次(S1 的 `doneWhen`、`gate_reason` 那条)。
|
||||
*/
|
||||
it('不绑的下场:真 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true**', () => {
|
||||
expect(evaluateCondition('case_redo_note != ""', {})).toBe(true);
|
||||
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '' })).toBe(false);
|
||||
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '有意见' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('card 与 submit', () => {
|
||||
it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
|
||||
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
|
||||
|
||||
@@ -43,6 +43,16 @@ export type AutoContext = {
|
||||
readonly quiet: boolean;
|
||||
/** 这一步已经发过卡(worldTouched) */
|
||||
readonly carded: boolean;
|
||||
/**
|
||||
* **人把这一步打回重做时写下的那句话**(2026-08-31 用户定:「重做后要把原因带回去,
|
||||
* 让 AI 重跑这一步」)—— 没有打回就是空串,**永远绑得上**:S-FEEL 里未绑定的名字参与
|
||||
* 比较会翻车(`!= ""` 求值为真),那正好会让「只在被打回时才跑」的那几笔每一轮都跑。
|
||||
*
|
||||
* 配方里的名字是 **`case_redo_note`**(下划线,不是 `case:` 前缀)—— S-FEEL 的名字只认
|
||||
* `[A-Za-z0-9_一-龥]`(`packages/sfeel/src/lexer.ts` 的 NAME_PART),带冒号的名字
|
||||
* 写不进 `when` 条件;而这句话最要紧的用法恰恰是当条件用。
|
||||
*/
|
||||
readonly redoNote?: string | undefined;
|
||||
};
|
||||
|
||||
export type AutoOutcome = {
|
||||
@@ -98,7 +108,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
async run(recipe, ctx) {
|
||||
const writable = await deps.data.writable(ctx.taskId);
|
||||
const results: Record<string, unknown[]> = {};
|
||||
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
/* 人打回时写的那句话:没有就是空串(见 `AutoContext.redoNote` 的头注 —— 必须永远绑得上) */
|
||||
const redoNote = { case_redo_note: ctx.redoNote ?? '' };
|
||||
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
|
||||
let dirty = false;
|
||||
let wrote = 0;
|
||||
let dropped = 0;
|
||||
@@ -391,7 +403,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
for (const [index, action] of recipe.do.entries()) {
|
||||
try {
|
||||
if (dirty) {
|
||||
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
scope = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
|
||||
dirty = false;
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -2102,6 +2102,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
||||
quiet: false,
|
||||
carded: touchedItem && settled,
|
||||
/* 打回那句话交给配方(`case_redo_note`)—— 活动据它决定这一轮要不要把意见交回给 AI 重跑 */
|
||||
...(settled || answer?.chose === undefined ? {} : { redoNote: answer.chose.startsWith('custom:') ? answer.chose.slice(7).trim() : answer.chose }),
|
||||
});
|
||||
if (auto.failedAt !== undefined) {
|
||||
/*
|
||||
|
||||
+6
-2
@@ -640,8 +640,12 @@ const SUITES = {
|
||||
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
|
||||
expectDecision: true,
|
||||
expectPort: 'assessment-released',
|
||||
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
|
||||
expectAnalyzeCalls: 1,
|
||||
/*
|
||||
* **两次**:S2 第一轮判 + S3 被打回那一轮的复核重判(2026-09-01 用户定:
|
||||
* 「重做后要把原因带回去,重新让 AI 重跑这一步」)。这条案例正是「先点名驳回、再通过」——
|
||||
* 它同时钉住:意见真交回给了 AI(不是只把派生列重算一遍),而没被打回的案例仍然只调一次。
|
||||
*/
|
||||
expectAnalyzeCalls: 2,
|
||||
/*
|
||||
* **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号**
|
||||
* (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):
|
||||
|
||||
Reference in New Issue
Block a user