Compare commits

...
4 Commits
Author SHA1 Message Date
xcsandClaude Fable 5 f41bd76b0e CLAUDE.md:今日五条入快照 —— 兜底轮询 · 录入卡占位 · 差异比对三刀
CI / ci (push) Failing after 11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:08 +08:00
xcsandClaude Fable 5 88f2149d12 差异比对三刀:客户约束不再一票否决 · 入参由基座从台账取 · 「下限~上限」一律读成范围
①**两颗一模一样的输入电压范围判成 MISMATCH**(用户送来 0bddd862):客户写「45V 以内」→ requirement `>= 45`,
而原 PN 与候选上限都是 40V;`judgePair` 只看候选过不过客户线、不看原 PN 自己过不过 ——
需求边界与在用料的差距记在了候选头上,硬约束 MISMATCH → 整颗不推荐。差异评估比的是「换上去会不会比现在差」:
候选与原 PN 相等 / 盖住、且客户线原 PN 也过不去 → 仍按比对结果判(MATCH,风险中),备注点名
「需求边界,请回头核对需求」;候选更窄且过不了线仍 MISMATCH(3 形有测)。
②**入参由基座从台账取,模型只报料号与客户约束**(用户:「这个做差异评估比对的 harness 肯定有问题」):
同一单痕里模型念出「输入上限 32V 低于客户 40V」—— 那是 S3 剧本示例的原话、不是这单的数;
工具入参只存哈希,喂了哪几对值事后查不到;写回时还把工具的 `equal` 改成 `less`。两头都靠模型抄、两头都没有基座核。
改三层:工具运行时把 `taskId / activityId` 交给处理器(`ToolHandler` 入参加两个可选键);
`compare-parameters` 给 `originalInternalPn + candidates[].candidateInternalPn(+ requirements)`,
参数行从 `parts.parameter`、封装 / 生命周期从 `parts.profile`、证据引用从 `parts.document` 由工具现读任务视图
(本单草稿也算),出参每项带原值与证据,写盘照抄;老形状(自带 pairs)照接,试运行走它;
container 用 `compareParametersWith(ledger)` 顶掉静态名单那份;≤800 字的工具入参整份进 `result` 痕(审得了才谈得上认真)。
**基座能读到的事实,不让模型转述给工具。**
③**「下限~上限」一律是范围**(用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚」):实测解析器真有洞 ——
单位写进值里(`4.5V~40V` / `-40℃~+85℃`)被读成单个数,Unicode 减号、`…`、`≥45 / 40 以内` 读成文本。
分隔符认 ~ ~ - – — … 至 到 to、单位可夹在值里、单边界读成半开范围;出参每项 `readAs:{original,candidate}`
写明「范围 4.5~40 / 定值 5 / 下界 ≥ 45 / 文本」—— 比对依据可见,模型照它写影响、不自己重新解释值;
契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。
工具 1.0.0 → 1.3.0(注册一名一行、upsert 换版本,handler 在进程内按名绑,装完稳定版重建即生效);
W05 包 1.6.2 → 1.6.4:S3 第一拍只报料号与约束、示例只留占位形,第三拍 compare_result 照抄,S4 重比只改 requirements。
13 工具测 + 包内 89。在途单要人在审核卡上「重做」并写明意见才会重比。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:07 +08:00
xcsandClaude Fable 5 e3479e0b9b fix(web): 录入卡在等人时,工作区摆占位页,不摆件
建档那一步(「客户 123 不在客户档案里,请核对建档」)要人做的事全在对话区那张录入卡上,
而工作区里躺着的是**上一拍**推的 HTML 件 —— 摆着只会让人以为要在那份工单上办点什么。
`pendingCardsOf`(纯函数,5 测)认出未确认的录入卡 → `ViewRegion` 在 running 之后、viewHtml 之前
插一支占位 `EmptyState`(点名那张表的人读名,指向对话区)。
决策卡**不走**这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:07 +08:00
xcsandClaude Fable 5 282584d26e feat(web): 工作台任务界面给 SSE 配 10 秒兜底轮询 —— 只此一处,由壳层起停
`app/fallback-poll.ts`:任务界面开着时每 10 秒把 `desk` 下**在用的**查询(清单 / 这一单 / 角标,
`type:'active'`)各重取一次,推送漏了画面最多旧 10 秒。**不取消在途**(`cancelRefetch:false` ——
慢接口不被兜底打成永远取不回)、页签藏起不打、回前台先补一趟;壳层 `useEffect` 起停,
切模块 / 卸载即 `stop()`(定时器与可见性监听一起摘)。`doc` 可注入,4 测钉死
(周期 / 停止 / 可见性 / 在途去重,第四条做过变异验证)。
`queryKeys.desk` 新增作整支前缀,订阅层的裸 `['desk']` 改用它。
编码规范 §9.2「组件禁止自行轮询」照旧,补一句:兜底只此一处、由壳层起停。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:07 +08:00
23 changed files with 817 additions and 45 deletions
+37
View File
@@ -1336,6 +1336,17 @@
让用例失败而不是把测试进程撑爆 —— 第一版真·无限递归 OOM 了)。两侧任一打上补丁回弹即断。 让用例失败而不是把测试进程撑爆 —— 第一版真·无限递归 OOM 了)。两侧任一打上补丁回弹即断。
连带看见但未动:`task-service.tick` 每拍无条件 `setPhase` 广播 + 每拍推帧 + 推进器 `advanced`,一拍 连带看见但未动:`task-service.tick` 每拍无条件 `setPhase` 广播 + 每拍推帧 + 推进器 `advanced`,一拍
3-4 条 `task` touch,前端 `invalidateFor` 无合并 —— 推送有多密请求就有多密(记 debt)。 3-4 条 `task` touch,前端 `invalidateFor` 无合并 —— 推送有多密请求就有多密(记 debt)。
- **工作台任务界面给 SSE 配了 10 秒兜底轮询**(2026-08-28 用户定):`app/fallback-poll.ts` ——
任务界面开着时每 10 秒把 `desk` 下**在用的**查询(清单 / 这一单 / 角标,`type:'active'`)各重取一次,
推送漏了画面最多旧 10 秒;**不取消在途**(`cancelRefetch:false`,慢接口不被兜底打成永远取不回)、
页签藏起不打、回前台先补一趟;壳层 `useEffect` 起停,切模块 / 卸载即 `stop()`(定时器与可见性监听一起摘)。
`doc` 可注入,4 测钉死(周期 / 停止 / 可见性 / 在途去重,第四条做过变异验证)。
§9.2「组件禁止自行轮询」照旧:兜底只此一处、由壳层起停。`queryKeys.desk` 新增作整支前缀。
- **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩的差异评估单定的):建档那一步
(「客户 123 不在客户档案里,请核对建档」)要人做的事全在对话区那张录入卡上,而工作区里躺着的是
**上一拍**推的 HTML 件,人以为要在那份工单上办点什么。前端判:`pendingCardsOf(detail.chat)`
(纯函数,5 测)认出未确认的录入卡 → `ViewRegion` 在 running 之后、viewHtml 之前插一支占位
`EmptyState`(点名那张表的人读名,指向对话区);决策卡**不走**这一支 —— 人要决定的常常就是件上那份稿。
- **第三个活动 ACT-WORK-05 差异评估已入库并全流程走查**(2026-08-27,`part-substitution-assessment@1.2.6`): - **第三个活动 ACT-WORK-05 差异评估已入库并全流程走查**(2026-08-27,`part-substitution-assessment@1.2.6`):
原 PN 与候选替代料**逐项差异评估** —— 比参数、判四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)、 原 PN 与候选替代料**逐项差异评估** —— 比参数、判四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)、
@@ -1570,6 +1581,32 @@
全贴顶边。与 08-x 治五模块空态同一招:`.wf-body:has(> .jc-empty / > .jc-working)` 才切成纵向 flex, 全贴顶边。与 08-x 治五模块空态同一招:`.wf-body:has(> .jc-empty / > .jc-working)` 才切成纵向 flex,
件 / 日志 / 往来记录这些正文照旧从顶往下排。浏览器实证「这单已收尾」「还没有往来记录」两屏居中。 件 / 日志 / 往来记录这些正文照旧从顶往下排。浏览器实证「这单已收尾」「还没有往来记录」两屏居中。
- **两颗一模一样的输入电压范围判成 MISMATCH:客户约束一票否决**(2026-08-27 晚,用户送来 0bddd862):
客户写「输入电压在 45V 以内」→ 模型译成 requirement `>= 45`,原 PN 与候选上限都是 40V;`judgePair` 只看候选
过不过客户线,不看原 PN 自己过不过 —— 需求边界与在用料的差距记在候选头上,硬约束 MISMATCH → 整颗不推荐。
差异评估比的是「换上去会不会比现在差」:工具 `compare-parameters` 升 1.1.0 —— 候选与原 PN 相等 / 盖住、
且客户线原 PN 也过不去 → 仍按比对结果判(MATCH,风险中),备注点名「需求边界,请回头核对需求」;
候选更窄且过不了线仍 MISMATCH(3 形有测)。工具注册一名一行、upsert 换版本,handler 在进程内按名绑,
装完稳定版重建即生效;在途单要人在审核卡上「重做」并写明意见才会重比。
- **差异比对的入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的
harness 肯定有问题,要让它认真比对每一条,然后再输出结论」):同一单痕里模型念出「输入上限 32V 低于客户 40V」——
那是 S3 剧本示例的原话,不是这单的数;工具入参只存哈希,喂了哪几对值事后查不到;写回时还把工具的 `equal`
改成 `less`。两头都靠模型抄、两头都没有基座核。改三层:①工具运行时把 `taskId / activityId` 交给处理器
(`ToolHandler` 入参加两个可选键);②`compare-parameters` 1.2.0:给 `originalInternalPn + candidates[].
candidateInternalPn(+ requirements)`,参数行从 `parts.parameter`、封装 / 生命周期从 `parts.profile`、证据引用从
`parts.document` 由工具现读任务视图(本单草稿也算),出参每项带原值与证据,写盘照抄;老形状(自带 pairs)照接,
试运行走它;container 用 `compareParametersWith(ledger)` 顶掉静态名单那份;③≤800 字的工具入参整份进 `result`
痕(审得了才谈得上认真);W05 S3 第一拍只报料号与约束、示例只留占位形,第三拍 compare_result 也照抄;
S4 重比只改 requirements。13 工具测 + 包内 89。**基座能读到的事实,不让模型转述给工具。**
- **「下限~上限」一律是范围:解析器补齐写法、出参摆出读法**(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型
搞不清楚,harness 里强调一下是范围值」):实测解析器真有洞 —— 单位写进值里(`4.5V~40V` / `-40℃~+85℃`)被读成
单个数,Unicode 减号、`…`、`≥45 / 40 以内` 读成文本。工具 1.3.0:分隔符认 ~ ~ - – — … 至 到 to、单位可夹在值里、
单边界读成半开范围;出参每项 `readAs:{original,candidate}` 写明「范围 4.5~40 / 定值 5 / 下界 ≥ 45 / 文本」——
比对依据可见,模型照它写影响、不自己重新解释值;契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,
不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。W05 升版装上。3 测(11+7 种写法)。
- **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行 - **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 + 实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。 参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
@@ -162,6 +162,21 @@ describe('说明书与剧本的骨架', () => {
expect(s3).toContain('工具调用后同拍其余动作作废'); expect(s3).toContain('工具调用后同拍其余动作作废');
}); });
/*
* 2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题,要让它认真比对每一条」。实测(0bddd862)模型把剧本示例里的
* 「4.5~32 / >= 40」当成这单的值念出来;比对入参改由工具从台账现读,模型只报料号与客户约束,示例里不再有任何具体参数值。
*/
it('S3 第一拍只报料号与客户约束:入参形状是 originalInternalPn + candidates[].requirements,示例里没有具体参数值', () => {
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
expect(s3).toContain('"originalInternalPn":"<原 PN>"');
expect(s3).toContain('"requirements"');
expect(s3).not.toContain('"pairs"');
expect(s3).not.toMatch(/4\.5~32|4\.5~40|32V|40V/);
expect(s3).toMatch(/一个参数值都不抄/);
// 写盘照抄出参:值与 compare_result 都不许换
expect(s3).toMatch(/compare_result 也照抄/);
});
it('S3 第三拍写盘前先自检两枚工具的调用次数(2026-08-27 实测:漏调 recommend 会让 doneWhen 恒假、模型死循环重写同样的行)', () => { it('S3 第三拍写盘前先自检两枚工具的调用次数(2026-08-27 实测:漏调 recommend 会让 doneWhen 恒假、模型死循环重写同样的行)', () => {
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? ''; const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
const thirdPass = s3.slice(s3.indexOf('第三拍')); const thirdPass = s3.slice(s3.indexOf('第三拍'));
@@ -400,6 +400,8 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
只有每颗料的名字集合一模一样时才成立;混着写(这颗中文那颗英文、或同一颗写两套)这一步就过不去。有 \`datasheets[].text\` 就从里面读出典型参数 只有每颗料的名字集合一模一样时才成立;混着写(这颗中文那颗英文、或同一颗写两套)这一步就过不去。有 \`datasheets[].text\` 就从里面读出典型参数
(如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度等,按品类而定)各写一行, (如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度等,按品类而定)各写一行,
key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡, key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡,
**param_value 的写法**:范围写「下限~上限」(如 1~10、-20~+60 —— 这表示一整段,比对工具按范围读),单位放 param_unit 不写进值里,
定值只写一个数;
如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写 如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写
internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。 internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。
3. 同一拍**给名单里每一颗料各写一行** \`parts.document\` 骨架(两颗就两行,别只写一行 —— 3. 同一拍**给名单里每一颗料各写一行** \`parts.document\` 骨架(两颗就两行,别只写一行 ——
@@ -532,13 +534,17 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
'tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0', 'tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0',
guide: `对照表铺好了,**固定三拍**:比对 → 定级 → 写盘。**工具调用后同拍其余动作作废**,别把工具和写台账串在一起;两枚工具各只调一次。 guide: `对照表铺好了,**固定三拍**:比对 → 定级 → 写盘。**工具调用后同拍其余动作作废**,别把工具和写台账串在一起;两枚工具各只调一次。
【第一拍:只调 \`${TOOL_COMPARE}\`,别的什么都不写】把**全部**关系一次打包: 【第一拍:只调 \`${TOOL_COMPARE}\`,别的什么都不写】**只报料号与客户约束,一个参数值都不抄** —— 参数、封装、生命周期、证据引用
{"candidates":[{"candidateInternalPn":"<候选>","pairs":[{"parameterName":"输入电压范围","originalValue":"4.5~40","candidateValue":"4.5~32", 由工具自己从台账现读(parts.parameter / parts.profile / parts.document,本单刚建的也算),每一对都是台账原值:
"unit":"V","constraintType":"hard","requirement":">= 40","evidenceRef":"P-08 · P-10 Rev H"}, …]}, …]} {"originalInternalPn":"<原 PN>","candidates":[{"candidateInternalPn":"<候选>","requirements":{"<参数名>":"<约束>"}}, …]}
· pairs 逐条照 \`substitution.difference_item\` 抄(parameterName / originalValue / candidateValue / unit / constraintType / evidenceRef); · 全部候选一次打包;
· **客户在应用场景里给了边界的参数才写 requirement**(如「输入上限 40V」→ 输入电压范围那一对写 ">= 40";「工业温域」→ 工作温度写 "-40~85"); · **requirements 只写客户在需求 / 应用场景里给了边界的参数**(如客户说输入上限是某个电压 → 输入电压范围写 ">= <那个数>";
· 每一对都带 evidenceRef —— 硬约束没有证据引用工具不判 MATCH,那正是要的。 说工业温域 → 工作温度写 "-40~85");客户没给边界的参数**不写**,不要替客户编约束;
调完这一拍结束。 · evidenceRef 不用给(工具从候选的 P-10 资料记录取);资料记录真没有时硬约束会退成 CONDITIONAL —— 那正是要的。
调完这一拍结束。出参 items 里每一项带 originalValue / candidateValue / unit / constraintType / evidenceRef 与四态,写盘照抄。
**值的读法(工具与你必须一致)**:「下限~上限」是**范围**—— 如 1~10 是 1 到 10 的整段,-20~+60 是 -20 到 60 的整段,**不是两个数、
不是「1 或 10」**;两颗写着同一段就是一致。出参每项的 readAs 写着工具把两边各读成了什么(范围 / 定值 / 单边界 / 文本),
写 impact_statement 时按它说,别自己重新解释一遍值。
【第二拍:只调 \`${TOOL_RECOMMEND}\`,别的什么都不做】把第一拍出参里每个候选的 counts 原样打包传进去:{"candidates":[{"candidateInternalPn":"…","counts":{…}}, …]}。调完这一拍结束。 【第二拍:只调 \`${TOOL_RECOMMEND}\`,别的什么都不做】把第一拍出参里每个候选的 counts 原样打包传进去:{"candidates":[{"candidateInternalPn":"…","counts":{…}}, …]}。调完这一拍结束。
**推荐等级只能由它算**——你自己填一个进 \`substitution.relation.recommendation_level\`,数据面看着像对的,机制上却是你拍的; **推荐等级只能由它算**——你自己填一个进 \`substitution.relation.recommendation_level\`,数据面看着像对的,机制上却是你拍的;
@@ -549,8 +555,11 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
\`tool_part_substitution_assessment_recommend_calls\`。**两个都必须 > 0 才能写盘**。若 \`recommend\` 那个还是 0, \`tool_part_substitution_assessment_recommend_calls\`。**两个都必须 > 0 才能写盘**。若 \`recommend\` 那个还是 0,
说明第二拍没调成——**回去调它**(入参就是第一拍出参里每个候选的 counts),不要往下写、更不要自己拍推荐等级。 说明第二拍没调成——**回去调它**(入参就是第一拍出参里每个候选的 counts),不要往下写、更不要自己拍推荐等级。
这一步的收步判据里就有这两个数,**不调够,写多少行都收不了步**。 这一步的收步判据里就有这两个数,**不调够,写多少行都收不了步**。
① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:difference_status / compare_result / risk_level 照第一拍出参**一字不改**; ① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:parameter_name / original_value / candidate_value / parameter_unit / constraint_type /
impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,如「输入上限 32V < 客户 40V,不能上板」); evidence_ref / difference_status / compare_result / risk_level 照第一拍出参**一字不改**(值是台账原值,不是你记忆里的;
compare_result 也照抄,别换成自己的词);
impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,引出参里的具体数值与工具 note 的措辞 —— note 说「需求边界」的,
影响就写「需求边界与在用料的差距,请核对需求」,不写成候选的短板);
difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测<参数名>」或「向原厂补来源」);confidence(0~1)。 difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测<参数名>」或「向原厂补来源」);confidence(0~1)。
② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、 ② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、
validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。 validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。
@@ -586,7 +595,10 @@ difference_status / compare_result / risk_level / impact_statement / unknown_dis
· "reject" / "custom:…" → **驳回**。**不许写 review_approved_total、不许 finish** —— 下一步的门读的是基座数 · "reject" / "custom:…" → **驳回**。**不许写 review_approved_total、不许 finish** —— 下一步的门读的是基座数
\`decision_last\`(人最后一次真点了 approve 才开),写了旗也开不了门,只会把自己绕进死循环。按意见改台账(同 PK 覆盖写,不开新行、不改行数): \`decision_last\`(人最后一次真点了 approve 才开),写了旗也开不了门,只会把自己绕进死循环。按意见改台账(同 PK 覆盖写,不开新行、不改行数):
· 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`; · 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`;
· 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持); · 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级**不许自己填** —— 它只能由工具算;写清楚为什么维持);
· 意见说**某条判定本身错了**(客户约束译错、或原 PN 自己也不满足的客户线被记在候选头上)→ 改对 requirements
(值不归你改:参数是台账原值,工具自己读),再**重新调 \`${TOOL_COMPARE}\` 与 \`${TOOL_RECOMMEND}\`**(这一轮各再调一次,
工具调用后同拍其余动作作废,下一拍照出参覆盖写四态、风险、备注与推荐等级 —— 这是等级唯一合法的改法);
· 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**; · 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**;
然后**换一种问法**再发一张决策卡 —— **ask 的开头必须写明这一轮改了什么**(与上一张字面不同; 然后**换一种问法**再发一张决策卡 —— **ask 的开头必须写明这一轮改了什么**(与上一张字面不同;
字面一样的卡会被当成同一张吞掉,连吞两次就算被拒),发完结束。 字面一样的卡会被当成同一张吞掉,连吞两次就算被拒),发完结束。
@@ -90,7 +90,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估', name: 'ACT-WORK-05 · 差异评估',
packageVersion: '1.6.2', packageVersion: '1.6.4',
category: '需求分析与方案匹配', category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment', checksum: 'dev-checksum-part-substitution-assessment',
@@ -5,7 +5,9 @@
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。 * C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts'; import { FIRST_PARTY_TOOLS, compareParametersWith, describeParsed, parseParamValue } from '../tools/first-party.ts';
import type { ParameterLedger } from '../tools/first-party.ts';
import type { TaskId } from '@contracts';
const handler = (name: string) => { const handler = (name: string) => {
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name); const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
@@ -113,6 +115,34 @@ describe('compare-parameters:逐参数四态', () => {
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 }); expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
}); });
/*
* 2026-08-27 晚,用户送来 0bddd862:客户写「输入电压在 45V 以内」→ requirement ">= 45",而原 PN 与候选的输入
* 上限都是 40V。从前客户约束一票否决:两颗一模一样却判硬约束 MISMATCH、整颗不推荐。差异评估比的是
* 「换上去会不会比现在差」—— 原 PN 自己也过不了的客户线,是需求边界的事,不是替代差异。
*/
it('候选与原 PN 相等、客户约束两颗都不满足 → 仍是 MATCH(风险中、备注点名需求边界),不记在候选头上', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'LM2596S-ADJ',
pairs: [
pair('输入电压范围', '4.5~40', '4.5~40', 'hard', { unit: 'V', requirement: '>= 45' }),
// 候选盖住原范围、客户线两颗都过不去 —— 同理
pair('工作温度', '-40~85', '-40~125', 'hard', { unit: '℃', requirement: '-55~125' }),
// 候选比原 PN 窄且过不了客户线 —— 仍是 MISMATCH
pair('输出电流', '3', '2', 'hard', { unit: 'A', requirement: '>= 3' }),
],
},
],
});
const c = (out['results'] as Result[])[0]!;
expect(c.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', riskLevel: 'medium' });
expect((c.items[0] as Item & { note: string }).note).toMatch(/需求边界/);
expect(c.items[1]).toMatchObject({ compareResult: 'covers', differenceStatus: 'match' });
expect(c.items[2]).toMatchObject({ compareResult: 'differs', differenceStatus: 'mismatch', riskLevel: 'high' });
expect(c.counts).toMatchObject({ match: 2, mismatch: 1, hardMismatch: 1 });
});
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => { it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
const out = await call('part-substitution-assessment.compare-parameters', { const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }], candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
@@ -132,6 +162,123 @@ describe('compare-parameters:逐参数四态', () => {
}); });
}); });
/*
* **比对入参由基座从台账取**(2026-08-27 晚,用户:「要让它认真比对每一条,然后再输出结论」):
* 模型只报料号与客户约束;参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读。
*/
describe('compare-parameters:按料号从台账取参数', () => {
const rows: Record<string, Array<Record<string, unknown>>> = {
'parts.parameter|LM2596S-5.0': [
{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
{ internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard' },
{ internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard' },
],
'parts.parameter|LM2596S-ADJ': [
{ internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard' },
// 候选多出来的参数不进比对(原 PN 定义比对集),只在 extraCandidateParams 里点名
{ internal_pn: 'LM2596S-ADJ', param_name: '反馈电压', param_value: '1.23', param_unit: 'V', constraint_type: 'soft' },
],
'parts.profile|LM2596S-5.0': [{ internal_pn: 'LM2596S-5.0', package: 'TO-263-5', lifecycle_status: 'nrnd' }],
'parts.profile|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', package: 'TO-263-5', lifecycle_status: 'active' }],
'parts.document|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', document_type: 'datasheet', document_version: 'Rev H (2023-09)', document_ref: 'https://x/lm2596.pdf' }],
};
const seen: string[] = [];
const ledger: ParameterLedger = {
rows: async (taskId, address, where) => {
seen.push(`${taskId ?? '-'}:${address.domain}.${address.object}|${where['internal_pn']}`);
return rows[`${address.domain}.${address.object}|${where['internal_pn']}`] ?? [];
},
};
const tool = compareParametersWith(ledger);
const run = async (input: Record<string, unknown>, taskId?: TaskId) => {
const out = await tool({ input, connection: {}, operation: undefined, idempotencyKey: undefined, taskId });
return out;
};
it('只给料号 + 客户约束 → 每一对都是台账原值;封装 / 生命周期从档案来;证据引用从资料记录来;读的是**这一单**的任务视图', async () => {
const out = await run({ originalInternalPn: 'LM2596S-5.0', candidates: [{ candidateInternalPn: 'LM2596S-ADJ', requirements: { 输入电压范围: '>= 45' } }] }, 't-1' as TaskId);
expect(out.ok).toBe(true);
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
expect(r['source']).toBe('ledger');
const items = r['items'] as Array<Record<string, unknown>>;
const byName = Object.fromEntries(items.map((i) => [i['parameterName'] as string, i]));
expect(byName['输入电压范围']).toMatchObject({ originalValue: '4.5~40', candidateValue: '4.5~40', unit: 'V', constraintType: 'hard', requirement: '>= 45', evidenceRef: 'P-08 · P-10 Rev H (2023-09)', differenceStatus: 'match' });
expect(byName['输出电压']).toMatchObject({ originalValue: '5.0', candidateValue: '1.23~37', compareResult: 'configurable', differenceStatus: 'conditional' });
// 原 PN 有、候选没有 → 候选值空 → UNKNOWN
expect(byName['使能阈值']).toMatchObject({ originalValue: '1.4', candidateValue: '', differenceStatus: 'unknown' });
expect(byName['封装']).toMatchObject({ originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard', differenceStatus: 'match' });
expect(byName['生命周期']).toMatchObject({ originalValue: 'nrnd', candidateValue: 'active', constraintType: 'soft', compareResult: 'covers', differenceStatus: 'match' });
expect(r['extraCandidateParams']).toEqual(['反馈电压']);
expect(r['counts']).toMatchObject({ match: 3, conditional: 1, unknown: 1, mismatch: 0 });
expect(seen.every((s) => s.startsWith('t-1:'))).toBe(true);
});
it('原 PN 台账里没有 → 如实报错,不比一半;没给 originalInternalPn → 拒', async () => {
const missing = await run({ originalInternalPn: 'NOPE', candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
expect(missing.ok).toBe(false);
expect(missing.error?.message).toMatch(/NOPE/);
const noOriginal = await run({ candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
expect(noOriginal.ok).toBe(false);
expect(noOriginal.error?.message).toMatch(/originalInternalPn/);
});
it('自带 pairs 的老形状照旧接(试运行 / 老调用方),source 标 pairs', async () => {
const out = await run({ candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0', '5.0', 'hard')] }] });
expect(out.ok).toBe(true);
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
expect(r['source']).toBe('pairs');
expect((r['items'] as Array<Record<string, unknown>>)[0]).toMatchObject({ originalValue: '5.0', candidateValue: '5.0', differenceStatus: 'match' });
});
it('没有台账的那份(静态名单)遇到不带 pairs 的入参 → 如实说这条通道读不到台账', async () => {
const out = await compareParametersWith()({ input: { originalInternalPn: 'A', candidates: [{ candidateInternalPn: 'B' }] }, connection: {}, operation: undefined, idempotencyKey: undefined });
expect(out.ok).toBe(false);
expect(out.error?.message).toMatch(/读不到台账/);
});
});
/*
* 2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」。
* 解析器把各种写法都认成范围,出参 readAs 把「读作什么」摆回去 —— 比对依据可见,模型不必自己再解释一遍值。
*/
describe('值的读法:「下限~上限」一律是范围', () => {
it('分隔符 / 正负号 / 单位写进值里 / Unicode 减号 / 省略号 / 中文「至到」都认成范围;单边界读成半开范围;定值一个数', () => {
const range = (v: string) => parseParamValue(v);
for (const v of ['4.5~40', '4.5~40', '4.5-40', '4.5 ~ 40', '4.5V~40V', '4.5V ~ 40V (典型)', '4.5…40', '4.5...40', '4.5至40', '4.5到40', '4.5 to 40']) {
expect(range(v), v).toEqual({ kind: 'range', lo: 4.5, hi: 40 });
}
for (const v of ['-40~+85', '-40~85', '−40~+85', '-40℃~+85℃', '−40 ℃ … +85 ℃', '-40 - 85', '-40 to +85']) {
expect(range(v), v).toEqual({ kind: 'range', lo: -40, hi: 85 });
}
expect(range('≥45')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
expect(range('45 以上')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
expect(range('<= 40')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
expect(range('40V 以内')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
expect(range('40')).toEqual({ kind: 'number', value: 40 });
expect(range('5.0(固定)')).toEqual({ kind: 'number', value: 5 });
expect(range('150 kHz')).toEqual({ kind: 'number', value: 150 });
expect(range('TO-263-5')).toEqual({ kind: 'text', value: 'to-263-5' });
});
it('readAs 说人话:范围 / 定值 / 单边界 / 文本;纯文本(封装)不啰嗦,像范围却没读成的才提示怎么写', () => {
expect(describeParsed(parseParamValue('-40~+85'))).toBe('范围 -40~85');
expect(describeParsed(parseParamValue('5.0'))).toBe('定值 5');
expect(describeParsed(parseParamValue('≥45'))).toBe('下界 ≥ 45');
expect(describeParsed(parseParamValue('TO-263-5'))).toBe('文本「to-263-5」');
expect(describeParsed(parseParamValue('约 4.5 ~ 40'))).toMatch(/请写成 下限~上限/);
});
it('两颗都写 4.5V~40V(单位在值里)→ 读成同一段范围 → equal / MATCH,出参 readAs 摆出「范围 4.5~40」', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输入电压范围', '4.5V~40V', '4.5 ~ 40 V', 'hard', { unit: 'V' }), pair('工作温度', '-40℃~+85℃', '−40~+85', 'hard', { unit: '℃' })] }],
});
const items = (out['results'] as Array<{ items: Array<Item & { readAs: { original: string; candidate: string } }> }>)[0]!.items;
expect(items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 4.5~40', candidate: '范围 4.5~40' } });
expect(items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 -40~85', candidate: '范围 -40~85' } });
});
});
describe('recommend:推荐等级四档', () => { describe('recommend:推荐等级四档', () => {
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => { it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
const out = await call('part-substitution-assessment.recommend', { const out = await call('part-substitution-assessment.recommend', {
+18 -2
View File
@@ -67,7 +67,8 @@ import type { ProcessService } from '@modules/process';
import type { TriggerService } from '@modules/trigger'; import type { TriggerService } from '@modules/trigger';
import type { MinuteService } from '@modules/minute'; import type { MinuteService } from '@modules/minute';
import type { ReportService } from '@modules/report'; import type { ReportService } from '@modules/report';
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts'; import { FIRST_PARTY_TOOLS, compareParametersWith } from './tools/first-party.ts';
import type { ParameterLedger } from './tools/first-party.ts';
import { httpToolHandler } from './tools/http-tool.ts'; import { httpToolHandler } from './tools/http-tool.ts';
import { createMastraSpanFactory } from './mastra-spans.ts'; import { createMastraSpanFactory } from './mastra-spans.ts';
import { createTraceService, createTraceStore } from '@modules/trace'; import { createTraceService, createTraceStore } from '@modules/trace';
@@ -416,8 +417,23 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
const toolRegistry = createToolRegistry(db); const toolRegistry = createToolRegistry(db);
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去 // 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
/*
* 差异评估的比对工具**从台账取参数**(2026-08-27 晚):静态名单里那份只认 pairs;这里用 ledger 顶掉 ——
* 有 taskId 读任务视图(正式版 + 本单草稿),没有(试运行)只读正式版。只读。
*/
const parameterLedger: ParameterLedger = {
rows: async (taskId, address, where) => {
const query = { address, where, orderBy: [], limit: 500, offset: 0 };
const page = taskId === undefined ? await ledger.query(query) : await ledger.taskView(taskId, query);
return page.rows;
},
};
const toolRuntime = createToolRuntime( const toolRuntime = createToolRuntime(
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), new Map<string, ToolHandler>([
...FIRST_PARTY_TOOLS,
['part-substitution-assessment.compare-parameters', compareParametersWith(parameterLedger)],
['mail.send', mailSendTool(mail)],
]),
httpToolHandler, httpToolHandler,
); );
const schemas = createSchemaValidator(); const schemas = createSchemaValidator();
+17 -5
View File
@@ -20,17 +20,29 @@ export const COMPARE_PARAMETERS_TOOL = {
...PURE, ...PURE,
name: 'part-substitution-assessment.compare-parameters', name: 'part-substitution-assessment.compare-parameters',
displayName: '替代料逐参数比对', displayName: '替代料逐参数比对',
version: '1.0.0', /*
* 1.1.0(2026-08-27):客户约束原 PN 自己也不满足、候选与原 PN 相等或盖住时不再判 mismatch —— 那是需求边界,不是替代差异。
* 1.2.0(同日):**比对入参由基座从台账取** —— 给 originalInternalPn + candidates[].candidateInternalPn,参数 / 封装 / 生命周期 /
* 证据引用从 parts.* 现读,模型只译客户约束(requirements);老形状(自带 pairs)照旧接。出参每项带 originalValue / candidateValue / evidenceRef。
*/
version: '1.3.0',
description: description:
'按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' + '按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
'compare_result、risk_level 与各候选的计数。一次可比一批候选(candidates 数组),每对参数可带客户约束 requirement(如 ">= 40")与证据引用 evidenceRef;' + '**值的读法**:「下限~上限」一律是范围(4.5~40 = 4.5 到 40 的整段;-40~+85 = -40 到 85 的整段,不是两个数),' +
'硬约束没有证据引用不判 match。规则版本 R-2026.07。', '分隔符认 ~ - … 至 到 to,单位写进值里也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界;只有一个数是定值。出参每项 readAs 摆出两边各读成了什么。' +
'compare_result、risk_level 与各候选的计数。**推荐用法**:只给 originalInternalPn 与 candidates[].candidateInternalPn,' +
'参数行从 parts.parameter、封装与生命周期从 parts.profile、证据引用从 parts.document 由工具现读(本单草稿层也算),模型不抄值;' +
'candidates[].requirements 是客户给了边界的参数 {参数名: ">= 40" / "-40~85"}。也接老形状(candidates[].pairs 自带值)。' +
'硬约束没有证据引用不判 match。候选不比原 PN 差(相等 / 盖住)而客户约束原 PN 自己也不满足的,不算替代差异:仍按比对结果判、备注点名需求边界。规则版本 R-2026.07。',
input: { input: {
type: 'object', type: 'object',
properties: { properties: {
originalInternalPn: { type: 'string', description: '原 PN(内部料号);按料号从台账取参数时必填' },
candidates: { candidates: {
type: 'array', type: 'array',
description: '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}]', description:
'[{candidateInternalPn, requirements?: {参数名: 约束}, evidenceRef?}](从台账取值) 或 ' +
'[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}](自带值)',
}, },
}, },
}, },
@@ -40,7 +52,7 @@ export const COMPARE_PARAMETERS_TOOL = {
ruleVersion: { type: 'string' }, ruleVersion: { type: 'string' },
results: { results: {
type: 'array', type: 'array',
description: '[{candidateInternalPn, items:[{parameterName, unit, constraintType, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]', description: '[{candidateInternalPn, source:"ledger"|"pairs", items:[{parameterName, originalValue, candidateValue, readAs:{original,candidate}, unit, constraintType, requirement, evidenceRef, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}, extraCandidateParams?}]',
}, },
}, },
}, },
+183 -8
View File
@@ -4,6 +4,7 @@
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。 * 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
*/ */
import { evaluateUnaryTest } from '@sfeel'; import { evaluateUnaryTest } from '@sfeel';
import type { TaskId } from '@contracts';
import type { ToolHandler } from '@modules/toolbox'; import type { ToolHandler } from '@modules/toolbox';
/** /**
@@ -375,11 +376,25 @@ type CompareCounts = { match: number; mismatch: number; unknown: number; conditi
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i; const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
const NUM = '[-+]?\\d+(?:\\.\\d+)?'; const NUM = '[-+]?\\d+(?:\\.\\d+)?';
const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i'); /** 数字后面允许跟一段单位(V / ℃ / °C / kHz / % / mA…)—— 「4.5V~40V」「-40℃~+85℃」这种把单位写进值里的形状要认成范围 */
const UNIT = '(?:\\s*[A-Za-z℃°%μµΩ][A-Za-z℃°%μµΩ0-9/]*)?';
const RANGE_RE = new RegExp(`^(${NUM})${UNIT}\\s*(?:~|~|-|–|—|…|\\.\\.\\.|至|到|to)\\s*(${NUM})`, 'i');
const NUM_RE = new RegExp(`^(${NUM})`); const NUM_RE = new RegExp(`^(${NUM})`);
/** 单边界:「≥45」「>= 45」「45 以上」→ [45, ∞);「≤40」「<= 40」「40 以内 / 以下」→ (-∞, 40] */
const GE_RE = new RegExp(`^(?:>=|≥)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以上|及以上|起)$`);
const LE_RE = new RegExp(`^(?:<=|≤)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以内|以下|及以下|以内)$`);
/**
* 参数值怎么读(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」):
* 「下限~上限」一律是**范围**——分隔符认 ~ ~ - – — … ... 至 到 to,正负号认 + - 与 Unicode 减号(−),
* 单位写进值里(4.5V~40V / -40℃~+85℃)也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界,读成半开范围;
* 只有一个数是定值;读不出数的按文本原样比。出参会把「读作什么」摆回去,模型与人都看得见。
*/
export function parseParamValue(raw: unknown): Parsed { export function parseParamValue(raw: unknown): Parsed {
const s = String(raw ?? '').trim().replace(/\s+/g, ' '); const s = String(raw ?? '')
.trim()
.replace(/[\u2212\u2010\u2011\u2012]/g, '-') // Unicode 减号 / 连字号 → ASCII 减号
.replace(/\s+/g, ' ');
if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' }; if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
const r = RANGE_RE.exec(s); const r = RANGE_RE.exec(s);
if (r !== null) { if (r !== null) {
@@ -387,11 +402,29 @@ export function parseParamValue(raw: unknown): Parsed {
const hi = Number(r[2]); const hi = Number(r[2]);
if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) }; if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
} }
const ge = GE_RE.exec(s);
if (ge !== null) return { kind: 'range', lo: Number(ge[1] ?? ge[2]), hi: Number.POSITIVE_INFINITY };
const le = LE_RE.exec(s);
if (le !== null) return { kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: Number(le[1] ?? le[2]) };
const n = NUM_RE.exec(s); const n = NUM_RE.exec(s);
if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) }; if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
return { kind: 'text', value: s.toLowerCase() }; return { kind: 'text', value: s.toLowerCase() };
} }
/** 「读作什么」—— 回给模型与人看的一句话,不是给机器再解析的 */
export function describeParsed(p: Parsed): string {
if (p.kind === 'range') {
if (p.lo === Number.NEGATIVE_INFINITY) return `上界 ≤ ${p.hi}`;
if (p.hi === Number.POSITIVE_INFINITY) return `下界 ≥ ${p.lo}`;
return `范围 ${p.lo}~${p.hi}`;
}
if (p.kind === 'number') return `定值 ${p.value}`;
if (p.kind === 'unknown') return '未披露';
// 带数字又带范围分隔符却没读成范围的(如「4.5V 到 40 伏」),提示怎么写;纯文本(封装 TO-263-5)不啰嗦
const looksLikeRange = /\d[^\d]{0,6}(?:[~~…]|至|到|\bto\b)[^\d]{0,6}\d/.test(p.value) || /^[^\d]+\d.*(?:[~~…]|至|到)/.test(p.value);
return `文本「${p.value}」${looksLikeRange ? '(没读成范围;本意是范围请写成 下限~上限,单位放单位列)' : ''}`;
}
function compareParsed(o: Parsed, c: Parsed): CompareResult { function compareParsed(o: Parsed, c: Parsed): CompareResult {
if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown'; if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
if (o.kind === 'range' && c.kind === 'range') { if (o.kind === 'range' && c.kind === 'range') {
@@ -439,10 +472,27 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
let compareResult = compareParsed(o, c); let compareResult = compareParsed(o, c);
if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult; if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
const req = satisfiesRequirement(p.requirement, c); const req = satisfiesRequirement(p.requirement, c);
/*
* **客户约束原 PN 自己也不满足时,不是替代差异**(2026-08-27 晚,用户送来 0bddd862:「输入电压范围两个
* 不是一样的吗为什么不匹配」):客户写「输入电压在 45V 以内」,模型译成 `>= 45`,而原 PN 与候选的输入上限
* 都是 40V —— 从前这里一票否决,候选与在用料一模一样却被记成硬约束 MISMATCH、整颗不推荐。
* 差异评估比的是「换上去会不会比现在差」:候选与原 PN 相等 / 盖住原范围,且这条客户边界**原 PN 一样过不去**,
* 那是需求边界与在用料之间的差距(该回到需求核对 / 验证),不该记在候选头上。
* 候选比原 PN 窄 / 不同而过不了客户线,仍是 MISMATCH;原 PN 一侧算不出(未披露 / 文本)也仍按候选过不了线判。
*/
if (req === false) { if (req === false) {
const parity = compareResult === 'equal' || compareResult === 'covers';
const originalFailsToo = parity && satisfiesRequirement(p.requirement, o) === false;
if (!originalFailsToo) {
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险 // 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` }; return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
} }
const boundary = `客户约束 ${text(p.requirement)} 原 PN 与候选都不满足 —— 是需求边界与在用料的差距,不是替代差异,请回头核对需求`;
if (hard && text(p.evidenceRef) === '') {
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: `硬约束缺证据引用:未经证据支持不判 MATCH;${boundary}` };
}
return { compareResult, differenceStatus: 'match', riskLevel: 'medium', note: `${compareResult === 'covers' ? '候选范围盖住原范围' : '与原 PN 一致'};${boundary}` };
}
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' }; if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
if (compareResult === 'equal' || compareResult === 'covers') { if (compareResult === 'equal' || compareResult === 'covers') {
if (hard && text(p.evidenceRef) === '') { if (hard && text(p.evidenceRef) === '') {
@@ -458,21 +508,146 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
: { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' }; : { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
} }
const compareParameters: ToolHandler = async ({ input }) => { /**
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; pairs: ParamPair[] }>) : []; * 比对工具读台账用的口 —— 按地址与等值条件取行(taskId 有就取任务视图 = 正式版 + 本单草稿,没有就只读正式版)。
const results = candidates.map((cand) => { * 只读、不写;由 app 用 ledger 服务实装,测试给假的。
*/
export type ParameterLedger = {
rows(taskId: TaskId | undefined, address: { domain: string; object: string }, where: Record<string, string>): Promise<ReadonlyArray<Record<string, unknown>>>;
};
type CandidateInput = {
candidateInternalPn: string;
pairs?: ParamPair[] | null;
/** 客户给了边界的参数:{ 参数名: ">= 40" / "-40~85" } —— 客户约束在需求原文里,归模型译;值不归它 */
requirements?: Record<string, unknown> | null;
evidenceRef?: string | null;
};
/**
* **比对入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题,
* 要让它认真比对每一条」)。从前 pairs 由模型从 `substitution.difference_item` 抄给工具:实测它把剧本示例里的
* 「4.5~32 / >= 40」当成这单的值念出来,而抄了什么**不留痕**(只存 inputHash),事后没法审。
* 现在给 originalInternalPn + candidateInternalPn,参数行从 `parts.parameter` 现读、封装与生命周期从 `parts.profile` 现读、
* 证据引用没给就从 `parts.document` 取 —— 每一对都是台账里的原值;出参把 originalValue / candidateValue / evidenceRef
* 一并摆出来,写盘照抄即可。老形状(自带 pairs)照旧接,试运行与老调用方不受影响。
*/
async function pairsFromLedger(ledger: ParameterLedger, taskId: TaskId | undefined, original: string, candidate: string, cand: CandidateInput) {
const PARAM = { domain: 'parts', object: 'parameter' };
const PROFILE = { domain: 'parts', object: 'profile' };
const DOC = { domain: 'parts', object: 'document' };
const [oParams, cParams, oProfiles, cProfiles] = await Promise.all([
ledger.rows(taskId, PARAM, { internal_pn: original }),
ledger.rows(taskId, PARAM, { internal_pn: candidate }),
ledger.rows(taskId, PROFILE, { internal_pn: original }),
ledger.rows(taskId, PROFILE, { internal_pn: candidate }),
]);
const oProfile = oProfiles[0];
const cProfile = cProfiles[0];
if (oParams.length === 0 && oProfile === undefined) {
return { error: `原 PN ${original} 在台账里没有主数据也没有参数行 —— 先把它建进 parts.profile / parts.parameter(数据工单),再比` };
}
if (cParams.length === 0 && cProfile === undefined) {
return { error: `候选 ${candidate} 在台账里没有主数据也没有参数行 —— 先建进 parts.profile / parts.parameter,再比` };
}
let evidenceRef = text(cand.evidenceRef);
if (evidenceRef === '') {
const docs = await ledger.rows(taskId, DOC, { internal_pn: candidate });
const sheet = docs.find((d) => text(d['document_type']) === 'datasheet' && text(d['document_ref']) !== '') ?? docs.find((d) => text(d['document_ref']) !== '');
if (sheet !== undefined) evidenceRef = `P-08 · P-10 ${text(sheet['document_version'])}`.trim();
}
const requirements = cand.requirements !== null && typeof cand.requirements === 'object' ? cand.requirements : {};
const requirementOf = (name: string) => text(requirements[name]);
const byName = new Map(cParams.map((r) => [text(r['param_name']), r] as const));
const sorted = [...oParams].sort((a, b) => text(a['param_name']).localeCompare(text(b['param_name']), 'zh-Hans-CN'));
const pairs: ParamPair[] = sorted
.filter((o) => text(o['param_name']) !== '')
.map((o) => {
const name = text(o['param_name']);
const c = byName.get(name);
return {
parameterName: name,
originalValue: text(o['param_value']),
candidateValue: c === undefined ? '' : text(c['param_value']),
unit: text(o['param_unit']) || (c === undefined ? '' : text(c['param_unit'])),
constraintType: text(o['constraint_type']) === 'soft' ? ('soft' as const) : ('hard' as const),
requirement: requirementOf(name),
evidenceRef,
};
});
// 封装(硬)与生命周期(软)不是参数,住在主数据上 —— 两颗都有档案时才比
if (oProfile !== undefined && cProfile !== undefined) {
if (text(oProfile['package']) !== '' || text(cProfile['package']) !== '') {
pairs.push({ parameterName: '封装', originalValue: text(oProfile['package']), candidateValue: text(cProfile['package']), unit: '', constraintType: 'hard', requirement: requirementOf('封装'), evidenceRef });
}
if (text(oProfile['lifecycle_status']) !== '' || text(cProfile['lifecycle_status']) !== '') {
pairs.push({ parameterName: '生命周期', originalValue: text(oProfile['lifecycle_status']), candidateValue: text(cProfile['lifecycle_status']), unit: '', constraintType: 'soft', requirement: requirementOf('生命周期'), evidenceRef });
}
}
const known = new Set(pairs.map((p) => p.parameterName));
const extraCandidateParams = [...byName.keys()].filter((n) => n !== '' && !known.has(n));
return { pairs, extraCandidateParams };
}
export function compareParametersWith(ledger?: ParameterLedger): ToolHandler {
return async ({ input, taskId }) => {
const original = text(input['originalInternalPn']);
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as CandidateInput[]) : [];
const results: Array<Record<string, unknown>> = [];
for (const cand of candidates) {
const candidatePn = text(cand.candidateInternalPn);
let pairs: ParamPair[];
let source: 'pairs' | 'ledger';
let extraCandidateParams: string[] = [];
if (Array.isArray(cand.pairs) && cand.pairs.length > 0) {
pairs = cand.pairs;
source = 'pairs';
} else {
if (ledger === undefined) {
return { ok: false, error: { code: 'invalid-input', retryable: false, message: `候选 ${candidatePn} 没给 pairs,而这条通道读不到台账 —— 给 pairs,或经任务里调用` } };
}
if (original === '') {
return { ok: false, error: { code: 'invalid-input', retryable: false, message: '缺 originalInternalPn:按料号从台账取参数,得先说原 PN 是谁' } };
}
const fetched = await pairsFromLedger(ledger, taskId, original, candidatePn, cand);
if ('error' in fetched) return { ok: false, error: { code: 'not-found', retryable: false, message: fetched.error } };
pairs = fetched.pairs;
extraCandidateParams = fetched.extraCandidateParams;
source = 'ledger';
}
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 }; const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
const items = (Array.isArray(cand.pairs) ? cand.pairs : []).map((p) => { const items = pairs.map((p) => {
const j = judgePair(p); const j = judgePair(p);
counts[j.differenceStatus] += 1; counts[j.differenceStatus] += 1;
if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1; if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
if (j.compareResult === 'configurable') counts.configurable += 1; if (j.compareResult === 'configurable') counts.configurable += 1;
return { parameterName: p.parameterName, unit: p.unit ?? '', constraintType: p.constraintType, ...j }; return {
parameterName: p.parameterName,
originalValue: text(p.originalValue),
candidateValue: text(p.candidateValue),
// 两边各读成了什么(范围 / 定值 / 单边界 / 文本)—— 比对的依据摆出来,「4.5~40」是范围不是两个数
readAs: { original: describeParsed(parseParamValue(p.originalValue)), candidate: describeParsed(parseParamValue(p.candidateValue)) },
unit: p.unit ?? '',
constraintType: p.constraintType,
requirement: text(p.requirement),
evidenceRef: text(p.evidenceRef),
...j,
};
}); });
return { candidateInternalPn: cand.candidateInternalPn, items, counts }; results.push({
candidateInternalPn: candidatePn,
source,
items,
counts,
...(extraCandidateParams.length === 0 ? {} : { extraCandidateParams }),
}); });
}
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } }; return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
}; };
}
/** 不带台账的那份(pairs 模式):静态名单里挂它,app 用 `compareParametersWith(ledger)` 顶掉 */
const compareParameters: ToolHandler = compareParametersWith();
/** /**
* 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环); * 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
@@ -866,7 +866,16 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
? '' ? ''
: ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`), : ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`),
// inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词) // inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词)
detail: { output: result.output, error: result.error?.message, inputHash: toolInputHash(action.input) }, /*
* **小入参原样留痕**(2026-08-27 晚):比对工具的入参从前只存哈希,模型到底喂了哪几对值事后没法审
* (实测它把剧本示例的值当这单的值念出来)。入参不长(≤ 800 字)就整份存 —— 审得了才谈得上「认真比对每一条」。
*/
detail: {
output: result.output,
error: result.error?.message,
inputHash: toolInputHash(action.input),
...(JSON.stringify(action.input).length <= 800 ? { input: action.input } : {}),
},
}); });
return { return {
// 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成 // 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成
+4
View File
@@ -77,3 +77,7 @@
影响半径: index/port 加法式扩容(新端口类型 + 三件纯函数 + 一张表); 影响半径: index/port 加法式扩容(新端口类型 + 三件纯函数 + 一张表);
`invoke` 对未注册工具的**行为**变了(抛 → 回 `tool-unregistered`)—— `invoke` 对未注册工具的**行为**变了(抛 → 回 `tool-unregistered`)——
下游只有 app 的 `toolGateway` 一处消费,已同步改为转人。 下游只有 app 的 `toolGateway` 一处消费,已同步改为转人。
- **处理器拿得到 `taskId` / `activityId`**(2026-08-27):`ToolHandler` 入参多两个可选键,运行时从 `ToolCall` 透传。
读台账的第一方工具(差异评估的 `compare-parameters`)靠它读**本单的任务视图**;试运行 / 老调用方没有,工具退回纯入参那条路。
这是「基座能读到的事实,不让模型转述给工具」的接口面 —— 工具仍只读,不经它写台账。
@@ -4,6 +4,7 @@
import { Ajv } from 'ajv'; import { Ajv } from 'ajv';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import type { TaskId } from '@contracts';
import type { Db } from '@shared/db.ts'; import type { Db } from '@shared/db.ts';
import type { SchemaValidator, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts'; import type { SchemaValidator, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
import type { ToolCall, ToolResult } from '../../contract.ts'; import type { ToolCall, ToolResult } from '../../contract.ts';
@@ -175,6 +176,13 @@ export type ToolHandler = (input: {
readonly connection: Readonly<Record<string, string>>; readonly connection: Readonly<Record<string, string>>;
readonly operation: string | undefined; readonly operation: string | undefined;
readonly idempotencyKey: string | undefined; readonly idempotencyKey: string | undefined;
/**
* 调用发生在哪一单 / 哪个活动(2026-08-27 晚):**读台账的工具**靠 taskId 认「本单的草稿层」——
* 比对入参由基座从台账取,不再由模型抄给工具(抄错、抄示例都拦不住,且抄了什么不留痕)。
* 试运行 / 老调用方没有这两样,工具自己退回纯入参那条路。
*/
readonly taskId?: TaskId | undefined;
readonly activityId?: string | undefined;
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>; }) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
/** /**
@@ -209,6 +217,8 @@ export function createToolRuntime(
connection: scoped, connection: scoped,
operation: call.operation, operation: call.operation,
idempotencyKey, idempotencyKey,
taskId: call.taskId,
activityId: call.activityId,
}), }),
timeout, timeout,
]); ]);
@@ -0,0 +1,135 @@
/**
* SSE 的兜底轮询 —— **推送丢了,画面最多旧 10 秒**,不必等人按刷新。
*
* 这里钉四条:①每个周期把这一屏**在用的**查询重取一遍(挂着观察者的才算在用;
* 早已离开的屏留在缓存里的键不碰);②`stop()` 之后定时器真的停了 —— 离开界面不许继续打接口;
* ③页签藏起来时不打,回到前台先补一趟;④正在飞的请求不被兜底那一下取消重发(在途去重)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryObserver } from '@tanstack/react-query';
import { startFallbackPolling } from '../fallback-poll.ts';
type Visibility = {
hidden: boolean;
fire(): void;
addEventListener(type: string, handler: () => void): void;
removeEventListener(type: string, handler: () => void): void;
readonly listeners: number;
};
/** 假的 document:只有 hidden 与 visibilitychange 这两样,测试自己拨 */
function fakeDoc(): Visibility {
const handlers = new Set<() => void>();
return {
hidden: false,
fire: () => handlers.forEach((h) => h()),
addEventListener: (_type, h) => void handlers.add(h),
removeEventListener: (_type, h) => void handlers.delete(h),
get listeners() {
return handlers.size;
},
};
}
/** 一把「在用的」查询:挂一个观察者,像组件 mount 了一样;回报它被取了几次(`delayOf` 按第几次给时延) */
function mounted(qc: QueryClient, key: readonly unknown[], delayOf: (call: number) => number = () => 0) {
let calls = 0;
const observer = new QueryObserver(qc, {
queryKey: key,
queryFn: async () => {
calls += 1;
const delayMs = delayOf(calls);
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
return { n: calls };
},
});
const unsubscribe = observer.subscribe(() => undefined);
return { calls: () => calls, unsubscribe };
}
const flush = async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(0);
};
describe('startFallbackPolling', () => {
let qc: QueryClient;
beforeEach(() => {
vi.useFakeTimers();
qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 5_000 } } });
});
afterEach(() => {
qc.clear();
vi.useRealTimers();
});
it('每 10 秒把 desk 下在用的查询各重取一次;别的前缀与没人看的键不碰', async () => {
const tasks = mounted(qc, ['desk', 'tasks']);
const detail = mounted(qc, ['desk', 'task', 'T1']);
const gov = mounted(qc, ['gov', 'tasks']);
// 早已离开的屏:缓存里有,没人看
qc.setQueryData(['desk', 'task', 'T0'], { stale: true });
await flush();
expect([tasks.calls(), detail.calls(), gov.calls()]).toEqual([1, 1, 1]); // 首取
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc: fakeDoc() });
await vi.advanceTimersByTimeAsync(10_000);
expect([tasks.calls(), detail.calls()]).toEqual([2, 2]);
await vi.advanceTimersByTimeAsync(10_000);
expect([tasks.calls(), detail.calls()]).toEqual([3, 3]);
expect(gov.calls()).toBe(1);
expect(qc.getQueryState(['desk', 'task', 'T0'])?.fetchStatus).toBe('idle');
stop();
});
it('stop() 之后定时器停了,监听也摘了 —— 离开界面不再打接口', async () => {
const tasks = mounted(qc, ['desk', 'tasks']);
const doc = fakeDoc();
await flush();
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc });
expect(doc.listeners).toBe(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(tasks.calls()).toBe(2);
stop();
expect(doc.listeners).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(tasks.calls()).toBe(2);
});
it('页签藏起来时不打;回到前台先补一趟,周期照旧', async () => {
const tasks = mounted(qc, ['desk', 'tasks']);
const doc = fakeDoc();
await flush();
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc });
doc.hidden = true;
doc.fire();
await vi.advanceTimersByTimeAsync(30_000);
expect(tasks.calls()).toBe(1);
doc.hidden = false;
doc.fire();
await flush();
expect(tasks.calls()).toBe(2); // 回前台立刻补一趟
await vi.advanceTimersByTimeAsync(10_000);
expect(tasks.calls()).toBe(3);
stop();
});
it('上一趟还在飞,兜底这一下不取消它重发 —— 慢接口不会被兜底打成永远取不回', async () => {
/*
* 首取要先回来:TanStack 只对**已有数据**的查询做 cancelRefetch(首取飞着时谁都不掐),
* 所以「第二趟慢」才是有分辨力的局面 —— 第一版拿首取当慢趟,true/false 都过。
*/
const slow = mounted(qc, ['desk', 'tasks'], (call) => (call === 1 ? 0 : 25_000));
await flush();
expect(slow.calls()).toBe(1);
const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc: fakeDoc() });
await vi.advanceTimersByTimeAsync(10_000);
expect(slow.calls()).toBe(2); // 第二趟起飞,要 25 秒
await vi.advanceTimersByTimeAsync(10_000);
// 第二趟还没回来:这一个兜底周期不该把它取消再发(cancelRefetch:true 会让这里变成 3)
expect(slow.calls()).toBe(2);
stop();
});
});
+18 -1
View File
@@ -6,7 +6,7 @@
* *
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。 * **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
*/ */
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts'; import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
import { import {
@@ -28,6 +28,7 @@ import { call } from '@web-shared/typed-fetch.ts';
import { logout as kcLogout } from '@web-shared/auth.ts'; import { logout as kcLogout } from '@web-shared/auth.ts';
import { useChatWidth } from '@web-shared/useChatWidth.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts';
import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { startFallbackPolling } from './fallback-poll.ts';
import { queryKeys } from './query.ts'; import { queryKeys } from './query.ts';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -40,6 +41,9 @@ const NAV_MODULES: ReadonlyArray<{ key: ModuleKey; label: string; icon: string }
{ key: 'report', label: '汇报', icon: 'bar-chart' }, { key: 'report', label: '汇报', icon: 'bar-chart' },
]; ];
/** 兜底轮询周期 —— 用户定 10 秒:比这密就成了轮询,比这疏推送漏了人等得出来 */
const FALLBACK_POLL_MS = 10_000;
export function AppShell() { export function AppShell() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [module, setModule] = useState<ModuleKey>('tasks'); const [module, setModule] = useState<ModuleKey>('tasks');
@@ -109,6 +113,19 @@ export function AppShell() {
*/ */
const detailPending = current !== '' && detail.isPending; const detailPending = current !== '' && detail.isPending;
/*
* SSE 的兜底:任务界面开着的时候每 10 秒把 `desk` 下在用的查询(清单 / 这一单 / 角标)
* 各重取一次 —— 推送漏了画面最多旧 10 秒,不必等人按刷新(2026-08-28 用户定)。
* 离开这一屏(切模块 / 壳层卸载)effect 清理即 `stop()`,定时器与可见性监听一起摘。
*/
useEffect(
() =>
module === 'tasks'
? startFallbackPolling(queryClient, { intervalMs: FALLBACK_POLL_MS, prefix: queryKeys.desk })
: undefined,
[module, queryClient],
);
+57
View File
@@ -0,0 +1,57 @@
/**
* SSE 的兜底轮询 —— **推送丢了,画面最多旧 10 秒**(2026-08-28 用户定)。
*
* 刷新的正路仍是订阅层(`subscribe.ts`:推送到了 → 按键失效 → 重取);这里只是兜底:
* 断线那几秒漏掉的推送、桥没接上的那一台、任何一处忘了广播的写路由 —— 从前都得靠人按刷新。
* 编码规范 §9.2「组件禁止自行轮询」照旧算数:兜底只此一处、由壳层起停,组件不许各自再起定时器。
*
* 三条约束:
* · 只重取**在用的**查询(`type: 'active'`,挂着观察者的才算)—— 早已离开的屏留在缓存里的键不碰;
* · **不取消在途请求**(`cancelRefetch: false`):慢接口正飞着,兜底那一下只是等它,不把它掐掉重发;
* · 页签藏起来不打(后台页签打接口是纯浪费),回到前台先补一趟。
*
* 连接是 I/O,节奏是逻辑:`doc` 可注入,测试里拨 hidden 与 visibilitychange 就能钉死起停。
* 返回 `stop()` —— 离开界面必须调它,定时器与监听一起摘。
*/
import type { QueryClient } from '@tanstack/react-query';
export type VisibilitySource = {
readonly hidden: boolean;
addEventListener(type: 'visibilitychange', handler: () => void): void;
removeEventListener(type: 'visibilitychange', handler: () => void): void;
};
export type FallbackPollingOptions = {
/** 周期(毫秒);工作台任务界面定 10 秒 */
readonly intervalMs: number;
/** 重取哪一支查询(按键前缀) */
readonly prefix: readonly unknown[];
/** 可见性来源;不传用 `document` */
readonly doc?: VisibilitySource;
};
export function startFallbackPolling(queryClient: QueryClient, options: FallbackPollingOptions): () => void {
const doc: VisibilitySource = options.doc ?? document;
let stopped = false;
const sweep = (): void => {
if (stopped || doc.hidden) return;
// 取不回来不算兜底的错:那一把查询自己会带着 error 状态,画面照实摆
// `cancelRefetch` 是第二参(RefetchOptions),写进过滤器会被无视、实际走默认 true —— 测试逮过一次
void queryClient
.refetchQueries({ queryKey: options.prefix, type: 'active' }, { cancelRefetch: false })
.catch(() => undefined);
};
const onVisibility = (): void => {
if (!doc.hidden) sweep();
};
const timer = setInterval(sweep, options.intervalMs);
doc.addEventListener('visibilitychange', onVisibility);
return () => {
stopped = true;
clearInterval(timer);
doc.removeEventListener('visibilitychange', onVisibility);
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ export { queryKeys };
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
// 缓存失效由 SSE 订阅层驱动,不靠组件轮询 // 缓存失效由 SSE 订阅层驱动,不靠组件轮询;推送漏了有壳层的 10 秒兜底(fallback-poll.ts),组件不另起定时器
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 1, retry: 1,
staleTime: 5_000, staleTime: 5_000,
+1 -1
View File
@@ -69,7 +69,7 @@ export function invalidateFor(queryClient: QueryClient, touch: Touch): void {
* 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。 * 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。
*/ */
void queryClient.invalidateQueries({ queryKey: ['gov'] }); void queryClient.invalidateQueries({ queryKey: ['gov'] });
void queryClient.invalidateQueries({ queryKey: ['desk'] }); void queryClient.invalidateQueries({ queryKey: queryKeys.desk });
break; break;
} }
} }
@@ -0,0 +1,68 @@
/**
* 工作区看对话区的脸色 —— 录入卡在等人时摆占位页,不摆件(2026-08-28 用户定)。
*/
import { describe, expect, it } from 'vitest';
import { dataEntryCardSchema, decisionCardSchema } from '@contracts';
import type { TaskDetail } from '@contracts';
import { pendingCardsOf } from '../internal/pending-cards.ts';
const AT = '2026-08-28T00:55:30.054Z';
function entry(id: string, extra: Record<string, unknown> = {}) {
return dataEntryCardSchema.parse({
k: 'entry',
id,
at: AT,
ask: '客户 123 不在客户档案里,请核对建档信息后确认',
ledger: 'customer.profile',
ledgerLabel: '客户档案',
key: { customer_code: '123' },
fields: [],
...extra,
});
}
function decision(id: string, extra: Record<string, unknown> = {}) {
return decisionCardSchema.parse({
k: 'decision',
id,
at: AT,
ask: '经营计划是否通过?',
basis: [{ k: '依据', v: '计划表 4 行' }],
options: [
{ value: 'pass', label: '通过' },
{ value: 'reject', label: '驳回' },
],
...extra,
});
}
const chat = (...items: TaskDetail['chat']): TaskDetail['chat'] => items;
describe('pendingCardsOf', () => {
it('未确认的录入卡在场 → entry,并带上那张表的人读名', () => {
expect(pendingCardsOf(chat(entry('c-1')))).toEqual({ decision: false, entry: true, entryLedger: '客户档案' });
});
it('录入卡已确认(done 落了)→ 不再算在等', () => {
const done = { at: AT, by: 'S-1', values: { name: '测试公司' } };
expect(pendingCardsOf(chat(entry('c-1', { done })))).toEqual({ decision: false, entry: false });
});
it('登记里没有人读名时退回对象地址 —— 宁可笼统,不可编', () => {
expect(pendingCardsOf(chat(entry('c-1', { ledgerLabel: undefined }))).entryLedger).toBe('customer.profile');
});
it('未选的决策卡 → decision;它不触发占位页(人要决定的常常就是件上那份稿)', () => {
expect(pendingCardsOf(chat(decision('d-1')))).toEqual({ decision: true, entry: false });
expect(pendingCardsOf(chat(decision('d-1', { chosen: { at: AT, by: 'S-1', value: 'pass' } })))).toEqual({
decision: false,
entry: false,
});
});
it('两张未回的录入卡:点名最早那一张', () => {
const later = entry('c-2', { at: '2026-08-28T01:00:00.000Z', ledgerLabel: '联系人档案' });
expect(pendingCardsOf(chat(entry('c-1'), later)).entryLedger).toBe('客户档案');
});
});
@@ -21,6 +21,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ViewBlocks, ViewContainer } from '@ui'; import { ViewBlocks, ViewContainer } from '@ui';
import { pagerFace, pagerText } from './pager.ts'; import { pagerFace, pagerText } from './pager.ts';
import { pendingCardsOf } from './pending-cards.ts';
import type { ViewEdits, ViewSave } from '@ui'; import type { ViewEdits, ViewSave } from '@ui';
import { GuideCard, WorkOrderReport } from './work-order-report.tsx'; import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
import { FileViewer } from './FileViewer.tsx'; import { FileViewer } from './FileViewer.tsx';
@@ -173,9 +174,8 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat` * 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat`
* 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。 * 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。
*/ */
const pendingChatCard = detail.chat.some( const pending = pendingCardsOf(detail.chat);
(m) => (m.k === 'decision' && m.chosen === undefined) || (m.k === 'entry' && m.done === undefined), const pendingChatCard = pending.decision || pending.entry;
);
/* /*
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点, * 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
@@ -776,6 +776,22 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。 * viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
*/ */
<WorkingScene doneN={doneList.length} /> <WorkingScene doneN={doneList.length} />
) : pending.entry ? (
/*
* **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩那单
* 「客户 123 不在客户档案里,请核对建档」定的)。建档那一步要人做的事全在
* 对话区那张卡上;这时 `viewHtml` 里躺着的是**上一拍**推的那份 HTML 件,
* 与建档无关 —— 摆着只会让人以为要在那份工单上办点什么。
* 这一支排在 viewHtml 之前、running 之后:AI 在算时照旧是推进场景。
* 决策卡不走这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。
*/
<EmptyState
ico="users"
tone="brand"
title="先把这份档案建起来"
desc={`AI 认不出这位客户,已在右边对话区发了一张「${pending.entryLedger ?? '档案'}」录入卡。核对、确认后档案落进台账,AI 接着往下推进 —— 这一步工作区没有要你看的件。`}
hint="录入在对话区那张卡上,不在这里"
/>
) : detail.viewHtml !== undefined ? ( ) : detail.viewHtml !== undefined ? (
/* /*
* **工作区只放那份件本身**(2026-08-01 用户定)。 * **工作区只放那份件本身**(2026-08-01 用户定)。
@@ -0,0 +1,35 @@
/**
* 对话区里**还在等人回**的卡 —— 工作区据此决定摆什么。
*
* decide / entry 发的是对话区的卡(chat_card),不进 `work_order`;它们挂起时
* `runtime` 同样是 `awaiting-event`,与「等外部回音」一个值。工作区要分清这两条岔,
* 只能读一眼 `detail.chat`。
*
* **录入卡在场时工作区摆占位页,不摆件**(2026-08-28 用户定):建档那一步要人做的事
* 全在对话区那张卡上,工作区里上一拍留下的那份 HTML 件与这件事无关 —— 摆着只会让人
* 以为「要在这份工单上办点什么」。决策卡不同:人要决定的对象常常就是件上那份计划 / 审批稿,
* 件得留着给他看。
*
* 纯函数,抽出来是为了可测:这段判断写在 JSX 里改一次漏一处(`pendingChatCard` 从前就是内联的)。
*/
import type { TaskDetail } from '@contracts';
export type PendingCards = {
/** 有未选的决策卡 */
readonly decision: boolean;
/** 有未确认的数据录入卡 */
readonly entry: boolean;
/** 那张录入卡要落的表的人读名(卡面同一个名字);没有录入卡时缺席 */
readonly entryLedger?: string;
};
export function pendingCardsOf(chat: TaskDetail['chat']): PendingCards {
let decision = false;
let entryLedger: string | undefined;
for (const m of chat) {
if (m.k === 'decision' && m.chosen === undefined) decision = true;
// 多张未回的录入卡,占位页点名最早那一张(对话区也是按时间排的,人先看到它)
if (m.k === 'entry' && m.done === undefined && entryLedger === undefined) entryLedger = m.ledgerLabel ?? m.ledger;
}
return entryLedger === undefined ? { decision, entry: false } : { decision, entry: true, entryLedger };
}
+2
View File
@@ -6,6 +6,8 @@
export const queryKeys = { export const queryKeys = {
/** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */ /** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */
me: ['me'] as const, me: ['me'] as const,
/** 工作台整支 —— 失效 / 兜底轮询按这个前缀扫,下面四把都在它名下 */
desk: ['desk'] as const,
tasks: ['desk', 'tasks'] as const, tasks: ['desk', 'tasks'] as const,
task: (id: string) => ['desk', 'task', id] as const, task: (id: string) => ['desk', 'task', id] as const,
messages: ['desk', 'messages'] as const, messages: ['desk', 'messages'] as const,
@@ -237,6 +237,8 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
| Z | 铺完骨架就标 `created`、直接 finish,撞「从没给它发过卡」连拒五拍 | 我 1.3.1 的措辞「表里真有一行才标 created」—— 而骨架是模型自己铺的 | 「created 认的是卡交回来了」;顺序写死:骨架 → 发卡 → 拍结束 → 人交回 → 才标 | 活动 | | Z | 铺完骨架就标 `created`、直接 finish,撞「从没给它发过卡」连拒五拍 | 我 1.3.1 的措辞「表里真有一行才标 created」—— 而骨架是模型自己铺的 | 「created 认的是卡交回来了」;顺序写死:骨架 → 发卡 → 拍结束 → 人交回 → 才标 | 活动 |
| AB | **库里有值的参数被 S1 清单当成缺口再问一遍**(三次复现):人在 P-08 填了使能阈值 2.5,清单上仍有「使能阈值 · 原厂未披露」;第三次连数据工单都没派(料号已在库、21 个参数全有值)照样出现 | S1 的「待确认」行是模型对参数表的断言,基座无读数核它;说明书里「使能阈值」与「未披露」同句三处(含 1.4.10 我写的反例原话);S-FEEL 无列表过滤写不出判据 | S1 一行参数都不写;件上加「候选参数现值」只读块(嵌套 `data-by` 现读台账);未披露归 S3 判 UNKNOWN;说明书全文去掉「使能阈值」 | 活动(结构)+ 平台(渲染器嵌套行) | | AB | **库里有值的参数被 S1 清单当成缺口再问一遍**(三次复现):人在 P-08 填了使能阈值 2.5,清单上仍有「使能阈值 · 原厂未披露」;第三次连数据工单都没派(料号已在库、21 个参数全有值)照样出现 | S1 的「待确认」行是模型对参数表的断言,基座无读数核它;说明书里「使能阈值」与「未披露」同句三处(含 1.4.10 我写的反例原话);S-FEEL 无列表过滤写不出判据 | S1 一行参数都不写;件上加「候选参数现值」只读块(嵌套 `data-by` 现读台账);未披露归 S3 判 UNKNOWN;说明书全文去掉「使能阈值」 | 活动(结构)+ 平台(渲染器嵌套行) |
| AA | **数据工单确认了,数据没入库**:卡上写着「确认后这批主数据即入库」,人建好的 P-08/P-10 记录要等整条评估放行才进正式表;试跑单(不落主数据)永远不进(2026-08-27 用户指出) | 草稿层通往正式表只有整单放行一道口子(`releaseWrite(taskId)`),回传路由只记办结 + 抛人事件;而引擎的 `mine:` / `_rows` / 哨兵全靠草稿层认「本单写下的行」,清掉就断 | `releaseWrite(taskId, {only, cause})` 范围放行:只并这张卡 `gateLedgers` 那几张表,并过的行**留在草稿层打 `landed` 记号**(读面不变,整单放行不再写);回传路由在保存与回传落定后调它,痕点名几张表各几行;`createOnly` / `drop` 对已落地行改口(别 drop,撤了读面少一行);活动 1.4.8 剧本改口「已入正式表」 | **平台**(结构)+ 活动 | | AA | **数据工单确认了,数据没入库**:卡上写着「确认后这批主数据即入库」,人建好的 P-08/P-10 记录要等整条评估放行才进正式表;试跑单(不落主数据)永远不进(2026-08-27 用户指出) | 草稿层通往正式表只有整单放行一道口子(`releaseWrite(taskId)`),回传路由只记办结 + 抛人事件;而引擎的 `mine:` / `_rows` / 哨兵全靠草稿层认「本单写下的行」,清掉就断 | `releaseWrite(taskId, {only, cause})` 范围放行:只并这张卡 `gateLedgers` 那几张表,并过的行**留在草稿层打 `landed` 记号**(读面不变,整单放行不再写);回传路由在保存与回传落定后调它,痕点名几张表各几行;`createOnly` / `drop` 对已落地行改口(别 drop,撤了读面少一行);活动 1.4.8 剧本改口「已入正式表」 | **平台**(结构)+ 活动 |
| AC | **两颗输入电压范围一模一样却判 MISMATCH、整颗不推荐**(2026-08-27 用户送来 0bddd862):客户写「输入电压在 45V 以内」,模型译成 requirement `>= 45`,原 PN 与候选上限都是 40V | `judgePair` 客户约束一票否决:候选过不了客户线就 mismatch,不看原 PN 自己过不过得了 —— 需求边界与在用料的差距被记在候选头上 | 工具 1.1.0:候选与原 PN 相等 / 盖住且原 PN 也不满足客户线 → 仍按比对结果判(MATCH,风险中),备注点名「需求边界,请回头核对需求」;候选更窄且过不了线仍 MISMATCH | **平台**(工具) |
| AD | **比对没有「认真比每一条」的机制**(2026-08-27 用户:「这个做差异评估比对的 harness 肯定有问题」):模型交代里念出「输入上限 32V 低于客户 40V」—— 这两个数不是这单的,是 S3 剧本示例的原话;工具入参只存哈希,喂了哪 9 对值事后查不到;写回台账时还把工具的 `equal` 改成了 `less` | 比对入参由模型从台账抄给工具(抄错 / 抄示例都拦不住),出参再由模型抄回台账,两头都没有基座核;示例引了具体值 | 工具 1.2.0:只报原 PN + 候选料号 + 客户约束,参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读(taskId 经运行时交给处理器,读任务视图);出参带原值,写盘照抄;≤800 字的工具入参整份留痕;剧本示例只留占位形 | **平台**(工具运行时 + 工具)+ 活动 |
**可推广的三条**(已记 `docs/debt.md`): **可推广的三条**(已记 `docs/debt.md`):
+3 -1
View File
@@ -300,7 +300,9 @@ start 已收项无事发生同族)。剧本里也别再教 start —— 教它
那才是替人签字。答过之后,「办完了没有」若是基座数得出来的事实,就写成 doneWhen 交给引擎: 那才是替人签字。答过之后,「办完了没有」若是基座数得出来的事实,就写成 doneWhen 交给引擎:
W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)就是这一类 —— 从前靠模型记得 finish, W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)就是这一类 —— 从前靠模型记得 finish,
剧本两处写死「先 finish 再发下一张」它照样忘,两步同时 Active、件停在上一步、人无处可填。 剧本两处写死「先 finish 再发下一张」它照样忘,两步同时 Active、件停在上一步、人无处可填。
**指南让模型判的事,引擎有现成事实的,一律改成读事实** —— 这条也管收步。 **指南让模型判的事,引擎有现成事实的,一律改成读事实** —— 这条也管收步,也管**工具入参**:
台账里有的值不让模型抄给工具(差异评估的比对工具 1.2.0 起只收料号 + 客户约束,参数由它自己从 parts.* 现读;
处理器经 `taskId` 读任务视图)。剧本示例里**不写具体参数值**,只留占位形 —— 模型会把示例当这单的数念出来(实测)。
仍不给 doneWhen 的:终局放行(T_release,由 submit 走)、审核循环、以及「办完了没有」 仍不给 doneWhen 的:终局放行(T_release,由 submit 走)、审核循环、以及「办完了没有」
数不出来只能由人说了算的项。 数不出来只能由人说了算的项。
+1
View File
@@ -390,6 +390,7 @@ S-FEEL 子集(OS 03 定义域)的解析与求值,纯函数。被 `process`(边
- 视图区容器渲染活动推送的 HTML:**沙箱 iframe(`srcdoc` + `sandbox` 属性)或净化后注入,二选一由 `ui-kit` 的 `ViewContainer` 统一实现,feature 不得自行 `dangerouslySetInnerHTML`**;无脚本执行权(AM 07) - 视图区容器渲染活动推送的 HTML:**沙箱 iframe(`srcdoc` + `sandbox` 属性)或净化后注入,二选一由 `ui-kit` 的 `ViewContainer` 统一实现,feature 不得自行 `dangerouslySetInnerHTML`**;无脚本执行权(AM 07)
- 卡片表单由 `@contracts/card.ts` schema 驱动渲染与校验,与后端回传校验同 schema - 卡片表单由 `@contracts/card.ts` schema 驱动渲染与校验,与后端回传校验同 schema
- SSE 事件到达 → 按信封目标(任务/工作流/要素)**失效对应 TanStack Query 缓存**,由订阅层统一做;组件禁止自行轮询或手写缓存 - SSE 事件到达 → 按信封目标(任务/工作流/要素)**失效对应 TanStack Query 缓存**,由订阅层统一做;组件禁止自行轮询或手写缓存
- 推送的兜底**只此一处**:`app/fallback-poll.ts`,工作台任务界面开着时每 10 秒把 `desk` 下在用的查询重取一遍(不取消在途、页签藏起不打、离开即停),由壳层起停 —— 组件仍不许各自起定时器(2026-08-28)
- 路由状态即应用状态:当前区/页签/下钻栈可从 URL 完整还原(TanStack Router 类型化 search params);内存路由栈只是原型期形态,正式系统必须可深链 - 路由状态即应用状态:当前区/页签/下钻栈可从 URL 完整还原(TanStack Router 类型化 search params);内存路由栈只是原型期形态,正式系统必须可深链
--- ---