feat(w05): S3 审核卡与 S4 落账放行 —— 摘要只读,改的是四态

关系与验证记录的投影抽成模块级常量,S2 与 S3 共用同一份:
人改了某一条四态,等级 / 风险 / 摘要 / 验证要求跟着重算。
各写一份早晚分叉,而分叉那一刻屏上会同时摆着两个互相矛盾的等级。
次序是投影在前、卡在后:人交回后卡这一笔只跳过自己,投影仍跑 —— 重算才发生在该发生的那一刻。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 16:47:08 +08:00
co-authored by Claude Opus 5
parent cd88d0e026
commit 725f1ecb0c
2 changed files with 189 additions and 87 deletions
@@ -297,3 +297,44 @@ describe('S2 配方 —— AI 只出场一次', () => {
} }
}); });
}); });
describe('S3 / S4 配方', () => {
const s3 = stepOf('S3_review')?.auto?.do ?? [];
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', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.relation');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('validation.record');
});
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
expect(s3[0]).toBe(s2[2]);
expect(s3[1]).toBe(s2[3]);
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[2];
expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve');
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
});
it('S4 两笔:写结论一句话 → 提交放行', () => {
expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']);
expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case');
expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true);
});
it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => {
expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4);
});
it('两步都不给 doneWhen —— 收步由人答卡决定', () => {
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
});
});
@@ -110,6 +110,90 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号
· 硬约束 MISMATCH 不许被措辞改判成 MATCH,要改只能补证据后重比; · 硬约束 MISMATCH 不许被措辞改判成 MATCH,要改只能补证据后重比;
· 不写客户批准状态——那是独立审批流的事,本活动只答技术可替代性。`; · 不写客户批准状态——那是独立审批流的事,本活动只答技术可替代性。`;
/*
* **关系与验证记录的投影,S2 与 S3 共用同一份**。
* S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
* 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。
*/
const RELATION_PROJECTION = {
kind: 'project' as const,
into: 'substitution.relation',
from: { once: true as const },
fields: {
substitution_relation_id: { in: 'relationId' },
eval_ref: { in: 'evalRef' },
original_internal_pn: { in: 'originalPn' },
candidate_internal_pn: { in: 'candidatePn' },
chip_type_id: { in: 'typeId' },
recommendation_level: {
when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended',
else: {
when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional',
else: {
when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional',
else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' },
},
},
},
risk_level: {
when: 'recommendation_level = "not_recommended"', then: 'high',
else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' },
},
difference_summary: {
cat: [
'共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:',
{ in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ',
{ in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ',
{ in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ',
{ in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ',
{ in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。',
],
},
recommendation_reason: {
when: 'recommendation_level = "not_recommended"',
then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。',
else: {
when: 'recommendation_level = "recommended"',
then: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。',
},
},
verification_requirement: {
when: 'recommendation_level = "conditional"',
/* `{joinRows}` 未实现 —— 不逐个列参数名,指向明细表(那里逐条摆着,且人能就地改) */
then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。',
else: '',
},
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
analysis_status: 'fae_confirmed',
rule_version: RULE_VERSION,
},
} as const;
/* P-06 **一单固定一条**(用户定)。本活动只写「还得去验什么」,状态一律 `not_started`:它不是实测结果,**不许虚构实测**。 */
const VALIDATION_PROJECTION = {
kind: 'project' as const,
into: 'validation.record',
from: { once: true as const },
fields: {
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
eval_ref: { in: 'evalRef' },
validation_internal_pn: { in: 'candidatePn' },
validation_type: 'parametric',
validation_status: 'not_started',
test_condition: {
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。',
else: '无',
},
next_validation_action: {
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。',
else: '本轮无待验项。',
},
},
} as const;
/** /**
* 步骤剧本 —— 一个计划项一条。四步**全程零 `guide`、零工具**:行为改由 `auto` 配方接管 * 步骤剧本 —— 一个计划项一条。四步**全程零 `guide`、零工具**:行为改由 `auto` 配方接管
* (ADR-014),这一份只放骨架 —— 每步 `auto.do` 先各摆一笔 `say` 占位,配方的实体 * (ADR-014),这一份只放骨架 —— 每步 `auto.do` 先各摆一笔 `say` 占位,配方的实体
@@ -420,92 +504,19 @@ export const ASSESSMENT_STEPS = [
rule_version: RULE_VERSION, rule_version: RULE_VERSION,
}, },
}, },
/* RELATION_PROJECTION,
* ③ 关系一行 —— **等级、风险、摘要、理由全由规则与模板拼,不让 AI 编**。 VALIDATION_PROJECTION,
* 于是永远不会出现「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。
*/
{
kind: 'project' as const,
into: 'substitution.relation',
from: { once: true as const },
fields: {
substitution_relation_id: { in: 'relationId' },
eval_ref: { in: 'evalRef' },
original_internal_pn: { in: 'originalPn' },
candidate_internal_pn: { in: 'candidatePn' },
chip_type_id: { in: 'typeId' },
recommendation_level: {
when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended',
else: {
when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional',
else: {
when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional',
else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' },
},
},
},
risk_level: {
when: 'recommendation_level = "not_recommended"', then: 'high',
else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' },
},
difference_summary: {
cat: [
'共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:',
{ in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ',
{ in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ',
{ in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ',
{ in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ',
{ in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。',
],
},
recommendation_reason: {
when: 'recommendation_level = "not_recommended"',
then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。',
else: {
when: 'recommendation_level = "recommended"',
then: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。',
},
},
verification_requirement: {
when: 'recommendation_level = "conditional"',
/* `{joinRows}` 未实现 —— 不逐个列参数名,指向明细表(那里逐条摆着,且人能就地改) */
then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。',
else: '',
},
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
analysis_status: 'fae_confirmed',
rule_version: RULE_VERSION,
},
},
/*
* ④ 验证记录一行 —— **一单固定一条**(用户定)。本活动只写「还得去验什么」,
* 状态一律 `not_started`:它不是实测结果,**不许虚构实测**。
*/
{
kind: 'project' as const,
into: 'validation.record',
from: { once: true as const },
fields: {
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
eval_ref: { in: 'evalRef' },
validation_internal_pn: { in: 'candidatePn' },
validation_type: 'parametric',
validation_status: 'not_started',
test_condition: {
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。',
else: '无',
},
next_validation_action: {
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。',
else: '本轮无待验项。',
},
},
},
{ kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' }, { kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
], /*
* **`] as const` 是必须的,不是随手加的**:`do` 数组里一旦混入对已命名常量的
* *引用*(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`),TS 对这个数组字面量的
* 逐位置精确类型推断就整体失效 —— 连数组里其它仍是内联字面量的位置(如 `acts[1]`)
* 也会被拉平成「这个数组里出现过的所有形状的并集」,`fields['具体列名']` 当场报
* TS7053(实测:去掉这个 `as const`,`pnpm typecheck` 在 `S2 配方` describe 块
* 报 9 处这类错误,尽管 `vitest run` 不做类型检查、照样能跑绿 —— 这是本仓已有的
* `: {}` vs `: undefined` 那类「并集吞窄类型」陷阱的第二种触发形态)。
*/
] as const,
}, },
}, },
{ {
@@ -523,7 +534,28 @@ export const ASSESSMENT_STEPS = [
'判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿', '判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿',
'影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值', '影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值',
], ],
auto: { do: [{ kind: 'say' as const, text: '差异结论已备好,请过目。' }] }, auto: {
do: [
/* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
RELATION_PROJECTION,
VALIDATION_PROJECTION,
{
kind: 'card' as const,
as: 'decide' as const,
variant: 'review' as const,
title: '差异评估结论审核',
ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。',
options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }],
basis: [
{ k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } },
{ k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } },
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
],
},
{ kind: 'say' as const, text: '差异结论已送审,请在件上逐条核对。' },
/* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */
] as const,
},
}, },
{ {
item: 'T_release' as const, item: 'T_release' as const,
@@ -541,7 +573,36 @@ export const ASSESSMENT_STEPS = [
writes: ['sub_eval.case'], writes: ['sub_eval.case'],
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'], reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'],
tools: [], tools: [],
auto: { do: [{ kind: 'say' as const, text: '拟落账内容已备好,请核对后签字。' }] }, auto: {
do: [
{
kind: 'project' as const,
into: 'sub_eval.case',
from: { once: true as const },
fields: {
eval_ref: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
summary: {
cat: [
{ lookup: 'mine:substitution.relation', by: {}, take: 'candidate_internal_pn', else: '' },
' 替代 ',
{ lookup: 'mine:substitution.relation', by: {}, take: 'original_internal_pn', else: '' },
':',
{ lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' },
],
},
},
},
{
kind: 'submit' as const,
rationale: {
cat: [
'逐项差异评估已经审核通过:',
{ in: 'substitution_difference_item_rows' }, ' 条明细、1 条替代关系、1 条验证记录待落账。',
],
},
},
],
},
}, },
]; ];