From df1888232205b3b976293354405e7b224dead8a7 Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 09:31:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=84=9A=E6=9C=AC=20e2e=20=E9=80=BC=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E5=85=AB=E6=9D=A1:=E9=97=B8=E7=9C=8B=E9=94=99?= =?UTF-8?q?=E4=BA=86=E8=A1=A8=20=C2=B7=20=E6=8B=92=E5=9B=9E=E5=96=82?= =?UTF-8?q?=E6=8C=87=E9=92=88=20=C2=B7=20=E9=9B=B6=E8=A1=8C=E9=AA=97?= =?UTF-8?q?=E8=BF=87=E9=97=B8=E4=B8=8E=E5=93=A8=E5=85=B5=20=C2=B7=20?= =?UTF-8?q?=E6=97=A9=E6=94=B6=E4=B8=8D=E8=AF=A5=E7=AE=97=E6=92=9E=E5=A2=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 凭证到位后 `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) Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh --- .../__tests__/steps.test.ts | 28 ++++ .../src/case-model.ts | 36 ++++- .../src/manifest.ts | 2 +- api-report/packages/contracts/src/card.d.ts | 2 + .../first-party-substitution.test.ts | 31 ++++ .../src/app/__tests__/view-table-edit.test.ts | 32 ++++- apps/api/src/app/routes/desk.ts | 53 ++++++- apps/api/src/app/tools/first-party.ts | 29 ++-- apps/api/src/app/wiring/engines.ts | 12 +- .../__tests__/turn-runner-acceptance.test.ts | 90 +++++++++++- .../loop-engine/internal/domain/lifecycle.ts | 5 +- .../internal/usecase/turn-runner.ts | 133 ++++++++++++++++-- docs/ACT-WORK-05-端到端测试记录.md | 42 +++++- docs/活动开发规范.md | 4 +- packages/contracts/src/card.ts | 12 ++ tools/e2e-a2.mjs | 22 ++- 16 files changed, 490 insertions(+), 43 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index cf714f2..be70ee8 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -252,6 +252,34 @@ describe('说明书与剧本的骨架', () => { 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 字;除放行外每项恰属一段', () => { expect(ASSESSMENT_PHASES).toHaveLength(5); for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 24a5a32..ec78b82 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -41,6 +41,7 @@ export const CMMN_XML = ` + @@ -79,7 +80,17 @@ export const CMMN_XML = ` --> update - 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 + + 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 @@ -213,7 +224,11 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该 \`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因; 点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。 -③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上): +③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上)—— +**发这张卡之前,\`parts.profile\` 与 \`parts.parameter\` 必须先有骨架行**(名单里每颗料各一行 + 它的参数各一行)。 +不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时这一步的剧本还没摊开),写骨架与发卡都得在同一串里,顺序不能反 —— +件上一行都没有的话,人打开是一张空表、什么也补不了,而这一单的主数据从头到尾不会存在 +(实测走到 S2 才被引用检查拦下,那时两张数据工单都已经「办完」了) {"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{ "title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>", "ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。", @@ -222,8 +237,12 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该 "有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写", "改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"], "standard":"每颗料号表 1 一行,必填列全齐;表 2 至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项各一行。", + "gateLedgers":["parts.profile","parts.parameter"], "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"}, {"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}} +**\`gateLedgers\` 必须给**:这是零输入卡,基座不知道这一张要人补哪几张表 —— +不给它就回落到必交付物那几张(与这张卡无关),于是件上红带点着「缺品牌名称」而提交钮照样绿, +空手交卷一路放行(2026-08-27 实测)。给了之后,闸与红带、放行门读的是同一个数。 ④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发): {"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{ @@ -233,15 +252,22 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该 "同一料号多份资料就多加几行,序号 01/02… 自己续", "改完保存,回这张卡确认"], "standard":"每颗料号至少一行 datasheet 记录,必填列全齐。", + "gateLedgers":["parts.document"], "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}} ⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入)—— +**发它的前提:所有料号都已落定** —— \`${D}.pn_check\` 里**一颗 absent 都不剩**(每颗要么 found、要么经数据工单 created、 +要么人点了放弃 abandoned),且建过的那几颗在 \`parts.profile\` 里真有行。还有 absent 就**回去把它们办完** +(候选总数 ≥2 的逐颗发决策卡问、原 PN 与独苗候选直接建),别先发这张卡: +硬发出去人也补不成 —— 后面写替代关系时会被引用检查逐颗拦下,而那时数据工单都已经「办完」了(2026-08-27 实测两轮)。 +**有几颗 absent 就要办几颗**,办完第一颗别忘了还有第二颗。 **发这张卡之前,\`${D}.input_gap\` 必须先有行**:清单为空的卡就是让人对着一张空表签字,他补不了任何东西。 不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时 S1 的剧本还没摊开),写清单与发卡都得在同一串里,顺序不能反: 已绑定四项(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"); 逐列口径见这一步的剧本。 {"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{ + "gateLedgers":["${D}.input_gap"], "title":"评估输入与资料补齐确认:<原 PN> → 个候选", "ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。", "steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)", @@ -300,6 +326,8 @@ export const ASSESSMENT_STEPS = [ 【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`), 发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。 +**那一拍只发卡,别顺手写 \`demand.request\`** —— 需求行的客户编码有引用检查(客户还没落草稿层,写下去当场被拒); +等人确认完,下一拍再写需求行与核对表,一次写齐。 【写 \`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"、 @@ -364,6 +392,10 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不 brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。 4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。 +**\`created\` 是「这颗料真的建好了」,不是「我打算建」**:只有它在 \`parts.profile\` 里真有一行(件上填好交回来了) +才改成 created —— 门上有一条逐颗对账(建好几颗就得有几行主数据),标了却没建,这一步过不去, +到 S2 写关系时还会被引用检查再拦一次。 + 【P-08 那张确认回来了】这一拍的动作串**恰好两个,顺序不能反**: ① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按 「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在 diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 14b82d5..e86fe0a 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -89,7 +89,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', - packageVersion: '1.2.6', + packageVersion: '1.2.9', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 22e66ed..88b1847 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -857,6 +857,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional { + 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:逐参数四态', () => { it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => { const out = await call('part-substitution-assessment.compare-parameters', { diff --git a/apps/api/src/app/__tests__/view-table-edit.test.ts b/apps/api/src/app/__tests__/view-table-edit.test.ts index d7c4089..e3d2f0b 100644 --- a/apps/api/src/app/__tests__/view-table-edit.test.ts +++ b/apps/api/src/app/__tests__/view-table-edit.test.ts @@ -19,7 +19,7 @@ * 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。 */ 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'; /** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */ @@ -284,6 +284,36 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声 ).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 单张 → 退它那一张;两边都空才真的没有依据', () => { expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']); expect(gateAddressesOf([], [{}])).toEqual([]); diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 047b264..cdd536e 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -794,33 +794,74 @@ async function submitGate( if (task === undefined) return undefined; const manifest = await container.registry.manifestOf(task.activityId).catch(() => 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; const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`)); 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:域.对象`) const mine: Record = {}; for (const draft of await container.ledger.taskDrafts(taskId)) { (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>>, + watched: ReadonlyArray<{ domain: string; object: string; label?: string | undefined }>, + declared: ReadonlyArray, +): { 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( blocks: ReadonlyArray | undefined, deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray | undefined }>, + declared?: ReadonlyArray | undefined, ): string[] { const bound = (blocks ?? []) .filter((b): b is Extract => b.t === 'table' && typeof b.ledger === 'string') .map((b) => b.ledger as string); 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]))]); } diff --git a/apps/api/src/app/tools/first-party.ts b/apps/api/src/app/tools/first-party.ts index 691efbf..2c8fe52 100644 --- a/apps/api/src/app/tools/first-party.ts +++ b/apps/api/src/app/tools/first-party.ts @@ -357,9 +357,20 @@ type ParamPair = { candidateValue: string; unit?: string; 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 }; const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i; @@ -397,9 +408,9 @@ function compareParsed(o: Parsed, c: Parsed): CompareResult { } /** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */ -function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined { - if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined; - const s = requirement.trim(); +function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined { + const s = text(requirement); + if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined; const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s); const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s); const hi = c.kind === 'range' ? c.hi : c.value; @@ -415,8 +426,8 @@ function satisfiesRequirement(requirement: string | undefined, c: Parsed): boole /** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */ const LIFECYCLE_ORDER: Record = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 }; function compareLifecycle(o: string, c: string): CompareResult | undefined { - const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()]; - const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()]; + const ro = LIFECYCLE_ORDER[text(o).toLowerCase()]; + const rc = LIFECYCLE_ORDER[text(c).toLowerCase()]; if (ro === undefined || rc === undefined) return undefined; 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); if (req === false) { // 客户约束不满足:比对结果照实留(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 === '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: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' }; diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 28ece17..3f1aae7 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -1002,7 +1002,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { throw new Error( `${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` + `${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` + - '要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' + + '要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' + '或者把这一笔挂到已存在的那一行名下', ); } @@ -1685,6 +1685,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { ? input.card['steps'].map((x) => String(x)).filter((x) => x.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, { title, basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }], @@ -1693,6 +1702,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { ...(steps.length === 0 ? {} : { steps }), ...(standard === '' ? {} : { standard }), ...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }), + ...(gateLedgers.length === 0 ? {} : { gateLedgers }), }); // 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限 await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index 02be286..ebff5f4 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest'; 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 { PendingAcceptance } from '../port.ts'; import type { CaseModel } from '../internal/domain/cmmn-parse.ts'; @@ -992,7 +992,7 @@ describe('宣告完成', () => { * 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒—— * 不是让模型自己判断,是让基座去核。 */ - it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => { + it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => { const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, @@ -1005,6 +1005,25 @@ describe('宣告完成', () => { 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 () => { const h = harness({ parsed: STAGE_MODEL, @@ -1704,6 +1723,58 @@ describe('办完的项:话要说对,按一下不报错', () => { * 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。 * 三档由 `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('同一张卡再发一遍的三档', () => { const sent = (title: string) => ({ kind: 'card-sent', title }); const replied = (title: string) => ({ kind: 'card-reply', title }); @@ -1717,6 +1788,21 @@ describe('同一张卡再发一遍的三档', () => { 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('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => { const win = [sent('这一问'), replied('这一问')]; expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' }); diff --git a/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts b/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts index fbf35a6..51759bc 100644 --- a/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts +++ b/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts @@ -396,7 +396,10 @@ export function criterionKey(raw: string): string { } /** 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']); /** * 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。 diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index 889849f..e2b78e0 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -210,6 +210,30 @@ function rejectReason(title: string): string { * 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀, * 一模糊就会把「问 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>): 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 { // 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截 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); } +/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */ +export function earlyFinishTitleOf(itemId: string): string { + return `还没到收步的时候,这一下 finish 没生效(${itemId})`; +} + /** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */ export function echoTitleOf(answered: string): string { return `这张卡人已答过,没有再发(「${answered}」)`; @@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string { * 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。 */ export function resendVerdict( - window: ReadonlyArray<{ readonly kind: string; readonly title: string }>, + window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record | undefined }>, 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(); 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)); if (same === undefined) return { kind: 'send' }; 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 | 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 了」绕过去,机检只读基座数得出来的数这条铁律, * 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。 */ - doneWhenOf?: (itemId: string) => boolean | undefined, + doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined, /** * **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。 * 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 —— @@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { if (verdict.kind === 'reject') { throw new Error( `这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` + - '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' + - '照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。', + (verdict.chose === undefined + ? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' + : `**他选的是「${verdict.chose}」** —— `) + + '照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' + + '再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。', ); } await deps.memory.append({ @@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { title: echoTitle, detail: { 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); - if (doneWhenNow === false) { - throw new Error( + if (doneWhenNow?.ok === false) { + const say = `${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` + - '不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' + - '(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' + - '基座会自己收,不必你再 finish。', - ); + '不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' + + doneWhenNow.why; + /* + * **早收一次不算撞墙**(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); await deps.memory.append({ @@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { : [ `【当前这一步 · ${current.item}】${current.intent}`, currentTouch, + answeredCardFact(trail), current.guide, current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`, current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`, @@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { const step = stepOf(itemId); if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined; 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 { // 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦 return undefined; diff --git a/docs/ACT-WORK-05-端到端测试记录.md b/docs/ACT-WORK-05-端到端测试记录.md index 1231f37..9043f01 100644 --- a/docs/ACT-WORK-05-端到端测试记录.md +++ b/docs/ACT-WORK-05-端到端测试记录.md @@ -3,7 +3,9 @@ > 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。 > 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。 > 台账字段口径见 `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.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`):判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步 | 活动(结构) | | J | 数据工单派给了在库的那颗,缺的那颗没人建 | 剧本把「建哪几颗」与「要不要问人」写在同一串分岔里,从没写过「found 的不建」 | 拆成两个问题分别作答,并写明后果 | 活动 | | 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`): @@ -199,6 +226,9 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回 2. 拒回文案只谈行与列,数不出「缺的是一次工具调用」(发现 G); 3. 执行工单绑的必交付台账**一行都没有**时提交闸无牙 —— 回落只数「已有行缺不缺必填」,零行 = 零缺(发现 H)。 +发现 L 是这三条里**唯一当场治了平台的**:闸看的表与件上摆的表不是同一张,闸就等于没有 —— +而这件事只有脚本 e2e 逮得到(它每张卡都先空手交一次;人手点的时候会先把格子填好再按提交)。 + **还有一条**(平台侧,已记 debt):`disable` 这条路从来没通过(发现 A)—— `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,值与业务一致; - **只读边界守住**:`customer_approval_status` 每单都留空(本活动不产生),`rule_version` 随结论落账; - 出口只有一口 `assessment-released`,每单都真发射了。 + +4. **两条线逮到的缺陷不重合,缺一条就漏一半。** + 浏览器逮的是**人看见什么、点得动什么**(件、闸、徽标、翻页、画面切换、快照 1:1); + 脚本逮的是**机制**(空手交卷、循环、崩、归属)。发现 L–S 八条没有一条是浏览器走查逮到的 —— + 因为人手点的时候会先把格子填好再按提交,永远不会去试「空着交上去会怎样」。 + +5. **拒回喂事实,不喂指针。** + 发现 N 那一轮把「他选的是 approve、下一步写哪面旗」写进拒回,日志里逐字可见,模型照发不误 —— + 因为**下一拍它又从头翻一次会滚走的窗口**。把同一件事做成「每拍都在的事实行」之后,一次就好了。 + 同一条道理已经在 worldTouched(2026-08-21)、E04 的 worldTouched 事实行(2026-08-24)上各现过一次。 diff --git a/docs/活动开发规范.md b/docs/活动开发规范.md index 9d7ecdd..89fcdfb 100644 --- a/docs/活动开发规范.md +++ b/docs/活动开发规范.md @@ -165,7 +165,7 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in | 门 | 在哪 | 规则 | |---|---|---| | **起单宽进** | `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 地址数本单草稿行 | | **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) | | **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) | @@ -252,7 +252,7 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in | `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 | | `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 | | `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}` | 立监听,挂起;时限必配 | | `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 | | `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 | diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts index e916918..de59ac3 100644 --- a/packages/contracts/src/card.ts +++ b/packages/contracts/src/card.ts @@ -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 .object({ /** 能交了吗 */ diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 418f80f..230cc91 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -31,6 +31,17 @@ const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, ''); const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp'; const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? ''; 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 */ const TOKEN_REFRESH_MS = 4 * 60 * 1000; @@ -758,7 +769,8 @@ let bearer = ''; let bearerAt = 0; 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}`, { ...init, headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) }, @@ -779,8 +791,14 @@ async function api(path, init = {}) { } 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 === '') { - 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`, { method: 'POST',