Archived
脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 —— 脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试 「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0 (第五例停在大模型账户余额不足,不是代码问题)。 平台侧(六条): - L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。 契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算), `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。 - M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**; 现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。 - N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** —— 下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把 「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。 - O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。 - P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。 - Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有 = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。 活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、 ⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账; S0_bind 写明发录入卡那一拍别抢写需求行。 e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。 门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/* (工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
@@ -252,6 +252,34 @@ describe('说明书与剧本的骨架', () => {
|
|||||||
expect(block).toContain('触发原因');
|
expect(block).toContain('触发原因');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('三张执行工单各自声明 gateLedgers —— 零输入卡不声明,闸就盯着与它无关的表(2026-08-27 e2e 实测:两张数据工单空手交卷都放行了)', () => {
|
||||||
|
for (const [item, ledger] of [
|
||||||
|
['芯片主数据创建', '"gateLedgers":["parts.profile","parts.parameter"]'],
|
||||||
|
['原厂技术资料创建', '"gateLedgers":["parts.document"]'],
|
||||||
|
['评估输入与资料补齐确认', '"gateLedgers":["sub_eval.input_gap"]'],
|
||||||
|
] as const) {
|
||||||
|
expect(NARRATIVE, item).toContain(ledger);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('数据工单也要「先写骨架再发卡」写进说明书,且哨兵不认零行主数据(2026-08-27 e2e:模型压拍没写骨架就发卡,人打开是空表,零行同时骗过闸与哨兵,一路走到 S2 才被引用检查拦下)', () => {
|
||||||
|
expect(NARRATIVE).toContain('必须先有骨架行');
|
||||||
|
expect(NARRATIVE).toMatch(/压拍/);
|
||||||
|
// 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
|
||||||
|
expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
|
||||||
|
expect(NARRATIVE).toContain('一颗 absent 都不剩');
|
||||||
|
expect(NARRATIVE).toMatch(/有几颗 absent 就要办几颗/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('发数据录入卡那一拍不许顺手写需求行(客户还没落草稿层,引用检查必拒)', () => {
|
||||||
|
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||||
|
expect(g).toMatch(/那一拍只发卡/);
|
||||||
|
expect(g).toContain('引用检查');
|
||||||
|
});
|
||||||
|
|
||||||
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
|
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
|
||||||
expect(ASSESSMENT_PHASES).toHaveLength(5);
|
expect(ASSESSMENT_PHASES).toHaveLength(5);
|
||||||
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
|
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<caseFileItem id="sub_eval_pn_check_exists_status_abandoned" name="已放弃的候选数(基座现数)"/>
|
<caseFileItem id="sub_eval_pn_check_exists_status_abandoned" name="已放弃的候选数(基座现数)"/>
|
||||||
<caseFileItem id="sub_eval_verdict_rows" name="结论行数(基座现数)"/>
|
<caseFileItem id="sub_eval_verdict_rows" name="结论行数(基座现数)"/>
|
||||||
<caseFileItem id="parts_profile_rows" name="本单建的芯片主数据行数(基座现数)"/>
|
<caseFileItem id="parts_profile_rows" name="本单建的芯片主数据行数(基座现数)"/>
|
||||||
|
<caseFileItem id="sub_eval_pn_check_exists_status_created" name="经数据工单建好的料号数(基座现数)"/>
|
||||||
<caseFileItem id="parts_profile_missing" name="本单建的芯片主数据缺必填的行数(基座现数)"/>
|
<caseFileItem id="parts_profile_missing" name="本单建的芯片主数据缺必填的行数(基座现数)"/>
|
||||||
<caseFileItem id="parts_parameter_missing" name="本单建的标准化参数集缺必填的行数(基座现数)"/>
|
<caseFileItem id="parts_parameter_missing" name="本单建的标准化参数集缺必填的行数(基座现数)"/>
|
||||||
<caseFileItem id="parts_document_missing" name="本单建的资料记录缺必填的行数(基座现数)"/>
|
<caseFileItem id="parts_document_missing" name="本单建的资料记录缺必填的行数(基座现数)"/>
|
||||||
@@ -79,7 +80,17 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
-->
|
-->
|
||||||
<sentry id="s_pns_resolved">
|
<sentry id="s_pns_resolved">
|
||||||
<onPart id="op_pr" caseFileItemRef="sub_eval_pn_check_exists_status_absent"><standardEvent>update</standardEvent></onPart>
|
<onPart id="op_pr" caseFileItemRef="sub_eval_pn_check_exists_status_absent"><standardEvent>update</standardEvent></onPart>
|
||||||
<ifPart id="if_pr"><condition>sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0</condition></ifPart>
|
<!--
|
||||||
|
**标了 created 的每一颗都要有一行主数据**(2026-08-27 e2e 两轮各现一形):
|
||||||
|
「没写过 ≠ 写了 0 行」(2026-08-01 A1 立的那一课,在这里以新形状复现):
|
||||||
|
\`_missing\` 只数**已有行**缺不缺格子 —— 一行都没写时它是 0,门就开了。
|
||||||
|
实测:模型压拍没写骨架就把两张数据工单发了出去、人也「交」了,
|
||||||
|
\`parts_profile_missing = 0\` 成立,这一步照过,而库里那颗料根本不存在。
|
||||||
|
第一刀写成「created ≥ 1 就得有行」还是太松:三候选那单里模型把**第二颗**也标成 created
|
||||||
|
却只建了第一颗,一行足以让门开着,直到 S2 写关系时才被引用检查拦下。
|
||||||
|
改成**逐颗对账**:\`parts_profile_rows >= created\` —— 标了几颗建好,就得有几行。
|
||||||
|
-->
|
||||||
|
<ifPart id="if_pr"><condition>sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_created</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
<!-- ③ 评估输入已确认(旗 + 清单有行且必填齐)—— 标准化那一步的入场前提 -->
|
<!-- ③ 评估输入已确认(旗 + 清单有行且必填齐)—— 标准化那一步的入场前提 -->
|
||||||
<sentry id="s_input_confirmed">
|
<sentry id="s_input_confirmed">
|
||||||
@@ -213,7 +224,11 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
|
|||||||
\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因;
|
\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因;
|
||||||
点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。
|
点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。
|
||||||
|
|
||||||
③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上):
|
③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上)——
|
||||||
|
**发这张卡之前,\`parts.profile\` 与 \`parts.parameter\` 必须先有骨架行**(名单里每颗料各一行 + 它的参数各一行)。
|
||||||
|
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时这一步的剧本还没摊开),写骨架与发卡都得在同一串里,顺序不能反 ——
|
||||||
|
件上一行都没有的话,人打开是一张空表、什么也补不了,而这一单的主数据从头到尾不会存在
|
||||||
|
(实测走到 S2 才被引用检查拦下,那时两张数据工单都已经「办完」了)
|
||||||
{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{
|
{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{
|
||||||
"title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
|
"title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
|
||||||
"ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
|
"ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
|
||||||
@@ -222,8 +237,12 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
|
|||||||
"有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写",
|
"有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写",
|
||||||
"改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"],
|
"改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"],
|
||||||
"standard":"每颗料号表 1 一行,必填列全齐;表 2 至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项各一行。",
|
"standard":"每颗料号表 1 一行,必填列全齐;表 2 至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项各一行。",
|
||||||
|
"gateLedgers":["parts.profile","parts.parameter"],
|
||||||
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"},
|
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"},
|
||||||
{"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}}
|
{"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}}
|
||||||
|
**\`gateLedgers\` 必须给**:这是零输入卡,基座不知道这一张要人补哪几张表 ——
|
||||||
|
不给它就回落到必交付物那几张(与这张卡无关),于是件上红带点着「缺品牌名称」而提交钮照样绿,
|
||||||
|
空手交卷一路放行(2026-08-27 实测)。给了之后,闸与红带、放行门读的是同一个数。
|
||||||
|
|
||||||
④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发):
|
④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发):
|
||||||
{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{
|
{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{
|
||||||
@@ -233,15 +252,22 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
|
|||||||
"同一料号多份资料就多加几行,序号 01/02… 自己续",
|
"同一料号多份资料就多加几行,序号 01/02… 自己续",
|
||||||
"改完保存,回这张卡确认"],
|
"改完保存,回这张卡确认"],
|
||||||
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
|
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
|
||||||
|
"gateLedgers":["parts.document"],
|
||||||
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
|
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
|
||||||
|
|
||||||
⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入)——
|
⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入)——
|
||||||
|
**发它的前提:所有料号都已落定** —— \`${D}.pn_check\` 里**一颗 absent 都不剩**(每颗要么 found、要么经数据工单 created、
|
||||||
|
要么人点了放弃 abandoned),且建过的那几颗在 \`parts.profile\` 里真有行。还有 absent 就**回去把它们办完**
|
||||||
|
(候选总数 ≥2 的逐颗发决策卡问、原 PN 与独苗候选直接建),别先发这张卡:
|
||||||
|
硬发出去人也补不成 —— 后面写替代关系时会被引用检查逐颗拦下,而那时数据工单都已经「办完」了(2026-08-27 实测两轮)。
|
||||||
|
**有几颗 absent 就要办几颗**,办完第一颗别忘了还有第二颗。
|
||||||
**发这张卡之前,\`${D}.input_gap\` 必须先有行**:清单为空的卡就是让人对着一张空表签字,他补不了任何东西。
|
**发这张卡之前,\`${D}.input_gap\` 必须先有行**:清单为空的卡就是让人对着一张空表签字,他补不了任何东西。
|
||||||
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时 S1 的剧本还没摊开),写清单与发卡都得在同一串里,顺序不能反:
|
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时 S1 的剧本还没摊开),写清单与发卡都得在同一串里,顺序不能反:
|
||||||
已绑定四项(customer / request / original_pn / candidate_N,status="ok")、待补两项(application_scenario / bom_position,status="gap")、
|
已绑定四项(customer / request / original_pn / candidate_N,status="ok")、待补两项(application_scenario / bom_position,status="gap")、
|
||||||
默认三项(substitution_type / rule_version / owner,status="default")、每颗候选的「未披露」参数各一行(status="gap")、原 PN 资料版本(status="ok");
|
默认三项(substitution_type / rule_version / owner,status="default")、每颗候选的「未披露」参数各一行(status="gap")、原 PN 资料版本(status="ok");
|
||||||
逐列口径见这一步的剧本。
|
逐列口径见这一步的剧本。
|
||||||
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
|
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
|
||||||
|
"gateLedgers":["${D}.input_gap"],
|
||||||
"title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
|
"title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
|
||||||
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
|
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
|
||||||
"steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
|
"steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
|
||||||
@@ -300,6 +326,8 @@ export const ASSESSMENT_STEPS = [
|
|||||||
|
|
||||||
【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`),
|
【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`),
|
||||||
发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。
|
发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。
|
||||||
|
**那一拍只发卡,别顺手写 \`demand.request\`** —— 需求行的客户编码有引用检查(客户还没落草稿层,写下去当场被拒);
|
||||||
|
等人确认完,下一拍再写需求行与核对表,一次写齐。
|
||||||
|
|
||||||
【写 \`demand.request\` 一行(V1)】request_id、customer_code(= customerCode)、requirement_version = "V1"、request_type = "技术需求"、
|
【写 \`demand.request\` 一行(V1)】request_id、customer_code(= customerCode)、requirement_version = "V1"、request_type = "技术需求"、
|
||||||
request_description(入参原话,不改写)、input_snapshot_ref、original_internal_pn、request_status = "pending"、current_activity = "ACT-WORK-05"、
|
request_description(入参原话,不改写)、input_snapshot_ref、original_internal_pn、request_status = "pending"、current_activity = "ACT-WORK-05"、
|
||||||
@@ -364,6 +392,10 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
|
|||||||
brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。
|
brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。
|
||||||
4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。
|
4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。
|
||||||
|
|
||||||
|
**\`created\` 是「这颗料真的建好了」,不是「我打算建」**:只有它在 \`parts.profile\` 里真有一行(件上填好交回来了)
|
||||||
|
才改成 created —— 门上有一条逐颗对账(建好几颗就得有几行主数据),标了却没建,这一步过不去,
|
||||||
|
到 S2 写关系时还会被引用检查再拦一次。
|
||||||
|
|
||||||
【P-08 那张确认回来了】这一拍的动作串**恰好两个,顺序不能反**:
|
【P-08 那张确认回来了】这一拍的动作串**恰好两个,顺序不能反**:
|
||||||
① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按
|
① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按
|
||||||
「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在
|
「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'ACT-WORK-05 · 差异评估',
|
name: 'ACT-WORK-05 · 差异评估',
|
||||||
packageVersion: '1.2.6',
|
packageVersion: '1.2.9',
|
||||||
category: '需求分析与方案匹配',
|
category: '需求分析与方案匹配',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-part-substitution-assessment',
|
checksum: 'dev-checksum-part-substitution-assessment',
|
||||||
|
|||||||
+2
@@ -857,6 +857,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
@@ -1470,6 +1471,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
gate: z.ZodOptional<z.ZodObject<{
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
ready: z.ZodBoolean;
|
ready: z.ZodBoolean;
|
||||||
why: z.ZodString;
|
why: z.ZodString;
|
||||||
|
|||||||
@@ -31,6 +31,37 @@ const pair = (parameterName: string, originalValue: string, candidateValue: stri
|
|||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
|
||||||
|
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
|
||||||
|
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
candidateInternalPn: 'LM2596S-ADJ',
|
||||||
|
pairs: [
|
||||||
|
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
|
||||||
|
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
|
||||||
|
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
|
||||||
|
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
|
||||||
|
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
candidateInternalPn: 'X',
|
||||||
|
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('compare-parameters:逐参数四态', () => {
|
describe('compare-parameters:逐参数四态', () => {
|
||||||
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
|
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
|
||||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||||
import { missingReadings } from '../wiring/engines.ts';
|
import { missingReadings } from '../wiring/engines.ts';
|
||||||
|
|
||||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||||
@@ -284,6 +284,36 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
|
|||||||
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
|
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
|
||||||
|
expect(
|
||||||
|
gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
|
||||||
|
).toEqual(['parts.profile', 'parts.parameter']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
|
||||||
|
const watched = [
|
||||||
|
{ domain: 'parts', object: 'profile', label: '芯片主数据' },
|
||||||
|
{ domain: 'parts', object: 'parameter', label: '标准化参数集' },
|
||||||
|
];
|
||||||
|
const declared = ['parts.profile', 'parts.parameter'];
|
||||||
|
expect(emptyGate({}, watched, declared)?.ready).toBe(false);
|
||||||
|
expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
|
||||||
|
// 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
|
||||||
|
expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
|
||||||
|
// 一张有一张没有 → 只点没有的那一张
|
||||||
|
expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
|
||||||
|
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
|
||||||
|
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
|
||||||
|
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
|
||||||
|
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
|
||||||
|
});
|
||||||
|
|
||||||
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
|
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
|
||||||
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
|
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
|
||||||
expect(gateAddressesOf([], [{}])).toEqual([]);
|
expect(gateAddressesOf([], [{}])).toEqual([]);
|
||||||
|
|||||||
@@ -794,33 +794,74 @@ async function submitGate(
|
|||||||
if (task === undefined) return undefined;
|
if (task === undefined) return undefined;
|
||||||
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||||
if (manifest === undefined) return undefined;
|
if (manifest === undefined) return undefined;
|
||||||
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
|
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables, order.gateLedgers);
|
||||||
if (addresses.length === 0) return undefined;
|
if (addresses.length === 0) return undefined;
|
||||||
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
||||||
if (watched.length === 0) return undefined;
|
if (watched.length === 0) return undefined;
|
||||||
|
/*
|
||||||
|
* **卡自己点名要拦的表,「一行都没有」也算没完成**(2026-08-27 W05 e2e 实测)。
|
||||||
|
*
|
||||||
|
* 缺必填的算法只数**已有行**缺不缺格子 —— 零行 = 零缺,闸照开。实测那一单:
|
||||||
|
* 模型压拍,骨架一行没写就把 P-08 数据工单发了出去;人打开件是空的,提交钮却绿着,
|
||||||
|
* 交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被 refersTo 那道墙拦下。
|
||||||
|
* 只对**卡自报**(`gateLedgers`)那几张表这么判 —— 那是发卡的人明说「这张卡就是来填它的」;
|
||||||
|
* 回落到必交付物的老路一个字不变(那条路上零行是常态,比如还没到写的时候)。
|
||||||
|
*/
|
||||||
|
const declared = (order.gateLedgers ?? []).map((a) => a.trim()).filter((a) => a !== '');
|
||||||
|
|
||||||
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
||||||
const mine: Record<string, unknown[]> = {};
|
const mine: Record<string, unknown[]> = {};
|
||||||
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
||||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||||
}
|
}
|
||||||
return gateOf(missingReadings(mine, watched), watched);
|
return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
|
* **卡自报要拦的表里,有没有「一行都没有」的** —— 有就是没完成,别的都不用算了。
|
||||||
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
|
*
|
||||||
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
|
* 「缺必填」的算法只数**已有行**缺不缺格子:零行 = 零缺,闸照开(2026-08-27 W05 e2e 实测)。
|
||||||
* 而失效的闸在页面上看不出来(按钮照常绿着)。
|
* 那一单模型压拍、骨架一行没写就把数据工单发了出去 —— 人打开件是空的、什么也补不了,
|
||||||
|
* 提交钮却绿着;交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被引用检查拦下。
|
||||||
|
*
|
||||||
|
* 只对**卡自报**(`gateLedgers`)那几张表这么判:那是发卡的人明说「这张卡就是来填它的」。
|
||||||
|
* 回落到必交付物的老路一个字不变 —— 那条路上零行是常态(还没轮到写的时候)。
|
||||||
|
*/
|
||||||
|
export function emptyGate(
|
||||||
|
mine: Readonly<Record<string, ReadonlyArray<unknown>>>,
|
||||||
|
watched: ReadonlyArray<{ domain: string; object: string; label?: string | undefined }>,
|
||||||
|
declared: ReadonlyArray<string>,
|
||||||
|
): { ready: boolean; why: string; blanks: string[] } | undefined {
|
||||||
|
if (declared.length === 0) return undefined;
|
||||||
|
const empty = watched.filter(
|
||||||
|
(l) => declared.includes(`${l.domain}.${l.object}`) && (mine[`mine:${l.domain}.${l.object}`] ?? []).length === 0,
|
||||||
|
);
|
||||||
|
if (empty.length === 0) return undefined;
|
||||||
|
const names = empty.map((l) => l.label ?? `${l.domain}.${l.object}`);
|
||||||
|
return { ready: false, why: `${names.join('、')} 这一单还一行都没有 —— 这张卡就是来建它的,先在件上加一行填好再提交`, blanks: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 闸看哪几张表,三级取一:
|
||||||
|
* ①卡上绑了表(`blocks`)就看绑的那几张;
|
||||||
|
* ②**卡自己声明了 `gateLedgers`** 就看它 —— 零输入卡里,只有发卡的人知道这一张卡要人补哪几张表
|
||||||
|
* (2026-08-27 W05 实测:数据工单要补的是 `parts.*` 主数据,不在必交付物名单里,
|
||||||
|
* 闸于是盯着一堆与这张卡无关的表、一格不缺、空手交卷照放);
|
||||||
|
* ③都没有才回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
|
||||||
|
* 三级全空才真的没有依据 —— **没有依据的时候不拦人**。
|
||||||
|
* 抽成纯函数是为了钉测:回落断了闸就整个失效,而失效的闸在页面上看不出来(按钮照常绿着)。
|
||||||
*/
|
*/
|
||||||
export function gateAddressesOf(
|
export function gateAddressesOf(
|
||||||
blocks: ReadonlyArray<ViewBlock> | undefined,
|
blocks: ReadonlyArray<ViewBlock> | undefined,
|
||||||
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
|
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
|
||||||
|
declared?: ReadonlyArray<string> | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
const bound = (blocks ?? [])
|
const bound = (blocks ?? [])
|
||||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||||
.map((b) => b.ledger as string);
|
.map((b) => b.ledger as string);
|
||||||
if (bound.length > 0) return bound;
|
if (bound.length > 0) return bound;
|
||||||
|
const own = (declared ?? []).map((a) => a.trim()).filter((a) => a !== '');
|
||||||
|
if (own.length > 0) return own;
|
||||||
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
|
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -357,9 +357,20 @@ type ParamPair = {
|
|||||||
candidateValue: string;
|
candidateValue: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
constraintType: 'hard' | 'soft';
|
constraintType: 'hard' | 'soft';
|
||||||
requirement?: string;
|
/**
|
||||||
evidenceRef?: string;
|
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
|
||||||
|
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
|
||||||
|
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
|
||||||
|
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
|
||||||
|
*/
|
||||||
|
requirement?: string | null;
|
||||||
|
evidenceRef?: string | null;
|
||||||
};
|
};
|
||||||
|
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
|
||||||
|
function text(v: unknown): string {
|
||||||
|
return typeof v === 'string' ? v.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
|
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
|
||||||
|
|
||||||
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
|
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
|
||||||
@@ -397,9 +408,9 @@ function compareParsed(o: Parsed, c: Parsed): CompareResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
|
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
|
||||||
function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined {
|
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
|
||||||
if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
|
const s = text(requirement);
|
||||||
const s = requirement.trim();
|
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
|
||||||
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
|
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
|
||||||
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
|
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
|
||||||
const hi = c.kind === 'range' ? c.hi : c.value;
|
const hi = c.kind === 'range' ? c.hi : c.value;
|
||||||
@@ -415,8 +426,8 @@ function satisfiesRequirement(requirement: string | undefined, c: Parsed): boole
|
|||||||
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
|
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
|
||||||
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
|
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
|
||||||
function compareLifecycle(o: string, c: string): CompareResult | undefined {
|
function compareLifecycle(o: string, c: string): CompareResult | undefined {
|
||||||
const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()];
|
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
|
||||||
const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()];
|
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
|
||||||
if (ro === undefined || rc === undefined) return undefined;
|
if (ro === undefined || rc === undefined) return undefined;
|
||||||
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
|
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
|
||||||
}
|
}
|
||||||
@@ -430,11 +441,11 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
|
|||||||
const req = satisfiesRequirement(p.requirement, c);
|
const req = satisfiesRequirement(p.requirement, c);
|
||||||
if (req === false) {
|
if (req === false) {
|
||||||
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
||||||
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${p.requirement ?? ''}` };
|
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
|
||||||
}
|
}
|
||||||
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 && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) {
|
if (hard && text(p.evidenceRef) === '') {
|
||||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
|
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
|
||||||
}
|
}
|
||||||
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
|
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
|
||||||
|
|||||||
@@ -1002,7 +1002,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
|
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
|
||||||
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
|
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
|
||||||
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
|
'要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' +
|
||||||
'或者把这一笔挂到已存在的那一行名下',
|
'或者把这一笔挂到已存在的那一行名下',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1685,6 +1685,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
|
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
|
||||||
: [];
|
: [];
|
||||||
const standard = String(input.card['standard'] ?? '').trim();
|
const standard = String(input.card['standard'] ?? '').trim();
|
||||||
|
/*
|
||||||
|
* **这张卡该拦哪几张表** —— 零输入卡没有 `blocks`,闸只能回落到必交付物,
|
||||||
|
* 而数据工单要人补的是 `parts.*` 这类主数据(不在交付物名单里):闸盯着一堆
|
||||||
|
* 与这张卡无关的表,一格不缺,空手交卷一路放行(2026-08-27 W05 实测)。
|
||||||
|
* 认不出形状的一律丢掉,不把脏东西端给闸。
|
||||||
|
*/
|
||||||
|
const gateLedgers = Array.isArray(input.card['gateLedgers'])
|
||||||
|
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
|
||||||
|
: [];
|
||||||
const issued = await deps.humanTool.issueExecution(context, {
|
const issued = await deps.humanTool.issueExecution(context, {
|
||||||
title,
|
title,
|
||||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
||||||
@@ -1693,6 +1702,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
...(steps.length === 0 ? {} : { steps }),
|
...(steps.length === 0 ? {} : { steps }),
|
||||||
...(standard === '' ? {} : { standard }),
|
...(standard === '' ? {} : { standard }),
|
||||||
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
||||||
|
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
|
||||||
});
|
});
|
||||||
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
||||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { TaskId } from '@contracts';
|
import type { TaskId } from '@contracts';
|
||||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
|
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
|
||||||
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
|
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
|
||||||
import type { PendingAcceptance } from '../port.ts';
|
import type { PendingAcceptance } from '../port.ts';
|
||||||
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
|
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
|
||||||
@@ -992,7 +992,7 @@ describe('宣告完成', () => {
|
|||||||
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
|
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
|
||||||
* 不是让模型自己判断,是让基座去核。
|
* 不是让模型自己判断,是让基座去核。
|
||||||
*/
|
*/
|
||||||
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
|
it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => {
|
||||||
const h = harness({
|
const h = harness({
|
||||||
parsed: STAGE_MODEL,
|
parsed: STAGE_MODEL,
|
||||||
runtime: READY_RUNTIME2,
|
runtime: READY_RUNTIME2,
|
||||||
@@ -1005,6 +1005,25 @@ describe('宣告完成', () => {
|
|||||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
|
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **早收一次不算撞墙,第二次才算**(2026-08-27 W05 e2e:五案例里三例各白吃一次被拒 ——
|
||||||
|
* 模型把「写行 · 调工具 · finish」排在同一串,工具还没轮到调 finish 先到了,下一拍它自己补上)。
|
||||||
|
* 与「重发已答过的卡头两次无事发生」同一把尺;真在原地打转的单仍看得见(第二次起照旧拒,喂熔断计数)。
|
||||||
|
*/
|
||||||
|
it('同一项第二次早收:照旧被拒 —— 熔断要看得见真卡死的单', async () => {
|
||||||
|
const h = harness({
|
||||||
|
parsed: STAGE_MODEL,
|
||||||
|
runtime: READY_RUNTIME2,
|
||||||
|
action: finish('FREE'),
|
||||||
|
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0' }],
|
||||||
|
sfeelCondition: () => false,
|
||||||
|
trail: [{ kind: 'observation', title: earlyFinishTitleOf('FREE') }],
|
||||||
|
});
|
||||||
|
const outcome = await h.runner.runTurn(TASK);
|
||||||
|
expect(outcome.status).toBe('running');
|
||||||
|
expect(h.appended.some((m) => m.kind === 'result' && m.title.includes('行动被拒'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
|
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
|
||||||
const h = harness({
|
const h = harness({
|
||||||
parsed: STAGE_MODEL,
|
parsed: STAGE_MODEL,
|
||||||
@@ -1704,6 +1723,58 @@ describe('办完的项:话要说对,按一下不报错', () => {
|
|||||||
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
|
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
|
||||||
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
|
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
|
||||||
*/
|
*/
|
||||||
|
describe('「这张卡人已经答过了」摆成每拍都在的事实行', () => {
|
||||||
|
const sent = (title: string) => ({ kind: 'card-sent', title });
|
||||||
|
it('**决策卡:把人选的那个值摆出来**(2026-08-27 W05 e2e:五个案例四个死在这里 —— 剧本让模型自己去轨迹里翻回传,而那条痕会被挤走)', () => {
|
||||||
|
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
|
||||||
|
expect(line).toContain('人已经答过了');
|
||||||
|
expect(line).toContain('他选的是「approve」');
|
||||||
|
expect(line).toContain('finish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
|
||||||
|
const line = answeredCardFact([sent('评估输入确认'), { kind: 'card-reply', title: '评估输入确认' }]);
|
||||||
|
expect(line).toContain('空答复 = 照原样确认');
|
||||||
|
expect(line).not.toContain('他选的是');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回传之后又发了**别的**卡 → 有新问题在等,这条不摆', () => {
|
||||||
|
const line = answeredCardFact([
|
||||||
|
sent('第一问'),
|
||||||
|
{ kind: 'card-reply', title: '第一问', detail: { value: 'approve' } },
|
||||||
|
sent('另一件完全不同的事'),
|
||||||
|
]);
|
||||||
|
expect(line).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一张卡都没答过 → 空', () => {
|
||||||
|
expect(answeredCardFact([sent('还没答的卡')])).toBe('');
|
||||||
|
expect(answeredCardFact([])).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('doneWhen 为假的拒回:差在哪要说得出口', () => {
|
||||||
|
it('**缺的是一次工具调用就直说** —— 2026-08-27 W05 e2e:五个案例里三个撞上,拒回只教它去查「行与列」,而缺的是 recommend 没调过', () => {
|
||||||
|
const why = unmetWhy('rows > 0 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 0 });
|
||||||
|
expect(why).toContain('缺的不是行也不是列,是一次工具调用');
|
||||||
|
expect(why).toContain('tool_w05_recommend_calls');
|
||||||
|
expect(why).toContain('tool_w05_recommend_calls=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('工具调过了(读数 > 0)→ 不点它的名,只把判据与读数摆出来', () => {
|
||||||
|
const why = unmetWhy('rows = 10 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 2 });
|
||||||
|
expect(why).not.toContain('缺的不是行也不是列');
|
||||||
|
expect(why).toContain('rows=9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('判据里压根没有工具那一档 → 照旧只摆判据与读数', () => {
|
||||||
|
const why = unmetWhy('a_rows = b_rows', { a_rows: 3, b_rows: 4 });
|
||||||
|
expect(why).toContain('a_rows=3');
|
||||||
|
expect(why).toContain('b_rows=4');
|
||||||
|
expect(why).not.toContain('工具调用');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('同一张卡再发一遍的三档', () => {
|
describe('同一张卡再发一遍的三档', () => {
|
||||||
const sent = (title: string) => ({ kind: 'card-sent', title });
|
const sent = (title: string) => ({ kind: 'card-sent', title });
|
||||||
const replied = (title: string) => ({ kind: 'card-reply', title });
|
const replied = (title: string) => ({ kind: 'card-reply', title });
|
||||||
@@ -1717,6 +1788,21 @@ describe('同一张卡再发一遍的三档', () => {
|
|||||||
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
|
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('**人选的那个值当场摆出来**(2026-08-27 W05 e2e:人点了通过,拒回只给「值在轨迹里」这个指针,模型对不准那一条,连发五次同一张卡熔断)', () => {
|
||||||
|
const win = [sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: 'approve', note: '试跑代答' } }];
|
||||||
|
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问', chose: 'approve' });
|
||||||
|
const rejected = resendVerdict([...win, echoed('这一问'), echoed('这一问')], '这一问');
|
||||||
|
expect(rejected).toEqual({ kind: 'reject', answered: '这一问', chose: 'approve' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回传痕里没有值(空串 / 缺 detail)→ 不编,退回原来那句指针话术', () => {
|
||||||
|
expect(resendVerdict([sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: ' ' } }], '这一问')).toEqual({
|
||||||
|
kind: 'echo',
|
||||||
|
answered: '这一问',
|
||||||
|
});
|
||||||
|
expect(resendVerdict([sent('这一问'), replied('这一问')], '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
|
||||||
|
});
|
||||||
|
|
||||||
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
|
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
|
||||||
const win = [sent('这一问'), replied('这一问')];
|
const win = [sent('这一问'), replied('这一问')];
|
||||||
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
|
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
|
||||||
|
|||||||
@@ -396,7 +396,10 @@ export function criterionKey(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
|
||||||
|
* `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
|
||||||
|
* 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
|
||||||
|
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||||||
|
|||||||
@@ -210,6 +210,30 @@ function rejectReason(title: string): string {
|
|||||||
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
|
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
|
||||||
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
|
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* **doneWhen 为假时,「差在哪」要说得出口** —— 拒回的话是模型下一拍的行动指南。
|
||||||
|
*
|
||||||
|
* 从前这条拒回只有一句通用话术:「常见的是该写 N 行只写了 M 行,或某一行缺了必填列」——
|
||||||
|
* 把注意力全导向**行与列**。而收步判据里那半句常常根本不是行也不是列:
|
||||||
|
* `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条)。
|
||||||
|
* 实测(2026-08-27 W05 e2e,五个案例里三个撞上):模型跳过工具、自己把推荐等级填了,
|
||||||
|
* finish 恒被拒,而拒回教它去查行与列 —— 它只能把同样的明细再写一遍。
|
||||||
|
* **指向两扇打不开的门,比不指还糟。**
|
||||||
|
*
|
||||||
|
* 现在把判据原文与它那几个名字**此刻的读数**一起摆出来(`readingsOf`,与断点提示同一段),
|
||||||
|
* 并且:凡是 `tool_*_calls` 这一族读数为 0 的,单独点名说「缺的不是行也不是列,是一次工具调用」。
|
||||||
|
*/
|
||||||
|
export function unmetWhy(doneWhen: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
|
const head = ` 干完的判据:${doneWhen}${readingsOf(doneWhen, scope)}`;
|
||||||
|
const idle = [...new Set(doneWhen.match(/tool_[A-Za-z0-9_]+_calls/g) ?? [])].filter((name) => Number(scope[name] ?? 0) === 0);
|
||||||
|
if (idle.length === 0) return head;
|
||||||
|
return (
|
||||||
|
`${head} —— **缺的不是行也不是列,是一次工具调用**:${idle.join(' / ')} 现在是 0,` +
|
||||||
|
'说明这一步该用的工具你还没真调过(调失败不算)。先按剧本调它,拿工具算出来的结果写盘,' +
|
||||||
|
'别自己填那几个值 —— 这一步的收步判据数的就是这枚工具的调用次数。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function sameQuestion(a: string, b: string): boolean {
|
export function sameQuestion(a: string, b: string): boolean {
|
||||||
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
|
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
|
||||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
|
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
|
||||||
@@ -229,6 +253,11 @@ export function sameQuestion(a: string, b: string): boolean {
|
|||||||
return short.length >= 6 && long.startsWith(short);
|
return short.length >= 6 && long.startsWith(short);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */
|
||||||
|
export function earlyFinishTitleOf(itemId: string): string {
|
||||||
|
return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
||||||
export function echoTitleOf(answered: string): string {
|
export function echoTitleOf(answered: string): string {
|
||||||
return `这张卡人已答过,没有再发(「${answered}」)`;
|
return `这张卡人已答过,没有再发(「${answered}」)`;
|
||||||
@@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string {
|
|||||||
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
|
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
|
||||||
*/
|
*/
|
||||||
export function resendVerdict(
|
export function resendVerdict(
|
||||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||||
asked: string,
|
asked: string,
|
||||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
|
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: string } {
|
||||||
const trimmed = asked.trim();
|
const trimmed = asked.trim();
|
||||||
if (trimmed === '') return { kind: 'send' };
|
if (trimmed === '') return { kind: 'send' };
|
||||||
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
|
const replies = window.filter((t) => t.kind === 'card-reply');
|
||||||
|
const replied = new Set(replies.map((t) => t.title));
|
||||||
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
|
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
|
||||||
if (same === undefined) return { kind: 'send' };
|
if (same === undefined) return { kind: 'send' };
|
||||||
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
|
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
|
||||||
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
|
/*
|
||||||
|
* **他选的是什么,当场摆出来**(2026-08-27 W05 e2e 实测的一单熔断):
|
||||||
|
* 人点了「通过」,拒回却只说「值在你的轨迹里(card-reply 那一条)」—— 一个**指针**。
|
||||||
|
* 而模型恰恰对不准那一条(轨迹里躺着好几张卡的 card-reply),于是连发五次同一张卡熔断。
|
||||||
|
* 与 E04 那一课同源:**指南让模型去找的事实,引擎手里有现成的,就直接给它**。
|
||||||
|
* 值就在回传痕的 `detail.value` 里(决策卡的载荷,与 payloadOf 同一约定)。
|
||||||
|
*/
|
||||||
|
const value = replies.find((t) => t.title === same.title)?.detail?.['value'];
|
||||||
|
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||||
|
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title, ...(chose === undefined ? {} : { chose }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **「这张卡人已经答过了、他选的是什么」——摆成每拍都在的一句事实**(2026-08-27 W05 e2e 逼出来的)。
|
||||||
|
*
|
||||||
|
* 剧本里那句「先在轨迹里找这一项已答过、还没处理的 card-reply」是**让模型自己去翻窗口**,
|
||||||
|
* 而那条回传会被后面的痕挤走:实测五个案例里四个死在同一处 —— 人点了「通过」,
|
||||||
|
* 模型每拍照剧本第一档「没有回音 → 发第一张决策卡」,同一张卡连发五次熔断;
|
||||||
|
* 拒回把「他选的是 approve、接下来该写哪面旗」说得再明白也没用,因为**下一拍它又从头翻一次窗口**。
|
||||||
|
*
|
||||||
|
* 与 worldTouched 那条(2026-08-21)、E04 的「唯引擎事实行是从」(2026-08-24)同一条治法:
|
||||||
|
* **指南让模型判的事,引擎手里有现成事实的,一律改成读事实**。
|
||||||
|
*
|
||||||
|
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
|
||||||
|
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
|
||||||
|
*/
|
||||||
|
export function answeredCardFact(
|
||||||
|
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||||
|
): string {
|
||||||
|
let at = -1;
|
||||||
|
for (let i = window.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (window[i]?.kind === 'card-reply') {
|
||||||
|
at = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reply = at < 0 ? undefined : window[at];
|
||||||
|
if (reply === undefined) return '';
|
||||||
|
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
|
||||||
|
if (newer) return '';
|
||||||
|
const value = reply.detail?.['value'];
|
||||||
|
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||||
|
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
||||||
|
return chose === undefined
|
||||||
|
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
||||||
|
: `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -537,7 +612,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
||||||
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
||||||
*/
|
*/
|
||||||
doneWhenOf?: (itemId: string) => boolean | undefined,
|
doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
|
||||||
/**
|
/**
|
||||||
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
||||||
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
||||||
@@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
if (verdict.kind === 'reject') {
|
if (verdict.kind === 'reject') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
||||||
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
|
(verdict.chose === undefined
|
||||||
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
|
||||||
|
: `**他选的是「${verdict.chose}」** —— `) +
|
||||||
|
'照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
|
||||||
|
'再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
@@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
title: echoTitle,
|
title: echoTitle,
|
||||||
detail: {
|
detail: {
|
||||||
internal: true,
|
internal: true,
|
||||||
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
|
note:
|
||||||
|
verdict.chose === undefined
|
||||||
|
? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
|
||||||
|
: `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// 什么也没做,局面原样交回
|
// 什么也没做,局面原样交回
|
||||||
@@ -1283,13 +1364,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
||||||
*/
|
*/
|
||||||
const doneWhenNow = doneWhenOf?.(action.itemId);
|
const doneWhenNow = doneWhenOf?.(action.itemId);
|
||||||
if (doneWhenNow === false) {
|
if (doneWhenNow?.ok === false) {
|
||||||
throw new Error(
|
const say =
|
||||||
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
||||||
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
|
'不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' +
|
||||||
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
|
doneWhenNow.why;
|
||||||
'基座会自己收,不必你再 finish。',
|
/*
|
||||||
);
|
* **早收一次不算撞墙**(2026-08-27 W05 e2e:五案例里三例各白吃一次)——
|
||||||
|
* 与「重发已答过的卡头两次无事发生」同一把尺(设计律:按钮按两下不该变成墙)。
|
||||||
|
* 实测那一形是**计划次序**的小错:模型把 [写行 · 调工具 · finish] 排在同一串,
|
||||||
|
* 工具还没轮到调,finish 先到了;它下一拍自己就补上了 ——
|
||||||
|
* 这种拍不该记成「行动被拒」去喂熔断计数(那是给真卡死留的)。
|
||||||
|
* 头一次只落观察(把判据与差在哪原样摆回轨迹,和拒回一个字不差),
|
||||||
|
* 第二次起照旧拒 —— 真在原地打转的单仍然看得见。
|
||||||
|
*/
|
||||||
|
const seen = (await deps.memory.window(taskId, STALL_WINDOW)).filter(
|
||||||
|
(t) => t.kind === 'observation' && t.title === earlyFinishTitleOf(action.itemId),
|
||||||
|
).length;
|
||||||
|
if (seen === 0) {
|
||||||
|
await deps.memory.append({
|
||||||
|
taskId,
|
||||||
|
kind: 'observation',
|
||||||
|
title: earlyFinishTitleOf(action.itemId),
|
||||||
|
detail: { internal: true, note: say },
|
||||||
|
});
|
||||||
|
return { runtime, status: 'running' };
|
||||||
|
}
|
||||||
|
throw new Error(say);
|
||||||
}
|
}
|
||||||
const ready = readyToWork(finishModel, runtime, action.itemId);
|
const ready = readyToWork(finishModel, runtime, action.itemId);
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
@@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
: [
|
: [
|
||||||
`【当前这一步 · ${current.item}】${current.intent}`,
|
`【当前这一步 · ${current.item}】${current.intent}`,
|
||||||
currentTouch,
|
currentTouch,
|
||||||
|
answeredCardFact(trail),
|
||||||
current.guide,
|
current.guide,
|
||||||
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
||||||
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
||||||
@@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
const step = stepOf(itemId);
|
const step = stepOf(itemId);
|
||||||
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
||||||
try {
|
try {
|
||||||
return deps.sfeel.condition(step.doneWhen, liveScope);
|
const ok = deps.sfeel.condition(step.doneWhen, liveScope);
|
||||||
|
return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
|
||||||
} catch {
|
} catch {
|
||||||
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
> 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。
|
> 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。
|
||||||
> 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。
|
> 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。
|
||||||
> 台账字段口径见 `docs/数据域字段说明-W05.md`。
|
> 台账字段口径见 `docs/数据域字段说明-W05.md`。
|
||||||
> 全部走查在**浏览器**里做(测试台起单 → 工作台真人点),不是脚本代答。
|
> 走查分两条线:**浏览器**(测试台起单 → 工作台真人点,六轮)与**脚本 e2e**
|
||||||
|
> (`node tools/e2e-a2.mjs w05`,五案例代答,可一键复跑)。两条线逮到的缺陷不重合 ——
|
||||||
|
> 浏览器逮呈现与交互(件、闸、徽标、翻页、画面切换),脚本逮机制(空手交卷、循环、崩、归属)。
|
||||||
|
|
||||||
## 一、这个活动是什么
|
## 一、这个活动是什么
|
||||||
|
|
||||||
@@ -177,6 +179,23 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
|
|||||||
版本轨迹:1.0.0 → 1.0.1(A)→ 1.1.0/1.1.1(B·C·D)→ 1.2.0(参数子表)→ 1.2.1(F)→ 1.2.2(G)
|
版本轨迹:1.0.0 → 1.0.1(A)→ 1.1.0/1.1.1(B·C·D)→ 1.2.0(参数子表)→ 1.2.1(F)→ 1.2.2(G)
|
||||||
→ 1.2.3(H)→ 1.2.4(I)→ 1.2.5(J)→ **1.2.6(K)**。包内自测 16 → **61 条**。
|
→ 1.2.3(H)→ 1.2.4(I)→ 1.2.5(J)→ **1.2.6(K)**。包内自测 16 → **61 条**。
|
||||||
|
|
||||||
|
## 二·六、脚本 e2e 的六轮(2026-08-27)
|
||||||
|
|
||||||
|
浏览器那六轮之后,凭证到位、`w05` 套件跑起来,又逼出六条 —— **全是浏览器逮不到的那一类**
|
||||||
|
(脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候会先把格子填好再按提交):
|
||||||
|
|
||||||
|
| 轮 | 结果 | 这一轮逼出什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 1 通过 | **L 数据工单的提交闸没牙** |
|
||||||
|
| 2 | 3 通过 | **M 拒回数不出「缺的是一次工具调用」· N 决策卡拒回只给指针** |
|
||||||
|
| 3 | 1 通过 | N 的措辞版没用 → **改结构**;**O 工具被 null 打崩** · P `count` 假线索 |
|
||||||
|
| 4 | 2 通过 | **H 的残留面:零行同时骗过闸与哨兵** |
|
||||||
|
| 5 | 4 通过 | **created 逐颗对账太松 · S1 卡的前提没写在说明书** |
|
||||||
|
| 6 | **跑到的 4 例全 released,3 例被拒 0** | 案例 4 停在 `Insufficient Balance`(大模型余额) |
|
||||||
|
|
||||||
|
第 6 轮逐条实证:早收容忍让案例 1/2 的最后 1 次被拒消失(轨迹里是**观察**不是拒回);
|
||||||
|
三候选那例**第一次跑通**(前三轮:超时 / 超时 / 烧穿预算),两颗缺库一起办、三条结论全对。
|
||||||
|
|
||||||
## 三、走查发现总表
|
## 三、走查发现总表
|
||||||
|
|
||||||
| # | 现象 | 病根 | 治法 | 层 |
|
| # | 现象 | 病根 | 治法 | 层 |
|
||||||
@@ -192,6 +211,14 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
|
|||||||
| I | 原 PN 根本不在库,模型两颗都判「在库」,硬阻塞整条路被跳过 | 存在性核对是模型自报的一句话,基座数不出来;剧本已写到头 | 加 `found_pn_ref`(`refersTo` + `requiredWhen`):判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步 | 活动(结构) |
|
| I | 原 PN 根本不在库,模型两颗都判「在库」,硬阻塞整条路被跳过 | 存在性核对是模型自报的一句话,基座数不出来;剧本已写到头 | 加 `found_pn_ref`(`refersTo` + `requiredWhen`):判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步 | 活动(结构) |
|
||||||
| J | 数据工单派给了在库的那颗,缺的那颗没人建 | 剧本把「建哪几颗」与「要不要问人」写在同一串分岔里,从没写过「found 的不建」 | 拆成两个问题分别作答,并写明后果 | 活动 |
|
| J | 数据工单派给了在库的那颗,缺的那颗没人建 | 剧本把「建哪几颗」与「要不要问人」写在同一串分岔里,从没写过「found 的不建」 | 拆成两个问题分别作答,并写明后果 | 活动 |
|
||||||
| K | 数据录入卡的「参考信息」栏摆着引擎内部家具(九个计划项全名) | `entryCard.basis` 契约里可选,活动没声明,基座兜底把内部事实摆了上去 | 卡骨架补 `basis` 三行,并写明不给的后果 | 活动 |
|
| K | 数据录入卡的「参考信息」栏摆着引擎内部家具(九个计划项全名) | `entryCard.basis` 契约里可选,活动没声明,基座兜底把内部事实摆了上去 | 卡骨架补 `basis` 三行,并写明不给的后果 | 活动 |
|
||||||
|
| L | **数据工单的提交闸没牙**:件上红带点着「缺品牌名称」,提交钮却是绿的,空手交卷一路放行 | `gateAddressesOf` 只有两级 —— 没绑表就回落到**必交付物**,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆与这张卡无关的表,一格不缺 | 契约新增 `ExecutionWorkOrder.gateLedgers`(**这张卡该拦哪几张表,发卡的人说了算**),闸改三级取一:绑的表 > 卡自报 > 交付物 | **平台**(结构) |
|
||||||
|
| M | `finish` 撞 doneWhen 为假时,拒回只谈「行与列」,而缺的是一次**工具调用** | 拒回从不摆判据本身,更不摆读数 | 拒回摆出**判据原文 + 那几个名字此刻的读数**;`tool_*_calls` 读到 0 的单独点名 | **平台** |
|
||||||
|
| N | 人点了决策卡,模型每拍再发一张,连拒五拍熔断 | 剧本让模型「去轨迹里找 card-reply」,而那条痕会被挤走 —— 拒回把「他选的是 approve」写明白也没用,**下一拍它又从头翻一次** | `answeredCardFact`:把「这张卡已答过 → 他选的是什么」做成**每拍都在的事实行** | **平台**(结构) |
|
||||||
|
| O | 模型传 `requirement: null`,工具 `null.trim()` 当场崩,整次调用记成「行动被拒」 | 工具入参是宽松描述式 schema,null 一路落到处理器;处理器只判了 `undefined` | 边界上一律经 `text()` 归一,类型如实写成可空 | **平台**(我写的工具) |
|
||||||
|
| P | 拒回里摆着 `count=(没写过)` —— 那是 S-FEEL 的函数名,不是谁忘了记的账 | `readingsOf` 把内建函数当台账名 | `SFEEL_WORDS` 补上 count/sum/min/max/all/any | 平台 |
|
||||||
|
| Q | 模型压拍没写骨架就发数据工单,人打开是空表,而**零行 = 零缺**,闸与哨兵一起放过 | 「缺必填」只数已有行;`_missing = 0` 在零行时恒真 | 平台 `emptyGate`(**卡自报的表一行都没有 = 没完成**)+ 说明书③「发卡前骨架必须先有行」+ 哨兵 `profile_rows >= created` 逐颗对账 | 平台 + 活动 |
|
||||||
|
| R | 两颗缺库只办了第一颗,第二颗还 absent 就把 S1 的卡发了出去 | **发卡的授权面是调人预算,不是计划项** —— 它绕得过哨兵;而 S1 那张卡的**前提**只写在剧本里 | 前提写进说明书的卡骨架(「一颗 absent 都不剩」「有几颗 absent 就要办几颗」) | 活动 |
|
||||||
|
| S | 每例白吃一次的「早收」:工具还没轮到调,`finish` 先到了 | 那是**计划次序**的小错,却被记成「行动被拒」去喂熔断计数 | 与「重发已答过的卡」同一把尺:**头一次只落观察,第二次起才算被拒** | **平台** |
|
||||||
|
|
||||||
**可推广的三条**(已记 `docs/debt.md`):
|
**可推广的三条**(已记 `docs/debt.md`):
|
||||||
|
|
||||||
@@ -199,6 +226,9 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
|
|||||||
2. 拒回文案只谈行与列,数不出「缺的是一次工具调用」(发现 G);
|
2. 拒回文案只谈行与列,数不出「缺的是一次工具调用」(发现 G);
|
||||||
3. 执行工单绑的必交付台账**一行都没有**时提交闸无牙 —— 回落只数「已有行缺不缺必填」,零行 = 零缺(发现 H)。
|
3. 执行工单绑的必交付台账**一行都没有**时提交闸无牙 —— 回落只数「已有行缺不缺必填」,零行 = 零缺(发现 H)。
|
||||||
|
|
||||||
|
发现 L 是这三条里**唯一当场治了平台的**:闸看的表与件上摆的表不是同一张,闸就等于没有 ——
|
||||||
|
而这件事只有脚本 e2e 逮得到(它每张卡都先空手交一次;人手点的时候会先把格子填好再按提交)。
|
||||||
|
|
||||||
**还有一条**(平台侧,已记 debt):`disable` 这条路从来没通过(发现 A)——
|
**还有一条**(平台侧,已记 debt):`disable` 这条路从来没通过(发现 A)——
|
||||||
`Disabled` 只能从 `Enabled` 来,而 `Enabled` 只由 `plan` 动作写。
|
`Disabled` 只能从 `Enabled` 来,而 `Enabled` 只由 `plan` 动作写。
|
||||||
|
|
||||||
@@ -222,3 +252,13 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
|
|||||||
- 落账逐单 SQL 复核:P-11 主记录 / 差异明细 / P-01 回写 / 验证任务 / 主数据 P-08 与 P-10,值与业务一致;
|
- 落账逐单 SQL 复核:P-11 主记录 / 差异明细 / P-01 回写 / 验证任务 / 主数据 P-08 与 P-10,值与业务一致;
|
||||||
- **只读边界守住**:`customer_approval_status` 每单都留空(本活动不产生),`rule_version` 随结论落账;
|
- **只读边界守住**:`customer_approval_status` 每单都留空(本活动不产生),`rule_version` 随结论落账;
|
||||||
- 出口只有一口 `assessment-released`,每单都真发射了。
|
- 出口只有一口 `assessment-released`,每单都真发射了。
|
||||||
|
|
||||||
|
4. **两条线逮到的缺陷不重合,缺一条就漏一半。**
|
||||||
|
浏览器逮的是**人看见什么、点得动什么**(件、闸、徽标、翻页、画面切换、快照 1:1);
|
||||||
|
脚本逮的是**机制**(空手交卷、循环、崩、归属)。发现 L–S 八条没有一条是浏览器走查逮到的 ——
|
||||||
|
因为人手点的时候会先把格子填好再按提交,永远不会去试「空着交上去会怎样」。
|
||||||
|
|
||||||
|
5. **拒回喂事实,不喂指针。**
|
||||||
|
发现 N 那一轮把「他选的是 approve、下一步写哪面旗」写进拒回,日志里逐字可见,模型照发不误 ——
|
||||||
|
因为**下一拍它又从头翻一次会滚走的窗口**。把同一件事做成「每拍都在的事实行」之后,一次就好了。
|
||||||
|
同一条道理已经在 worldTouched(2026-08-21)、E04 的 worldTouched 事实行(2026-08-24)上各现过一次。
|
||||||
|
|||||||
+2
-2
@@ -165,7 +165,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
|||||||
| 门 | 在哪 | 规则 |
|
| 门 | 在哪 | 规则 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **起单宽进** | `process.start` | 不按 Schema 拒缺项。三活动 `input.required` 一律空(宽进严出),起单补全卡那条老路实际不再触发(彻底移除记 debt) |
|
| **起单宽进** | `process.start` | 不按 Schema 拒缺项。三活动 `input.required` 一律空(宽进严出),起单补全卡那条老路实际不再触发(彻底移除记 debt) |
|
||||||
| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表:卡上绑了 blocks 看绑的;**没绑(执行卡零输入后是常态)回落到必交付物声明的 `ledgers`/`ledger`**(`gateAddressesOf`,有测钉着)。两边都空才不给闸 —— 没有依据的时候不拦人 |
|
| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表**三级取一**(`gateAddressesOf`,有测钉着):①卡上绑了 blocks 看绑的;②**卡自己声明的 `gateLedgers`**(零输入卡该给的就是它 —— 只有发卡的人知道这一张要人补哪几张表);③回落到必交付物声明的 `ledgers`/`ledger`。三级全空才不给闸 —— 没有依据的时候不拦人。**主数据类的数据工单必须给 `gateLedgers`**:它要补的表(`parts.*`)不在必交付物名单里,不给就回落到一堆无关的表、一格不缺,件上红带点着缺项而提交钮照样绿(2026-08-27 W05 e2e 实测) |
|
||||||
| **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 |
|
| **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 |
|
||||||
| **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) |
|
| **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) |
|
||||||
| **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) |
|
| **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) |
|
||||||
@@ -252,7 +252,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
|||||||
| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
|
| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
|
||||||
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
|
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
|
||||||
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
|
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
|
||||||
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住(闸回落到必交付物的台账声明) |
|
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住 —— **卡上加 `gateLedgers:["域.对象"]` 告诉闸这一张要拦哪几张表**,不给才回落到必交付物声明 |
|
||||||
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
|
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
|
||||||
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
|
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
|
||||||
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
|
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
|
||||||
|
|||||||
@@ -356,6 +356,18 @@ export const executionWorkOrderSchema = z.object({
|
|||||||
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
|
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
|
||||||
* 按钮照常能按:**没有依据的时候不拦人**。
|
* 按钮照常能按:**没有依据的时候不拦人**。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* **这张卡该拦哪几张台账**(发卡的人说了算)—— 不给就照旧回落到必交付物声明。
|
||||||
|
*
|
||||||
|
* 起因(2026-08-27 W05 实测):数据工单是零输入卡(没有 `blocks`),闸于是回落到
|
||||||
|
* **必交付物**的那几张表 —— 而数据工单要人补的是 `parts.profile` / `parts.parameter`
|
||||||
|
* 这类**主数据**,压根不在交付物名单里。结果:件上红带如实点着「缺品牌名称、缺封装」,
|
||||||
|
* 提交钮却是绿的,空手交卷一路放行。**闸看的表和件上摆的表不是同一张,闸就等于没有。**
|
||||||
|
*
|
||||||
|
* 声明的是台账地址(`域.对象`),基座据此现算缺几格 —— 与红带、放行门 L1 同一个数同一套算法。
|
||||||
|
* 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。
|
||||||
|
*/
|
||||||
|
gateLedgers: z.array(z.string()).optional(),
|
||||||
gate: z
|
gate: z
|
||||||
.object({
|
.object({
|
||||||
/** 能交了吗 */
|
/** 能交了吗 */
|
||||||
|
|||||||
+20
-2
@@ -31,6 +31,17 @@ const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, '');
|
|||||||
const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp';
|
const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp';
|
||||||
const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? '';
|
const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? '';
|
||||||
const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
|
const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
|
||||||
|
/**
|
||||||
|
* **直供 token 的旁路**(`JCAI_E2E_BEARER`)—— 手边没有服务账号客户端时的临时通道:
|
||||||
|
* 直接给一枚已经拿到手的 access token(比如从浏览器会话里取的、有效期调长过的)。
|
||||||
|
* 两处代价说在前头,别不知不觉踩上:
|
||||||
|
* ①**卡会落到那个人的真工作台上**(服务账号那条路才是「绝不派到用户工作台」的);
|
||||||
|
* ②**不会自动续期** —— 过期了整套当场 401,得换一枚重跑。
|
||||||
|
* **给了就用它** —— 显式设了这个变量就是要走旁路,哪怕环境里还留着一对
|
||||||
|
* `KEYCLOAK_E2E_CLIENT_ID/SECRET`(常见于 `.stable/env` 里的历史残留,还未必有效);
|
||||||
|
* 不设它才走服务账号那条正路(ADR-012)。
|
||||||
|
*/
|
||||||
|
const DIRECT_BEARER = (process.env.JCAI_E2E_BEARER ?? '').replace(/^Bearer\s+/i, '').trim();
|
||||||
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
|
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
|
||||||
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
|
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
|
||||||
|
|
||||||
@@ -758,7 +769,8 @@ let bearer = '';
|
|||||||
let bearerAt = 0;
|
let bearerAt = 0;
|
||||||
|
|
||||||
async function api(path, init = {}) {
|
async function api(path, init = {}) {
|
||||||
if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
|
/* 直供的那枚续不了期(没有 refresh 凭据),只有服务账号那条路才换 */
|
||||||
|
if (bearer !== '' && DIRECT_BEARER === '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) },
|
headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) },
|
||||||
@@ -779,8 +791,14 @@ async function api(path, init = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
if (DIRECT_BEARER !== '') {
|
||||||
|
if (bearer === '') console.log(' 身份:JCAI_E2E_BEARER 直供的 token(不续期;卡会落到这枚 token 主人的工作台)');
|
||||||
|
bearer = `Bearer ${DIRECT_BEARER}`;
|
||||||
|
bearerAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') {
|
if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') {
|
||||||
throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)');
|
throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012);临时可用 JCAI_E2E_BEARER 直供一枚');
|
||||||
}
|
}
|
||||||
const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user