fix(rectification): enforce trusted result identity and preserve receipt provenance
Unify minute and block cache identity, keep unverifiable historical results read-only across server tools and write entrypoints, and aggregate completed receipt sources chronologically through a compatible function migration. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -1,6 +1,13 @@
|
||||
# BLOCKED
|
||||
|
||||
## BUG-984 补单:F2 混合成功身份实证触发 SQL 授权停点(2026-09-20)
|
||||
## BUG-984:受控 staging 与真人验收尚未完成(2026-09-20)
|
||||
|
||||
- 本 agent 不调用线上、不读取凭据;没有本轮受控账号/浏览器会话证据。自动化仅使用明确虚构 fixture 与本地 Docker PostgreSQL,不能代替发布后登录态验收。
|
||||
- 清单:`docs/testing/rectification-cross-midnight-fix-20260920.md`。推送、独立验收及部署由主会话负责,BUG-984 暂不标 resolved。
|
||||
|
||||
## ~~BUG-984 补单:F2 混合成功身份实证触发 SQL 授权停点(2026-09-20)~~
|
||||
|
||||
- **已解除**:产品明确授权继续 B;本轮新增 `20260920010000_rectification_receipt_result_identity.sql`,只替换目标函数身份 SELECT,保留权限、owner/turn/attempt 与重试语义,不改表结构、已应用迁移或历史行。以下保留授权前停点事实。
|
||||
|
||||
- A 已局部实现:started/failed 不写算法身份,completed 只用实际结果来源。真实工具 + 原生 golden 的同 turn compare/diagnostics 组合可产生 scoring-9、scoring-10 两条成功回执;字符串 max 返回 scoring-9,不是最近成功 scoring-10。该测试证明缺陷仍存在,不是聚合修复通过。
|
||||
- 既有 RPC 输出没有各 completed 的来源身份;历史 fingerprint 也不能还原。按补单须升 B(兼容函数体迁移),但当前执行授权禁止自行迁移,已停止进一步业务实施并报告。F1/F3/F4 pending;未改 SQL/UI/缓存、未推送。见 `docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md`。
|
||||
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-20 — 校正开始与失败回执不再冒充结果来源(局部实现,未发布)
|
||||
## 2026-09-20 — 校正旧结果保留真实来源,无法核实时只读(未发布)
|
||||
|
||||
分钟与时段缓存统一核验算法、策略和输入指纹;无法取得可信当前身份时保留旧结果,提示「按旧算法产出」,不强制重算。候选交付、选择、采用和确认由服务端重新检查,历史仍按绑定 Skill 打开。新算响应与当前身份不一致也保留真实来源并只读;成功回执聚合改取选中 attempt 最近的 completed 来源,不再取版本字符串最大值。只新增兼容函数体迁移,不修改历史行或表结构。Skill 版本、评分和确认门不变;部署与真人验收另行完成。
|
||||
|
||||
## 2026-09-20 — 校正开始与失败回执不再冒充结果来源(局部实现历史记录)
|
||||
|
||||
compare/diagnostics 开始或失败时不写算法身份,成功时只记录实际结果来源,不回退部署默认值。同轮多个成功结果仍可能被旧 SQL 字符串聚合选错,本轮因此停在迁移授权边界;缓存只读策略尚未实施,不宣称 BUG-984 已修。Skill 版本不变。
|
||||
|
||||
|
||||
+4
-1
@@ -13015,7 +13015,10 @@
|
||||
- 相关记录:BUG-427、BUG-621、BUG-981。
|
||||
- 复发自:未发现同症状既有记录;既有分钟算法身份缓存门不覆盖提前返回的时段分支,回执测试未组合新版 started 与旧缓存 completed。
|
||||
- F2 补充实测:原生虚构输入 golden 经真实 compare/diagnostics 工具顺序执行,仅替换算法标签模拟滚动版本;同一 turn 可写 completed scoring-9 与 scoring-10,而字符串 max 为 scoring-9。既有聚合 RPC 不暴露各成功行身份,历史 fingerprint 无法还原,应用层 A 不足以闭环。定向身份 9/9(含缺陷诊断)、tsc 0;五文件回归 61 项中 57 pass / 4 Windows symlink EPERM,未冒称 DB 聚合已验。
|
||||
- 修复版本:`codex/rectification-cross-midnight-fix-20260920` 的 F2 A 局部实现,B 授权阻塞、未推送;缓存与聚合整体验收未通过。
|
||||
- 授权恢复与修复:产品明确授权 B 后,新增兼容函数体迁移,仅将身份聚合改为选中成功/最新 attempt 的 completed 非空来源、按时间/id稳定排序;表/已应用迁移/历史行与权限不变。分钟/时段统一完整算法+策略pair与输入指纹;未知身份保留旧值只读且不强制重算,服务端采用/确认/候选选择独立重查。历史GET/刷新和工具均标注;新算响应与当前pair不一致也只读。可信当前版本已知的stale历史保留显式「重新比较」,未知身份没有入口,避免只读锁死升级。以上替代授权前局部状态,前文保留事故与停点历史。
|
||||
- 恢复验证:真实native虚构golden的minute/block旧缓存重算及当前缓存复用、部分env冲突/接口超时与故障、缺source/非最新result、历史read→compare路径均回归;F1先红4项后绿。B真实PostgreSQL先红8≠7后绿,标准DB40/40(无skip);Python日期/bridge/memoization32/32。Linux完整基线3575/3575→最终diagnostics补丁快照3588/3588(0fail/skip),首页Static、完整28资源gzip+0.040085%,tsc/lint0error;主会话独立DB40/40、最终定向40/40+tsc通过。不提前算部署通过。
|
||||
- diagnostics追加复核:旧failure回归未调用诊断工具,新增调用先得16绿/6红,确认未知身份仍会重计算;现未知身份+旧结果仅返回只读来源/notice,不执行诊断或方法,不允许分钟确认。可信身份下诊断新响应pair不一致也只读、确认false,成功来源仍是真实9/10;修后身份22/22、身份+spoken37/37;改后全量3588/3588与build已新快照复跑通过,旧delivery证据另外保留,主会话也已独立40/40及tsc0。
|
||||
- 修复版本:`codex/rectification-cross-midnight-fix-20260920` 本地实现,未推送;前置 `3f39bafc` 已由主会话确认run2819及web/API双SHA部署,本补丁仍待独立验收、发布与受控真人走查,保持 investigating。
|
||||
|
||||
## BUG-985 | 同日不变性回归写死浮点分数哈希导致跨机门禁失败
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
|
||||
|
||||
## 2026-09-20 · BUG-984 Linux 标准复验与保真归档
|
||||
|
||||
- B 经产品明确授权后新增兼容函数体迁移;此前停点仍保留。Windows checkout 将 migration symlink 变成普通文本,标准扫描报 duplicate filename;Skill 注册表另有 symlink EPERM。不要改迁移扫描器或弱化安全断言,用 `git archive` 的真实 symlink 在 Linux 复验。
|
||||
- 首轮 Linux 基线 3575 项中 4 项失败来自归档无 Git index、缺 PyYAML、缺 rsync。补工具,并用源树 `git ls-files -z` 精确恢复 tracked inventory(含原本已 tracked 但现在被 ignore 的文件,不能 `git add .` 收进日志),基线 3575/3575。临时仓只有 index,不伪称完整原 Git 历史。
|
||||
- 覆盖 `/repo` 基线的同步命令被权限系统拒绝后未重试覆盖;改为创建全新快照目录,基线、失败轮和日志均保留。Docker `bug984-runner`、DinD `bug984-docker`、volume `bug984-validation` 留给独立复跑,不清理。
|
||||
|
||||
## 2026-09-20 · BUG-984 补单前置解除与聚合授权停点
|
||||
|
||||
- 前轮写认证 blocker 已由主会话解除:前置代码 `3f39bafc` 已推 staging 并核对;本执行树 fetch 到后续纯文档 `f09f3d80`。下面旧记录保留为历史,不再作为当前开工 blocker。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 结论与授权边界
|
||||
|
||||
**blocked:F2 方案 A 已完成局部实现,实测证实同一 turn 可出现不同身份的多个 completed,按补单须升级 B;未获 SQL 迁移授权,依指令停止进一步业务实施。F1 / F3 / F4 未完成,不可交付为 BUG-984 修复。**
|
||||
**本地实现与自动化完成:F1/F2/F3 已实现,diagnostics追加修复后的新快照全量3588/3588、定向37/37、tsc/lint通过;标准DB40/40,首页Static、完整28资源gzip+0.040085%。主会话独立DB40/40、diagnostics最终定向40/40+tsc通过。未推送、未部署,受控真人验收仍缺口,BUG-984保持investigating。** 以下保留授权前记录,最新状态以文末「授权恢复」为准。
|
||||
|
||||
- 执行树:`.worktrees/rectification-cross-midnight-fix-20260920`,分支 `codex/rectification-cross-midnight-fix-20260920`。
|
||||
- 前置代码基线:`3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`。主会话已成功推 staging 并核对 ls-remote,**前轮 Gitea 写认证 blocker 已解除**;保留旧失败历史,不把它当本轮阻塞。
|
||||
@@ -89,3 +89,66 @@
|
||||
- 独立实跑身份文件 9 / 9 通过;BUG-621 历史打开与 case-service 两文件 29 / 29 通过;`tsc --noEmit` 退出 0;前置 Python 跨午夜 bridge 9 / 9 通过。混版本项只证明缺陷可达,未执行数据库聚合验收。
|
||||
- 前置 Gitea run2819(SHA `3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`)的 validate 已 success,最后查询整轮仍 in_progress。staging `/login` 200、匿名 `/api/account` 401;健康响应的 web/API 部署均仍 `539d4daee4d0065f5ef3b974903ea8d639a249c6`,所以前置部署尚未确认完成。这些匿名检查不能代替缓存身份受控会话验收。
|
||||
- 局部实现未推送;按任务书迁移授权停点报告,F1/F3/F4 保持 pending。
|
||||
|
||||
## 授权恢复与最终实现(2026-09-20)
|
||||
|
||||
产品明确回复继续并授权 B:只新增向后兼容函数体迁移、仅修回执聚合,不改表结构/已应用迁移/历史行,保留 owner/turn/attempt 与重试语义,真跑标准 test:db。恢复时 HEAD `6dd62207eb1ab04aaac8f6755146980b7abac8b2`。原停点是正确执行历史,不删除或改写。
|
||||
|
||||
主会话补报前置 run2819 completed/success,staging health 的 deployment.gitCommit 与 apiGitCommit 均等于 `3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`。前置部署确认完成;本 agent 未访问线上,这不是本补丁受控身份验收。
|
||||
|
||||
### F1 / F2 / F3
|
||||
|
||||
- minute 与 block_scan 共用算法+策略完整 pair、证据指纹、范围/基线资料指纹;完整 env 保留明确覆盖语义,部分 env 补查 native pair、冲突 fail closed,不拿前端默认常量冒充部署覆盖。时段来源 policy/range 指纹只写新 JSON,不回填历史。
|
||||
- 真实原生虚构输入 golden 经 `normalize_rectification_request`→`api_service.block_scan` 生成;旧 -7/当前 -8 minute 和 late-night 缓存均实跑应用链,旧重算、当前命中。F1 红测 11 项中 4 红(partial/unknown/旧block/当前block未探测),修后全绿。
|
||||
- B 迁移 `20260920010000_rectification_receipt_result_identity.sql` 与旧函数除注释外仅身份 SELECT 不同:选中 successful attempt,否则 latest attempt;仅 completed 非空身份,started_at DESC、id DESC。真实 PostgreSQL 先红(8≠7)后绿,覆盖9/10、失败重试、同时间稳定排序、历史不重标与owner/turn/权限。
|
||||
- 未知当前身份:旧结果只读、cached=false、来源不重标,不重算、不跑 oracle、不写 focus/inference/候选/验证;历史 GET/刷新、read-case、compare 同样显示「按旧算法产出」。新算响应与可信 pair 不同也保存真实来源并只读。
|
||||
- 服务端写门覆盖 API direct accept、service accept/confirm、offer候选、选择/推断、block推进/拒绝、widen、tie-break、focus denial及闲置/出口修复。重查 dossier+当前身份,不信客户端flag;stage/source交叉缺失、非最新resultId、无source具体采用一律拒绝,正常空Case收集不误拦。无生产引用的旧 session.ts helper未扩改。
|
||||
- 独立review发现 stale 与 unknown 同锁composer会造成旧结果无法升级,已修:旧候选仍不可写,但可信当前pair已知时、非终止Case可点「重新比较」,复用既有message入口;未知身份不显示,不自动重算。测试覆盖历史投影→read-case提示compare→实际compare新来源,以及按钮handler的stale/unknown/terminal/busy派发边界。
|
||||
- 独立review再发现 diagnostics 未经过身份门,原六种failure测试没有调用该工具。补调用后22项16绿/6红,证明未知身份仍请求了diagnostics;现入口在读取compute/诊断前短路:旧来源+notice/read_only、diagnostics=null、can_confirm_exact_minute=false、executed_methods=[],无source则engine_identity_unavailable。可信身份下仍独立诊断,新响应pair不匹配只读且不能确认,completed保持实际来源,9/10聚合用例保留;修后22/22。
|
||||
- 不改Skill、input contract、评分/确认门、历史打开绑定,不删除或重标历史缓存;UI同步DESIGN,无Home状态增长。
|
||||
|
||||
### F4 证据与复跑环境
|
||||
|
||||
| 检查 | 已完成结果 |
|
||||
| --- | --- |
|
||||
| Linux基线全量(HEAD6dd62207应用代码) | 3575/3575,fail/skip=0 |
|
||||
| 身份及历史重算定向 | 22项;含minute/block、无/完整/部分env、冲突、接口异常/超时、来源缺失与滚动响应 |
|
||||
| 最终四文件业务定向(加入stale测试之前) | 112/112 |
|
||||
| Python日期+bridge+memoization | 32/32;bridge重复收集已知,不称32个独立用例 |
|
||||
| 标准 `npm run test:db` | 执行方40/40,主会话独立40/40(304634ms),fail/skip=0,真实PostgreSQL17;独立日志 `<临时目录>/bug984-independent-db.log` |
|
||||
| 中间完整修复轮 | 3587项,3586通过/1源码断言失败:终止提示改为historyReadonly,已附三栏同步 |
|
||||
| 中间build | 首页 `┌ ○ /`;25个首页直链JS/CSS gzip 655491→655584B,+93B/+0.0142% |
|
||||
| 保留的旧delivery全量 | 3588/3588,fail/skip/cancelled=0,307886ms;不替代追加diagnostics后的复跑 |
|
||||
| 最终diagnostics全量 | 3588/3588,fail/skip/cancelled=0,259596ms;比基线3575增加13项 |
|
||||
| 最终diagnostics build/gzip | `┌ ○ /` Static;完整28资源666078→666345B,+267B/+0.040085%,在±2%内。读取index.html全部src/href的/_next/ JS/CSS,去query后去重,各文件gzipSync默认参数求和;旧25资源655491→655758B为遗漏3资源的局部统计,由本完整口径替代 |
|
||||
| 最终diagnostics/按钮/身份/spoken定向 | 37/37;含22项身份、六种故障的diagnostics调用、实际handler派发与历史read→compare行为 |
|
||||
| tsc/lint | 最终diagnostics后退出0;lint 0 error/119 warning,不顺修warnings |
|
||||
| BUG-621及最终独立补验 | 主会话先前43/43;身份+spoken(含按钮handler)+BUG-621独立40/40、tsc退出0;日志 `<临时目录>/bug984-independent-final-targeted.log`;diagnostics补丁后再次独立40/40且tsc0 |
|
||||
| 隐私门 | 明确stage全部37个本单文件(含新增fixture/SQL)后 `python -m pytest tests/test_repo_privacy_markers.py`:62/62;删除文档内两处HOME_PREFIX,日志以临时目录占位符保留 |
|
||||
| 本轮部署/真人 | 未进行,BLOCKED与真人清单保留 |
|
||||
|
||||
Linux保真方式:`git archive HEAD` 保留真实symlink,增量文件在打包时替换;从不将Windows整个checkout覆到Linux。首轮基线3575/3571/4,失败为缺Git index/PyYAML/rsync;装备后3575全绿。源树`git ls-files -z`精确建立tracked index,临时repo无完整历史。基线目录带新增B迁移与原DB文件新增断言,但应用源码是HEAD;DB仍同一个test,应用基线总数不变。
|
||||
|
||||
- 保留 Docker runner `bug984-runner`(node22-bookworm)、DinD `bug984-docker`、volume `bug984-validation`;无线上数据/凭据。
|
||||
- 基线 `/repo/frontend`;中间 `/repo/bug984-fixed/frontend`、`/repo/bug984-final/frontend`;最终 `/repo/bug984-delivery/frontend`。这些是容器内路径,主会话复验不要用Windows主检出路径。
|
||||
- diagnostics追加修复使用新快照 `/repo/bug984-diagnostics/frontend`,旧delivery与其证据不覆盖;最终30个修改/新增frontend文件在stage前与新快照逐文件SHA256相同(0差异),含SQL与DB测试;stage后diff-check发现新增SQL/golden的CRLF,已仅转为LF(内容/JSON/SQL语义不变),最终字节比较28相同、这2项去CRLF后相同。快照文档为创建时点;之后只补最终数字/独立证据,不影响源码测试。新增三文件也按明确清单加入临时tracked index。
|
||||
- 标准复跑:`MSYS_NO_PATHCONV=1 docker exec -w /repo/bug984-diagnostics/frontend bug984-runner npm test`;同命令末尾换 `npm run test:db`、`npm run build`、`./node_modules/.bin/tsc --noEmit`、`npm run lint`。SQL和DB测试在diagnostics补丁中未改,沿用两方实际40/40,不伪称重复跑DB。
|
||||
- 日志(volume内绝对路径):`/repo/baseline-equipped-tests.log`、`/repo/baseline-build.log`、`/repo/baseline-gzip.json`;`/repo/fixed-standard-db.log`(标准40/40);`/repo/fixed-tests.log`、`/repo/final-tests.log`(中间失败留存);旧delivery `/repo/delivery-tests.log`、`/repo/delivery-build.log`、`/repo/delivery-gzip.json`;最终 `/repo/diagnostics-tests.log`、`/repo/diagnostics-targeted.log`、`/repo/diagnostics-lint.log`、`/repo/diagnostics-build.log`、`/repo/diagnostics-gzip.json`。
|
||||
- 曾同步覆盖基线被权限系统拒绝,未重试覆盖,改为创建全新目录;首次final build抢在依赖copy完成前报next not found,等待copy完成再建成功。没有改业务/依赖声明来规避平台问题。
|
||||
|
||||
### 本轮既有断言/fixture调整(三栏)
|
||||
|
||||
| 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 部分policy env返回algorithm null、旧cache可用、0fetch | native完整pair、旧cache不可用、1fetch | 部分身份不能绕过算法核验;完整pair仍0额外fetch |
|
||||
| 版本接口失败旧cache reusable=true | false并只读 | 策略b,保留显示不等于当前缓存命中 |
|
||||
| 业务test-support无显式部署身份 | 与其fixture一致的rectification-v5/policy-v2完整env | 正常业务回归有受控可信身份,故障测试单独清env;不放宽生产 |
|
||||
| block业务fixture algorithm=test、无policy | 与受控env一致的v5/policy-v2 | 不改blocks/分数/选择预期,仅补真实身份语义 |
|
||||
| 两个业务score替身algorithm误用event-contract-v2 | rectification-v5,event_contract_version保持原值 | 与test-support可信部署一致,不把滚动不一致误算正常业务 |
|
||||
| 直接accept/confirm及collect替身没有dossier重读RPC | 补原candidate/dossier fixture | 新服务端写门必须重读,权限/确认/问题预期不变 |
|
||||
| native policy滞后测试继承fixture env | 测试内清pair并恢复 | 必须真实走versions替身,不被完整env覆盖 |
|
||||
| 无source采用返回candidate_not_found | RPC前result_identity_read_only | 提前拒绝不可核验具体结果,不能产生候选 |
|
||||
| question/current_question/choice_card无只读分支,GET同步projection | 只读null,GET await可信projection | 历史直接刷新同样受保护,非删除断言 |
|
||||
| 终止UI源码readonly && | historyReadonly && | 身份不可核验不等于Case已结束;不诱导强制新建 |
|
||||
|
||||
新增源形合同全部采用真实native golden;既有业务替身只是补身份或RPC,不声称为新合同golden。所有示例明确虚构,未使用用户账号或资料。
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
| `TASK-rectification-validation-integrity-20260920.md` | `PROGRESS-rectification-validation-20260920.md` | **生时校正验证体系补缺(纯离线评测,不改打分不改产品)**:会议要求把「推断真实出生时间」与「用户认可的参考盘」分开证明。核对结论——**产品口径侧四条已落地**(`accepted`≠`confirmed` 两条写入路径、确认门 fail-closed 且 `holdout` 为 `not_ready` 使 `confirmation_allowed` 不可能为真、采用不写 `reported_birth_time`、无任何把采用率当准确率的指标;运行时也无按生日走捷径的分支);**缺口全在评测本身**。三条:① 封存契约 `rectification_sealed_holdout.v1.json` 的三个打分哈希互不相同(封存 `f41c298d` / 契约记录 `99730c84` / 基线实测 `b15d9ea1`),`official_eval_trial_count: 0`——当前实现**从未产出过一次有效官方盲测**,唯一跑过那次已被资料审计作废(top-1 `0.15`,发布门要 `0.60`),可见的 `0.45` 自带「不得当发布指标」标记(BUG-978);② 全部离线评测的候选窗**以真值为圆心**(`_candidate_moments()`、`request_from_case()` 的 `true_time`),生产以申报时间为圆心(`ENGINE_SEARCH_RADIUS_MINUTES = 15`)——「真值掉出窗外」这一失败模式从不可见(BUG-979);③ v3 封存但每例仅 3 事件、v4 有 7+ 事件却已被看过并用于调参,**无口径干净又贴近真实会话的封存集**;六题回放的 `0.80/0.55/0.35` 是「真值方向最优答」的上帝视角上界,±30/±60 仍低于发布门(BUG-980)。T1 申报偏差敏感性 sweep、T2 有效重跑 + 契约对齐 + **防复发新测试**、T3 只出 v5 采集协议、T4 记录。**硬红线:不得改 12 个打分文件、不得用封存集调参、不得把 `status` 改 `ready`。** 家庭信息(父母职业/兄弟姐妹)在拿到基线数字前不开工——现有七领域全是带日期事件,静态属性没有输入口。前置:owner-case-purge 三提交仍未合入 staging。BUG-978~980 | **已验收通过(2026-09-20,Claude 独立复算)**;独立盲测仍 blocked;门禁/部署待核验 | `932f2fff`。Claude 独立复核:T1 完整重跑 900 组合**逐例 8 字段 0 差异**、summary/specification 全等,另从 900 条逐例重算 45 格表与发布表逐位相同;T2 独立复跑 0.45/0.50/6.45/0/1.0/0.0 与哈希全序 0.05/0.10 全部一致;12 个打分文件字节未动、`status=not_ready`、`confirmation_coverage_rate=0`、实跑 `holdout_passed()`=False;新增/触及 Python 测试本机 38 条全绿;freshness 测试比对时排除 `frozen_at_utc`(BUG-693/694 教训已用上)。**执行方正确推翻任务书 T2.3**:「首次口径干净的官方盲测」与 BUG-428 防复发(已看过结果的案例不得再计入盲测)冲突,已核 BUG-428 原文,是任务书写错。环境缺口:本机 frontend 无 node_modules,tsc/lint/npm test/build 未能复核,采信执行方自报。遗留 → `TASK-rectification-cross-midnight-dasha-20260920.md`(BUG-981) |
|
||||
| `TASK-rectification-cross-midnight-dasha-20260920.md` | — | **跨午夜候选的 Dasha 边界错一天(生产打分)**:`scoring_service.py` 调 `merge_transition_proximity()` 只传**一个** `birth_date`,该函数用它算全部候选的 Vimshottari / Narayana 起始日期,候选之间只靠 `_context_time()` 的 `HH:MM` 区分、**日期被丢掉**。窗口跨午夜时午夜后候选的 dasha 边界整体错一天。Claude 验收时在生产调用链独立复现:窗口 `23:50→00:10`、21 个候选,**恰好那 11 个跨日候选分数错、10 个同日候选逐位相同**(幅度 +0.0267 / −0.0133,本例头名未变)。算术上界 = `cap/kernel_width`,day 精度 0.067 分/件,18 件可累计约 1.2 分,而随分钟变化项总量仅约 2.1 分 —— **上界是推的不是实测,真实幅度本单必须实测**。踩中路径:`late_night` 时段 `23:00–03:59`、`unknown` `00:00–23:59`、23:45 后或 00:15 前申报的 ±15 窗。连带发现:`calculation_spec()` 不含打分实现身份,修复后同一 spec hash 对应不同分数,历史 Case 静默失去可复现性(同 BUG-427 类型)。**产品 2026-09-20 已就三点拍板:A 修、B 修完重新冻结并重跑 T1/T2、C 让新旧结果可区分。** C 的做法经查证已修正:`calculation_spec_hash` 全在 **V4** 链路、**V9 零引用**,原提案 bump `INPUT_CONTRACT_VERSION` 对真实历史无效已作废;改为随修复 bump `engine_version`(`v9EngineVersion()` 缺省串用于 started/failed;成功回执及 minute 缓存身份来自后端,旧「只写不比」假设作废),**不得动 `skill_version`**(BUG-621:open RPC 要求绑定 Skill 等于当前版本,bump 会让历史校正打不开)。硬红线:只改「按候选日期取 dasha 起始」,不得动 kernel/cap/share 任一常数;确认门不变。BUG-981 | **核心修复与 BUG-985 已 review 通过,本地合并 b27d4de9;推 staging 认证失败,端到端仍受 BUG-984 阻塞** | 实现 `aa46da10`(分支 `codex/rectification-cross-midnight-20260920`,未合入 staging)。Claude 独立 review:修复后整窗打分与**逐候选独立重算 21/21 一致**;非跨午夜分数基线 vs 修复**逐位不变**(我测半径 10 六例 + 半径 60 三例,执行方证据覆盖 19 例 / 2299 候选全部 `bytes_equal`);kernel/cap/share/`PRECISION_WEIGHTS` 一个未动;`status=not_ready`、coverage 0、`holdout_passed()` False、官方试次 0;决策 C 执行正确(`ALGORITHM_VERSION` 7→8,`INPUT_CONTRACT_VERSION` 与 `skill_version` 未动,golden 里 `calculation_spec_hash` 不变可证);既有断言改动均带三栏说明且为加强。**P1 阻塞**:新增的 `test_same_day_public_aa_scores_keep_pre_fix_bytes` 写死 121 个浮点分数的 SHA-256,在 Claude 机器上 ordinal 2/3 红、**基线与修复分支同样红**(非修复所致),且经 `test_rectification_cross_midnight_gate.py` re-export 落入快速门 glob —— 同 glob 基线 214 passed/0 failed、`aa46da10` 4 failed,推 staging 会让门禁红。执行方自报未通过的 BUG-984 经独立确认成立且更重:`block_scan` 只比 evidence 指纹不读打分身份,而 `late_night`(299 min)/`unknown`(1439 min) 均 >120 走该分支、又恰是最易跨午夜的窗口 |
|
||||
| `TASK-rectification-cross-midnight-gate-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-gate-fix-20260920.md` | **review 修复单:门禁级浮点哈希断言(`aa46da10` 的合入阻塞项)**:新增回归把 121 个分数的 SHA-256 写死成字面量,于是断言范围把跨机 libm/pyswisseph 差异也圈了进去。实测该组 121 个分数**全无浮点尾噪**(`repr(s)==repr(round(s,4))` 121/121),所以差异是第 4 位真的不同,靠改序列化消不掉。同一提交刚改过的 `test_rectification_engine_memoization.py` docstring 正好写着「跨机舍入已漂 1.1e-3,不得整体 `==` 比较」——教训被引用了又被踩。执行方自己的证据 JSON 用的却是正确做法(`scores_bytes_equal` = 同机基线 vs 当前)。**根因**:要证的是相对不变量(同机基线 vs 当前),却实现成绝对不变量(当前 vs 某台机器的历史哈希)。F1 改用生产代码已有的 legacy 回退路径做同机 A/B 对照(去掉 `candidate_at` 即修复前行为),验收要求**两台浮点环境不同的机器各跑一次**、且回退核心修复后该测试仍绿(证明它守的是同日不变性而非跨午夜回归的替身);F2 bridge 重复收集(4 failed = 2×2);F3 查证 `block_scan` 重算是否经过被修 helper —— 会则 BUG-981 在 late_night/unknown 路径等于没上线、BUG-984 升为阻塞项;F4 记录。**硬红线:不得删测试/skip/把哈希改成本机当前值消红,不得改已验收的打分代码。** 串行:本单 → `aa46da10` 合入 → BUG-984 补单。BUG-985 | **已 review 通过;BUG-985 resolved;合入推送被 Gitea 认证阻塞,远端仍 a3577ce2** | 实现 `25232ce4`(分支 `codex/rectification-cross-midnight-gate-fix-20260920`)。F1 改为同进程 A/B:在 `scoring_service.merge_transition_proximity` 调用边界剥掉 `candidate_at` 走生产既有 legacy 回退,对比 121 个分数与整份贡献矩阵的 canonical 字节;另加 `legacy_calls == [121]` 与 `static_contexts is contexts` 两道防空转保险。Claude 复核:写死哈希字面量 **0 残留**;**第三套环境(Linux + Python 3.13,与执行方 Windows 3.11.7 不同)定向 18 条全绿** → 两机验收闭环;**回退日期修复探针:同日 3 条全绿、跨午夜相关 4 条转红**,证明同日不变性与跨午夜正确性已真正分开;快速门 glob staging `1b646659` 200 passed/0 failed → `25232ce4` 216 passed/0 failed(+16,零新增失败,总数未降);相对合并点仅动 2 个测试文件 + 文档,`scripts/`、前端、golden、打分常数零改动。BUG-985 记录含我要求的防复发条,并正确认定为 BUG-733 同形复发。**建议 BUG-985 由 `blocked` 改 `resolved`**(证据即第三环境复跑)。环境缺口:完整快速门在 Claude 机器 120 秒超时被杀,6→4 那组数以执行方记录为准 |
|
||||
| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**;`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:00–03:59) 跨日;`unknown`(00:00–23:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。A=started/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **blocked:F2 A 局部完成,多 completed 混版本实测成立,等待 B 的 SQL 授权;F1/F3/F4 pending** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 |
|
||||
| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**;`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:00–03:59) 跨日;`unknown`(00:00–23:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。A=started/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **本地完成:diagnostics追加修复后全量3575→3588全绿,标准DB40/40,Static/完整28资源gzip+0.040085%,tsc/lint通过;主会话独立DB40与最终定向40全绿。未推送,真人/部署待主会话** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 |
|
||||
|
||||
## 命名与归档
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# BUG-984 补单验收清单(2026-09-20)
|
||||
|
||||
当前 **blocked**:F2 A 局部实现,多 completed 混版本实测触发 B,SQL 未获授权;F1/F3 尚未实现。以下不是已通过清单,不能将本分支当完整修复部署。
|
||||
B授权停点已解除,F1/F2/F3本地实现;标准DB40/40,完整结果以PROGRESS最终记录为准。下面区分自动化与尚未完成的受控staging真人验收,不能把本地通过等同已部署。
|
||||
|
||||
## 自动化续验
|
||||
|
||||
- [ ] 获准 B 后新增兼容函数体迁移,不修改旧迁移,标准 `npm run test:db` 实跑。
|
||||
- [ ] 同一成功 attempt 的 started -8 / completed -7 只展示 -7;历史行字节不变。
|
||||
- [ ] 同 turn compare / diagnostics 多 completed -9 / -10 不按字符串序选 9;明确取最新成功的稳定排序与各工具来源语义。
|
||||
- [ ] 失败后重试、成功后失败、不同 attempt、历史未带新 metadata 都覆盖,不能用当前 dossier 身份替代历史来源。
|
||||
- [ ] minute / late-night block_scan 均用真实 golden 测同身份命中、旧身份重算;未知当前身份时旧缓存只读、不强制重算。
|
||||
- [ ] 无覆盖、完整覆盖、部分覆盖、版本接口超时/错误均区分当前身份与来源。
|
||||
- [ ] 不经界面直接调所有采用/确认/候选选择写入口,旧只读结果均由服务端拒绝。
|
||||
- [ ] `BUG-621` 历史按绑定 Skill 打开回归;不增加 engineVersion 打开相等门。
|
||||
- [ ] tsc、lint、定向、全量、build `/` Static、首屏 gzip ±2%;实际平台失败与基线逐项对照,不削弱断言。
|
||||
- [x] 获准 B 后新增兼容函数体迁移,不修改旧迁移,标准 `npm run test:db` 实跑40/40。
|
||||
- [x] 同一成功 attempt 的 started -8 / completed -7 只展示 -7;历史行字节不变。
|
||||
- [x] 同 turn compare / diagnostics 多 completed -9 / -10 不按字符串序选 9;明确取最新成功的稳定排序与各工具来源语义。
|
||||
- [x] 成功后失败重试、不同 attempt、历史未带新 metadata、latest failed-only均覆盖,不能用当前 dossier 身份替代历史来源。
|
||||
- [x] minute / late-night block_scan 均用真实 golden 测同身份命中、旧身份重算;未知当前身份时旧缓存只读、不强制重算。
|
||||
- [x] 无覆盖、完整覆盖、部分覆盖、版本接口超时/错误均区分当前身份与来源。
|
||||
- [x] 服务层采用/确认/推进、工具offer与compare/read/diagnostics、idle/repair行为回归,旧只读结果由服务端拒绝;真实HTTP伪造请求与浏览器交互留真人栏。
|
||||
- [x] `BUG-621` 历史按绑定 Skill 打开回归;不增加 engineVersion 打开相等门。
|
||||
- [x] diagnostics追加修复后tsc/lint0error、最终定向37/37、新快照全量3588/3588(基线3575/3575)、build `/` Static、完整28个首屏JS/CSS gzip+0.040085%;Windows平台问题以保真Linux补验,不削弱断言。
|
||||
|
||||
## 受控 staging 真人验收(由主会话安排)
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
- [ ] 先核对部署 gitCommit(允许其后纯文档差异),不是只看 HTTP 200。
|
||||
- [ ] 依次测试分钟历史结果与 late-night 时段历史结果;记录是否缓存、实际来源版本、是否重算,不记个人资料。
|
||||
- [ ] 版本探测异常时旧结果仍可查看,显著出现“按旧算法产出”;没有采用/确认/选择写入口,伪造请求也拒绝。
|
||||
- [ ] 版本探测异常时旧结果仍可查看,显著出现“按旧算法产出”;没有采用/确认/选择写入口,伪造请求也拒绝;诊断工具不重计算,只读来源不变、can_confirm_exact_minute=false。
|
||||
- [ ] 已生成历史回执来源保留,不显示 started 的部署版本;旧 Skill 会话仍可从侧栏打开。
|
||||
- [ ] 恢复版本接口后当前同身份缓存可正常复用,身份变化按新规则重算,不删除或重标旧结果。
|
||||
- [ ] 恢复版本接口后当前同身份缓存可正常复用;旧身份的非终止历史显示「重新比较」,点击确实启动一次既有流程,普通旧候选不能采用。打开/刷新本身不强制重算;接口再次失败则仍只读,不删除或重标旧结果。
|
||||
- [ ] 历史首次打开与刷新即有只读说明,不必先compare;unknown时无重新比较按钮,不错误显示「该校正已结束」。
|
||||
- [ ] 新算响应版本与可信当前pair不一致时保存真实来源,仍不可采用,不能被前端默认版本重标。
|
||||
- [ ] 失败重试后活动与成功来源一致;多人/多个请求压力下没有把版本接口抖动放大成强制重计算。
|
||||
|
||||
当前没有浏览器级完成证据,本 agent 未调用线上。详见 `docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md`。
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract.
|
||||
|
||||
## 校正历史结果身份只读(2026-09-20)
|
||||
|
||||
历史打开、刷新与比较返回的结果若无法核实为当前算法及策略,消息区顶部复用 `rectification-pending-note`、`role="status"` 显示「按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。」保留原历史内容和来源,不出现强制重算入口;关闭旧结果选项、候选采用及普通输入,输入提示为「当前结果仅供查看」。可信当前身份已知但与旧结果不同,非终止会话可显式点「重新比较」,复用既有发送链路;身份未知时无此入口,服务端再次探测失败也不能重算。此状态不等同 Case 已结束,不显示误导的结束提示或额外「再次校正」按钮。真正终止的历史仍沿用原结束操作。服务端独立核验所有结果写入,不以界面禁用代替权限。无新颜色、加载动画或 Home 状态。
|
||||
|
||||
## 普通对话寒暄轮(2026-09-20)
|
||||
|
||||
服务端标记 `responseKind: smalltalk` 的回复仅展示正文与既有头像/消息操作,不展示思考面板、活动步骤、已完成步骤或技法证据;刷新后的历史也遵循此规则。分类尚未确定、且没有任何真实 activity/thinking/timeline 时,保留消息位置,不虚构「正在分析」或步骤,不另加 spinner。咨询收到真实活动后恢复既有呈现。完成后读取服务端账户余额,寒暄预留点数原额退回,净点数不变;不增加 Home 状态。
|
||||
|
||||
@@ -22,6 +22,7 @@ const acceptSchema = z.object({
|
||||
function errorResponse(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const known = [
|
||||
["result_identity_read_only", 409, "按旧算法产出,暂不能采用"],
|
||||
["agentic_rectification_candidate_not_found", 404, "候选记录不存在或已过期"],
|
||||
["agentic_rectification_candidate_expired", 409, "候选结果已过期,请重新比较"],
|
||||
["agentic_rectification_candidate_selection_blocked", 409, "当前还不能采用候选"],
|
||||
@@ -114,6 +115,8 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
const { publicDecisionFields } = await import(
|
||||
"@/lib/rectification-agentic/core/rectification-decision"
|
||||
);
|
||||
const { assertV9ResultWritable } = await import("@/lib/rectification-agentic/v9/result-identity");
|
||||
await assertV9ResultWritable(accounting, user.id, caseId, parsed.data.resultId);
|
||||
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
|
||||
const decision = decideFromDossier(dossier, {
|
||||
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
listV10ConversationFocuses,
|
||||
} from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
|
||||
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
|
||||
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...dossierResponse(dossier, receipts, skillIdentity, { listed }),
|
||||
...await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { listed }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
listV10ConversationFocuses,
|
||||
safeToolErrorCode,
|
||||
} from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
|
||||
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}),
|
||||
);
|
||||
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
|
||||
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, listed }));
|
||||
return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { fullDetail, listed }));
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const message = error.message;
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
isAbortError,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
recompareHistoricalResult,
|
||||
rectificationQuestionGapState,
|
||||
rectificationReadonlyRangeCopy,
|
||||
contrastProbesFromReceipt,
|
||||
@@ -389,7 +390,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const {
|
||||
caseId,
|
||||
sessionId,
|
||||
readonly,
|
||||
readonly: historyReadonly,
|
||||
shouldStartOpening,
|
||||
initialTurns,
|
||||
initialSnapshot,
|
||||
@@ -410,6 +411,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
headerSlot,
|
||||
} = props;
|
||||
|
||||
const [resultNotice, setResultNotice] = useState(initialSnapshot?.case?.result_notice ?? null);
|
||||
const [canRecompare, setCanRecompare] = useState(initialSnapshot?.case?.result_identity?.can_recompare === true);
|
||||
const readonly = historyReadonly || Boolean(resultNotice);
|
||||
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
|
||||
const [draft, setDraft] = useState("");
|
||||
const queued = useQueuedMessage();
|
||||
@@ -583,6 +588,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
} | null) => {
|
||||
if (!payload) return;
|
||||
const snapshot = payload as RectificationCaseSnapshotPayload;
|
||||
setResultNotice(snapshot.case?.result_notice ?? null);
|
||||
setCanRecompare(snapshot.case?.result_identity?.can_recompare === true);
|
||||
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
|
||||
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
|
||||
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
|
||||
@@ -693,7 +700,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
|
||||
) => {
|
||||
const trimmed = action === "message" ? messageText.trim() : "";
|
||||
if ((action === "message" && !trimmed) || readonly) return;
|
||||
if ((action === "message" && !trimmed) || conversationReadonly) return;
|
||||
if (!continuation && busy) return;
|
||||
setError("");
|
||||
setPending(true);
|
||||
@@ -1018,7 +1025,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
lastRunOutcome.current = readonly ? "readonly" : runOutcome;
|
||||
setPending(false);
|
||||
}
|
||||
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
|
||||
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, conversationReadonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (busy) return;
|
||||
@@ -1688,6 +1695,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
aria-busy={busy || regeneratingMessageKey !== null}
|
||||
>
|
||||
<div className="message-list">
|
||||
{resultNotice && <p role="status" className="rectification-pending-note">{resultNotice}</p>}
|
||||
{resultNotice && canRecompare && !historyReadonly && (
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void recompareHistoricalResult({ canRecompare, historyReadonly, busy, send })}>重新比较</Button>
|
||||
)}
|
||||
{pendingConsultationQuestion?.trim() && (
|
||||
<p className="rectification-pending-note">
|
||||
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
|
||||
@@ -1822,7 +1833,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="error-message" role="alert">{error}</p>}
|
||||
{readonly && (
|
||||
{historyReadonly && (
|
||||
<div className="rectification-terminal-actions">
|
||||
<p className="rectification-terminal-note">该校正已结束,只能查看历史。</p>
|
||||
<Button type="button" onClick={onRestart}>再次校正</Button>
|
||||
@@ -1839,8 +1850,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
inputRef={composer}
|
||||
value={draft}
|
||||
remainingId={composerRemainingId}
|
||||
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
placeholder={readonly
|
||||
inputLabel={resultNotice ? "当前结果仅供查看" : readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
placeholder={resultNotice
|
||||
? "当前结果仅供查看,暂不能选择或采用。"
|
||||
: readonly
|
||||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||||
: showLiveChoiceCard
|
||||
? "点上面的选项即可;想补一句细节再写"
|
||||
|
||||
@@ -619,6 +619,8 @@ export async function applyRectificationChoice(
|
||||
accounting: AccountingClient,
|
||||
command: ApplyChoiceCommand,
|
||||
): Promise<AppliedChoiceReceipt> {
|
||||
const { assertV9ResultWritable } = await import("./result-identity.ts");
|
||||
await assertV9ResultWritable(accounting, command.userId, command.caseId);
|
||||
const dossier = await loadV9CaseDossier(accounting, command.userId, command.caseId);
|
||||
if (dossier.case.sessionId !== command.sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
@@ -1003,6 +1005,12 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
followup?: MethodFollowup | null;
|
||||
terminalNote?: boolean;
|
||||
}> {
|
||||
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
|
||||
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
||||
const identityDossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
if (resultIdentityView(identityDossier, await readV9EngineScoringIdentity()).read_only) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
|
||||
}
|
||||
const birthDate = input.birthDate ?? null;
|
||||
let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
|
||||
let decisionState = input.decisionState;
|
||||
@@ -1296,6 +1304,8 @@ export async function requestTieBreakPersonality(input: {
|
||||
hostNarration: string;
|
||||
focus: ConversationFocus | null;
|
||||
}> {
|
||||
const { assertV9ResultWritable } = await import("./result-identity.ts");
|
||||
await assertV9ResultWritable(input.accounting, input.userId, input.caseId);
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
if (dossier.case.acceptedTime) {
|
||||
return {
|
||||
@@ -1478,6 +1488,11 @@ export async function applyCollectFocusDenial(
|
||||
? RECTIFICATION_USER_COPY.collectSkippedAck
|
||||
: RECTIFICATION_USER_COPY.collectDeclinedAck;
|
||||
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
|
||||
const { resultIdentityView } = await import("./result-identity.ts");
|
||||
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
||||
if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) {
|
||||
throw new RectificationToolServiceError("result_identity_read_only");
|
||||
}
|
||||
const focus = dossier.conversationSummary.activeFocus;
|
||||
if (!focus || focus.id !== input.focusId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_focus_not_active");
|
||||
@@ -1627,6 +1642,11 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
askedTurnId: input.askedTurnId ?? null,
|
||||
});
|
||||
let dossier = rescored.dossier;
|
||||
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
|
||||
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
||||
if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
|
||||
}
|
||||
const finishIdle = <T extends { hostNarration: string | null; terminalNote?: boolean }>(result: T): T => {
|
||||
let hostNarration = result.hostNarration;
|
||||
if (dossier.case.acceptedTime && hostNarration) {
|
||||
@@ -2423,6 +2443,11 @@ export async function ensureNonTerminalTurnExit(input: {
|
||||
caseId: string;
|
||||
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
|
||||
const before = await inspectNonTerminalTurnExit(input);
|
||||
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
|
||||
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
||||
if (resultIdentityView(before.dossier, await readV9EngineScoringIdentity()).read_only) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
|
||||
}
|
||||
if (before.satisfied) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export async function mutateCaseForBlockChoice(input: {
|
||||
if (!isBlockChoiceSchema(input.schema)) {
|
||||
return { dossier: input.dossier, snapshotCurrent: true };
|
||||
}
|
||||
const { assertV9ResultWritable } = await import("./result-identity.ts");
|
||||
await assertV9ResultWritable(input.accounting, input.userId, input.caseId, null);
|
||||
const window = windowFromBlockChoice({
|
||||
schema: input.schema,
|
||||
optionId: input.optionId,
|
||||
@@ -105,6 +107,8 @@ export async function mutateCaseForWidenWindow(input: {
|
||||
if (!isWidenWindowSchema(input.schema)) {
|
||||
return { dossier: input.dossier, snapshotCurrent: true };
|
||||
}
|
||||
const { assertV9ResultWritable } = await import("./result-identity.ts");
|
||||
await assertV9ResultWritable(input.accounting, input.userId, input.caseId);
|
||||
const chosen = windowFromWidenChoice({
|
||||
schema: input.schema,
|
||||
optionId: input.optionId,
|
||||
|
||||
@@ -58,6 +58,8 @@ export type BlockScanBlock = Readonly<{
|
||||
export type BlockScanPayload = Readonly<{
|
||||
evidenceLedgerFingerprint?: string | null;
|
||||
algorithmVersion?: string | null;
|
||||
policyVersion?: string | null;
|
||||
candidateRangeFingerprint?: string | null;
|
||||
minuteStep?: number | null;
|
||||
declinedAtFingerprint?: string | null;
|
||||
rounds?: number | null;
|
||||
@@ -132,6 +134,8 @@ export function parseBlockScanPayload(value: unknown): BlockScanPayload | null {
|
||||
? row.evidence_ledger_fingerprint
|
||||
: null,
|
||||
algorithmVersion: typeof row.algorithm_version === "string" ? row.algorithm_version : null,
|
||||
policyVersion: typeof row.decision_policy_version === "string" ? row.decision_policy_version : null,
|
||||
candidateRangeFingerprint: typeof row.candidate_range_fingerprint === "string" ? row.candidate_range_fingerprint : null,
|
||||
minuteStep: typeof row.minute_step === "number" ? row.minute_step : null,
|
||||
declinedAtFingerprint: typeof row.declined_at_fingerprint === "string"
|
||||
? row.declined_at_fingerprint
|
||||
@@ -145,6 +149,8 @@ export function serializeBlockScanPayload(payload: BlockScanPayload): Record<str
|
||||
return {
|
||||
evidence_ledger_fingerprint: payload.evidenceLedgerFingerprint ?? null,
|
||||
algorithm_version: payload.algorithmVersion ?? null,
|
||||
decision_policy_version: payload.policyVersion ?? null,
|
||||
candidate_range_fingerprint: payload.candidateRangeFingerprint ?? null,
|
||||
minute_step: payload.minuteStep ?? null,
|
||||
declined_at_fingerprint: payload.declinedAtFingerprint ?? null,
|
||||
rounds: payload.rounds ?? 0,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resultIdentityView, OLD_ALGORITHM_NOTICE } from "./result-identity.ts";
|
||||
import { readV9EngineScoringIdentity, type LiveEngineScoringIdentity } from "./engine-client.ts";
|
||||
import {
|
||||
evidenceLedgerFingerprint,
|
||||
loadV9CaseSkillIdentityStatus,
|
||||
@@ -20,12 +22,21 @@ import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectifica
|
||||
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
|
||||
import { normalizeBirthTimeSource } from "@/lib/rectification-agentic/birth-time-provenance";
|
||||
|
||||
export async function dossierResponseWithIdentity(
|
||||
...args: Parameters<typeof dossierResponse>
|
||||
) {
|
||||
return dossierResponse(args[0], args[1], args[2], {
|
||||
...args[3], liveIdentity: await readV9EngineScoringIdentity(),
|
||||
});
|
||||
}
|
||||
|
||||
export function dossierResponse(
|
||||
dossier: V9CaseDossier,
|
||||
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
|
||||
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
|
||||
options: { fullDetail?: boolean; listed?: ConversationFocusList } = {},
|
||||
options: { fullDetail?: boolean; listed?: ConversationFocusList; liveIdentity?: LiveEngineScoringIdentity } = {},
|
||||
) {
|
||||
const identity = resultIdentityView(dossier, options.liveIdentity ?? { algorithmVersion: null, policyVersion: null });
|
||||
const decision = decideFromDossier(dossier, {
|
||||
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
|
||||
});
|
||||
@@ -38,11 +49,13 @@ export function dossierResponse(
|
||||
: fields;
|
||||
const turns = attachOfferResultToTurns(turnsWithQuestions, {
|
||||
resultId: dossier.latestResult?.resultId ?? null,
|
||||
canAdopt: overlaid.can_adopt === true,
|
||||
canAdopt: !identity.read_only && overlaid.can_adopt === true,
|
||||
acceptedTime: dossier.case.acceptedTime,
|
||||
});
|
||||
return {
|
||||
case: {
|
||||
result_identity: identity,
|
||||
result_notice: identity.read_only ? OLD_ALGORITHM_NOTICE : null,
|
||||
case_id: dossier.case.caseId,
|
||||
session_id: dossier.case.sessionId,
|
||||
status: dossier.case.status,
|
||||
@@ -67,22 +80,24 @@ export function dossierResponse(
|
||||
status: turn.status,
|
||||
created_at: turn.createdAt,
|
||||
receipt: turnReceipt(turn.id, receipts),
|
||||
question: turn.question,
|
||||
question: identity.read_only ? null : turn.question,
|
||||
offer_result_id: turn.offer_result_id,
|
||||
})),
|
||||
evidence: dossier.evidence,
|
||||
latest_result: dossier.case.stage === "block_scan" || decision.sessionOutcome === "compare_blocks"
|
||||
? null
|
||||
: dossier.latestResult
|
||||
? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true)
|
||||
? { ...publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true),
|
||||
...(identity.read_only ? { can_adopt: false, selectionAllowed: false, confirmationAllowed: false, read_only: true } : {}) }
|
||||
: null,
|
||||
interview: {
|
||||
...fields,
|
||||
...(identity.read_only ? { can_adopt: false, selection_allowed: false, confirmation_allowed: false, validated: false, read_only: true } : {}),
|
||||
birth_time_source: birthTimeSource,
|
||||
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
||||
},
|
||||
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
|
||||
choice_card: choiceCardFromCaseDossier(dossier),
|
||||
current_question: identity.read_only ? null : projectCurrentQuestion(dossier.conversationSummary.activeFocus),
|
||||
choice_card: identity.read_only ? null : choiceCardFromCaseDossier(dossier),
|
||||
step_state: stepStateFromCaseDossier(dossier),
|
||||
next_user_action: nextUserActionFromDossier(dossier),
|
||||
question_source: questionSourceFromFocusList(listed),
|
||||
|
||||
@@ -422,6 +422,19 @@ export function scoringIdentityFromEnginePayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function scoringIdentityIsTrusted(identity: LiveEngineScoringIdentity): boolean {
|
||||
return Boolean(identity.algorithmVersion && identity.policyVersion);
|
||||
}
|
||||
|
||||
export function scoringIdentityMatches(
|
||||
stored: { algorithmVersion?: string | null; policyVersion?: string | null } | null | undefined,
|
||||
live: LiveEngineScoringIdentity,
|
||||
): boolean {
|
||||
return Boolean(stored && scoringIdentityIsTrusted(live)
|
||||
&& stored.algorithmVersion === live.algorithmVersion
|
||||
&& stored.policyVersion === live.policyVersion);
|
||||
}
|
||||
|
||||
export function cachedEngineScoreIsReusable(
|
||||
stored: {
|
||||
evidenceLedgerFingerprint?: string | null;
|
||||
@@ -435,20 +448,21 @@ export function cachedEngineScoreIsReusable(
|
||||
if (!stored) return false;
|
||||
if (stored.evidenceLedgerFingerprint !== fingerprints.evidenceLedgerFingerprint) return false;
|
||||
if (stored.candidateRangeFingerprint !== fingerprints.candidateRangeFingerprint) return false;
|
||||
if (live.policyVersion && stored.policyVersion && stored.policyVersion !== live.policyVersion) {
|
||||
return false;
|
||||
}
|
||||
if (live.algorithmVersion && stored.algorithmVersion && stored.algorithmVersion !== live.algorithmVersion) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return scoringIdentityMatches(stored, live);
|
||||
}
|
||||
|
||||
export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringIdentity> {
|
||||
const fromEnv = liveEngineScoringIdentityFromEnv();
|
||||
if (fromEnv.policyVersion || fromEnv.algorithmVersion) return fromEnv;
|
||||
if (scoringIdentityIsTrusted(fromEnv)) return fromEnv;
|
||||
try {
|
||||
return scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions"));
|
||||
const native = scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions"));
|
||||
// Partial overrides cannot invent the missing half of a computation identity.
|
||||
// A conflicting partial override is not a coherent identity pair.
|
||||
if ((fromEnv.algorithmVersion && fromEnv.algorithmVersion !== native.algorithmVersion)
|
||||
|| (fromEnv.policyVersion && fromEnv.policyVersion !== native.policyVersion)) {
|
||||
return { algorithmVersion: null, policyVersion: null };
|
||||
}
|
||||
return native;
|
||||
} catch {
|
||||
return { algorithmVersion: null, policyVersion: null };
|
||||
}
|
||||
@@ -1014,6 +1028,7 @@ export async function runV9RangeReading(input: {
|
||||
export type V9BlockScanResult = Readonly<{
|
||||
resultId: string | null;
|
||||
algorithmVersion: string;
|
||||
policyVersion: string | null;
|
||||
minuteStep: number;
|
||||
blocks: readonly BlockScanBlock[];
|
||||
}>;
|
||||
@@ -1042,6 +1057,7 @@ export async function runV9BlockScan(input: {
|
||||
return {
|
||||
resultId: typeof data.result_id === "string" ? data.result_id : null,
|
||||
algorithmVersion: parsed.algorithmVersion ?? String(data.algorithm_version ?? ""),
|
||||
policyVersion: scoringIdentityFromEnginePayload(record(data.decision_receipt)).policyVersion,
|
||||
minuteStep: parsed.minuteStep ?? 10,
|
||||
blocks: parsed.blocks,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
readV9EngineScoringIdentity,
|
||||
scoringIdentityMatches,
|
||||
scoringIdentityIsTrusted,
|
||||
type LiveEngineScoringIdentity,
|
||||
} from "./engine-client.ts";
|
||||
import {
|
||||
loadV9CaseDossier,
|
||||
RectificationToolServiceError,
|
||||
type AccountingClient,
|
||||
type V9CaseDossier,
|
||||
} from "./tool-service.ts";
|
||||
|
||||
export const OLD_ALGORITHM_NOTICE = "按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。";
|
||||
|
||||
/** Read-time projection only. Never persist or relabel a historical source. */
|
||||
export function resultIdentityView(dossier: V9CaseDossier, live: LiveEngineScoringIdentity) {
|
||||
const source = dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult;
|
||||
const hasAnyResult = Boolean(dossier.case.blockScan || dossier.latestResult);
|
||||
return {
|
||||
read_only: hasAnyResult && !scoringIdentityMatches(source, live),
|
||||
can_recompare: hasAnyResult && scoringIdentityIsTrusted(live) && !scoringIdentityMatches(source, live),
|
||||
algorithm_version: source?.algorithmVersion ?? null,
|
||||
policy_version: source?.policyVersion ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Server-owned check at every candidate mutation boundary, not a client flag. */
|
||||
export async function assertV9ResultWritable(
|
||||
accounting: AccountingClient, userId: string, caseId: string, resultId?: string | null,
|
||||
): Promise<void> {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
if (!dossier.latestResult && !dossier.case.blockScan && resultId === undefined) return;
|
||||
const live = await readV9EngineScoringIdentity();
|
||||
if (resultIdentityView(dossier, live).read_only
|
||||
|| (resultId === null && !(dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult))
|
||||
|| (typeof resultId === "string" && (dossier.case.stage !== "minute"
|
||||
|| dossier.latestResult?.resultId !== resultId
|
||||
|| !scoringIdentityMatches(dossier.latestResult, live)))) {
|
||||
throw new RectificationToolServiceError("result_identity_read_only");
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
executedMethodsFromLedger,
|
||||
mergeVedastroValidateIntoReceipt,
|
||||
readV9EngineScoringIdentity,
|
||||
scoringIdentityIsTrusted,
|
||||
scoringIdentityMatches,
|
||||
runV9BlockScan,
|
||||
runV9CandidateScore,
|
||||
runV9VedastroValidate,
|
||||
@@ -61,6 +63,7 @@ export type ScorePersistTimings = Readonly<{
|
||||
}>;
|
||||
|
||||
export type ScorePersistResult = Readonly<{
|
||||
readOnly?: boolean;
|
||||
persisted: V9PersistedCandidate | Readonly<{
|
||||
resultId: string;
|
||||
cached: boolean;
|
||||
@@ -71,7 +74,7 @@ export type ScorePersistResult = Readonly<{
|
||||
representativeTime: null;
|
||||
algorithmVersion: string;
|
||||
eventContractVersion: string;
|
||||
policyVersion: string;
|
||||
policyVersion: string | null;
|
||||
decisionReceipt: Readonly<Record<string, unknown>>;
|
||||
executionLedger: readonly [];
|
||||
}>;
|
||||
@@ -87,6 +90,7 @@ export async function persistBlockScanPayload(input: {
|
||||
userId: string;
|
||||
caseId: string;
|
||||
evidenceFingerprint: string;
|
||||
rangeFingerprint?: string;
|
||||
scan: Awaited<ReturnType<typeof runV9BlockScan>>;
|
||||
previousRounds?: number;
|
||||
}): Promise<void> {
|
||||
@@ -96,6 +100,8 @@ export async function persistBlockScanPayload(input: {
|
||||
await writeV9BlockScan(input.accounting, input.userId, input.caseId, {
|
||||
evidence_ledger_fingerprint: input.evidenceFingerprint,
|
||||
algorithm_version: input.scan.algorithmVersion,
|
||||
decision_policy_version: input.scan.policyVersion,
|
||||
candidate_range_fingerprint: input.rangeFingerprint ?? null,
|
||||
minute_step: input.scan.minuteStep,
|
||||
rounds: isSub ? (input.previousRounds ?? 0) + 1 : 0,
|
||||
blocks: input.scan.blocks,
|
||||
@@ -167,8 +173,15 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
if (!candidateRange) throw new RectificationToolServiceError("case_range_missing");
|
||||
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
|
||||
const evidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence);
|
||||
const rangeFingerprint = candidateRangeFingerprint(candidateRange, compute.baselineProfileFingerprint);
|
||||
const liveIdentity = await readV9EngineScoringIdentity();
|
||||
const readOnly = !scoringIdentityIsTrusted(liveIdentity);
|
||||
if (readOnly && !(dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult)) {
|
||||
throw new RectificationToolServiceError("engine_identity_unavailable");
|
||||
}
|
||||
if (
|
||||
dossier.case.stage === "block_scan"
|
||||
!readOnly
|
||||
&& dossier.case.stage === "block_scan"
|
||||
&& shouldForceMinuteAfterSubBlocks(dossier.case.blockScan)
|
||||
) {
|
||||
await setV9CaseStage(input.accounting, input.userId, input.caseId, "minute");
|
||||
@@ -176,11 +189,10 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
}
|
||||
if (dossier.case.stage === "block_scan") {
|
||||
const cachedPayload = dossier.case.blockScan;
|
||||
const cached = Boolean(
|
||||
cachedPayload
|
||||
&& cachedPayload.evidenceLedgerFingerprint === evidenceFingerprint,
|
||||
);
|
||||
if (!cached) {
|
||||
const cached = cachedEngineScoreIsReusable(cachedPayload, {
|
||||
evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint,
|
||||
}, liveIdentity);
|
||||
if (!cached && !readOnly) {
|
||||
const events = toEngineEvents(scorable);
|
||||
const extras = blockScanRequestExtras(candidateRange);
|
||||
const scoreStarted = Date.now();
|
||||
@@ -197,11 +209,13 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
evidenceFingerprint,
|
||||
rangeFingerprint,
|
||||
scan,
|
||||
previousRounds: cachedPayload?.rounds ?? 0,
|
||||
});
|
||||
const reloaded = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
return {
|
||||
readOnly: !scoringIdentityMatches(scan, liveIdentity),
|
||||
persisted: {
|
||||
resultId: scan.resultId ?? "",
|
||||
cached: false,
|
||||
@@ -212,7 +226,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
representativeTime: null,
|
||||
algorithmVersion: scan.algorithmVersion,
|
||||
eventContractVersion: "",
|
||||
policyVersion: "",
|
||||
policyVersion: scan.policyVersion,
|
||||
decisionReceipt: {},
|
||||
executionLedger: [],
|
||||
},
|
||||
@@ -228,9 +242,10 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
};
|
||||
}
|
||||
return {
|
||||
readOnly,
|
||||
persisted: {
|
||||
resultId: "",
|
||||
cached: true,
|
||||
cached,
|
||||
candidates: [],
|
||||
overallConfidence: "low",
|
||||
selectionAllowed: false,
|
||||
@@ -238,7 +253,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
representativeTime: null,
|
||||
algorithmVersion: cachedPayload?.algorithmVersion ?? "",
|
||||
eventContractVersion: "",
|
||||
policyVersion: "",
|
||||
policyVersion: cachedPayload?.policyVersion ?? null,
|
||||
decisionReceipt: {},
|
||||
executionLedger: [],
|
||||
},
|
||||
@@ -253,25 +268,19 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
},
|
||||
};
|
||||
}
|
||||
const rangeFingerprint = candidateRangeFingerprint(
|
||||
candidateRange,
|
||||
compute.baselineProfileFingerprint,
|
||||
);
|
||||
const events = toEngineEvents(scorable);
|
||||
const previousReceipt = keepAnswers ? dossier.latestResult?.decisionReceipt : null;
|
||||
const askedProbeKeys = keepAnswers
|
||||
? askedSemanticKeysForEngine(previousReceipt, dossier.evidence)
|
||||
: [];
|
||||
const latest = dossier.latestResult;
|
||||
const liveIdentity = await readV9EngineScoringIdentity();
|
||||
if (
|
||||
keepAnswers
|
||||
&& latest
|
||||
&& cachedEngineScoreIsReusable(
|
||||
latest
|
||||
&& (readOnly || (keepAnswers && cachedEngineScoreIsReusable(
|
||||
latest,
|
||||
{ evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint },
|
||||
liveIdentity,
|
||||
)
|
||||
)))
|
||||
) {
|
||||
const ledger = latest.executionLedger ?? [];
|
||||
const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt);
|
||||
@@ -287,7 +296,8 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
let vedastroMs = 0;
|
||||
let persistMs = 0;
|
||||
if (
|
||||
latest.selectionAllowed
|
||||
!readOnly
|
||||
&& latest.selectionAllowed
|
||||
&& primary
|
||||
&& runnerUp
|
||||
&& primary !== runnerUp
|
||||
@@ -317,13 +327,14 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
persistMs = Date.now() - persistStarted;
|
||||
}
|
||||
return {
|
||||
readOnly,
|
||||
persisted: {
|
||||
resultId: latest.resultId,
|
||||
cached: true,
|
||||
cached: !readOnly,
|
||||
candidates,
|
||||
overallConfidence: latest.overallConfidence,
|
||||
selectionAllowed: candidateProjection.consistent && latest.selectionAllowed,
|
||||
confirmationAllowed: latest.confirmationAllowed,
|
||||
selectionAllowed: !readOnly && candidateProjection.consistent && latest.selectionAllowed,
|
||||
confirmationAllowed: !readOnly && latest.confirmationAllowed,
|
||||
representativeTime,
|
||||
algorithmVersion: latest.algorithmVersion,
|
||||
eventContractVersion: latest.eventContractVersion,
|
||||
@@ -340,10 +351,10 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
candidates,
|
||||
overallConfidence: latest.overallConfidence,
|
||||
marginPercent: null,
|
||||
acceptanceAllowed: latest.selectionAllowed,
|
||||
selectionAllowed: candidateProjection.consistent && latest.selectionAllowed,
|
||||
proposeAllowed: latest.selectionAllowed,
|
||||
confirmationAllowed: latest.confirmationAllowed,
|
||||
acceptanceAllowed: !readOnly && latest.selectionAllowed,
|
||||
selectionAllowed: !readOnly && candidateProjection.consistent && latest.selectionAllowed,
|
||||
proposeAllowed: !readOnly && latest.selectionAllowed,
|
||||
confirmationAllowed: !readOnly && latest.confirmationAllowed,
|
||||
representativeCandidateId: null,
|
||||
representativeTime,
|
||||
decisionReceipt,
|
||||
@@ -383,7 +394,8 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
});
|
||||
const engineCompareMs = Date.now() - scoreStarted;
|
||||
const vedastroStarted = Date.now();
|
||||
const receipt = await persistableReceipt(score, {
|
||||
const responseReadOnly = !scoringIdentityMatches(score, liveIdentity);
|
||||
const receipt = responseReadOnly ? score.decisionReceipt : await persistableReceipt(score, {
|
||||
baselineBirthSnapshot: compute.baselineBirthSnapshot,
|
||||
candidateRange,
|
||||
events,
|
||||
@@ -451,8 +463,9 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
executionLedger: score.executionLedger,
|
||||
});
|
||||
return {
|
||||
persisted,
|
||||
score,
|
||||
readOnly: responseReadOnly,
|
||||
persisted: responseReadOnly ? { ...persisted, selectionAllowed: false, confirmationAllowed: false } : persisted,
|
||||
score: responseReadOnly ? { ...score, selectionAllowed: false, confirmationAllowed: false, acceptanceAllowed: false, proposeAllowed: false } : score,
|
||||
dossier,
|
||||
windowScan: score.windowScan,
|
||||
decisionReceipt,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* event arrays, scores or permission decisions.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { assertV9ResultWritable } from "./result-identity.ts";
|
||||
import type { AdoptedCredibleRangePayload } from "../../report-candidate-range.ts";
|
||||
import {
|
||||
EVIDENCE_KINDS,
|
||||
@@ -1546,6 +1547,7 @@ export async function advanceV9CaseFromBlockScan(
|
||||
caseId: string,
|
||||
window: { start_time: string; end_time: string },
|
||||
): Promise<{ start_time: string; end_time: string }> {
|
||||
await assertV9ResultWritable(accounting, userId, caseId, null);
|
||||
const row = await rpc<Record<string, unknown>>(
|
||||
accounting,
|
||||
"advance_agentic_rectification_case_from_block_scan",
|
||||
@@ -1730,6 +1732,7 @@ export async function persistV9InferenceState(
|
||||
decisionReceipt: Readonly<Record<string, unknown>>;
|
||||
decisionStateFingerprint: string;
|
||||
}>> {
|
||||
await assertV9ResultWritable(accounting, userId, caseId, null);
|
||||
const row = await rpc<Record<string, unknown>>(
|
||||
accounting,
|
||||
"append_agentic_rectification_inference_transition",
|
||||
@@ -1794,6 +1797,9 @@ export async function persistV9ChoiceAction(
|
||||
narration: string | null;
|
||||
receipt: Readonly<Record<string, unknown>>;
|
||||
}>> {
|
||||
// Non-scoring choices were checked before their window/evidence mutation;
|
||||
// rechecking an intentionally cleared block payload would reject its audit.
|
||||
if (input.inference) await assertV9ResultWritable(accounting, userId, caseId, null);
|
||||
const inference = input.inference
|
||||
? {
|
||||
expected_revision: input.inference.expectedRevision,
|
||||
@@ -1907,6 +1913,7 @@ export async function acceptV9Candidate(
|
||||
if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) {
|
||||
throw new RectificationToolServiceError("invalid_candidate_ref");
|
||||
}
|
||||
await assertV9ResultWritable(accounting, userId, caseId, resultId);
|
||||
const row = await rpc<Record<string, unknown>>(
|
||||
accounting,
|
||||
"accept_agentic_rectification_candidate_for_case_v2",
|
||||
@@ -1957,6 +1964,7 @@ export async function confirmV9BirthTime(
|
||||
if (!uuidPattern.test(input.candidateId) || !uuidPattern.test(input.requestId)) {
|
||||
throw new RectificationToolServiceError("invalid_candidate_ref");
|
||||
}
|
||||
await assertV9ResultWritable(accounting, userId, caseId, input.resultId);
|
||||
const row = await rpc<Record<string, unknown>>(
|
||||
accounting,
|
||||
"confirm_agentic_rectification_candidate_for_case_v2",
|
||||
@@ -2018,6 +2026,8 @@ export function safeToolErrorCode(error: unknown): string {
|
||||
"candidate_not_found",
|
||||
"candidate_expired",
|
||||
"candidate_selection_blocked",
|
||||
"result_identity_read_only",
|
||||
"engine_identity_unavailable",
|
||||
"candidate_time_not_allowed",
|
||||
"candidate_already_selected",
|
||||
"candidate_superseded",
|
||||
|
||||
@@ -47,6 +47,16 @@ export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
|
||||
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
|
||||
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
|
||||
|
||||
export async function recompareHistoricalResult(input: {
|
||||
canRecompare: boolean;
|
||||
historyReadonly: boolean;
|
||||
busy: boolean;
|
||||
send: (action: "message", text: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (!input.canRecompare || input.historyReadonly || input.busy) return;
|
||||
await input.send("message", "请按当前算法重新比较候选。");
|
||||
}
|
||||
|
||||
export function isAbortError(caught: unknown): boolean {
|
||||
return (caught instanceof DOMException || caught instanceof Error) && caught.name === "AbortError";
|
||||
}
|
||||
@@ -79,6 +89,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{
|
||||
type?: unknown;
|
||||
}>;
|
||||
case?: Readonly<{
|
||||
result_notice?: string | null;
|
||||
result_identity?: Readonly<{ can_recompare?: boolean }>;
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
|
||||
@@ -64,6 +64,7 @@ import { inclusiveClockWidthMinutes, indistinguishableWidthMinutes } from "@/lib
|
||||
import { rankActive } from "@/lib/rectification-agentic/core/convergence-evaluator";
|
||||
import { unionStillValidRange } from "@/lib/rectification-agentic/core/credible-range";
|
||||
import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan";
|
||||
import { assertV9ResultWritable, resultIdentityView, OLD_ALGORITHM_NOTICE } from "@/lib/rectification-agentic/v9/result-identity";
|
||||
import { scoreAndPersistCurrentEvidence as persistCurrentEvidenceScore } from "@/lib/rectification-agentic/v9/score-persist";
|
||||
import {
|
||||
buildConfirmationGate,
|
||||
@@ -158,6 +159,8 @@ import {
|
||||
runV9Diagnostics,
|
||||
runV9VedastroValidate,
|
||||
readV9EngineScoringIdentity,
|
||||
scoringIdentityIsTrusted,
|
||||
scoringIdentityMatches,
|
||||
cachedEngineScoreIsReusable,
|
||||
toEngineEvents,
|
||||
executedMethodsFromLedger,
|
||||
@@ -998,6 +1001,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
// Ranking stays fail-open when compute is unavailable.
|
||||
}
|
||||
const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate });
|
||||
const currentDossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
if (resultIdentityView(currentDossier, await readV9EngineScoringIdentity()).read_only) {
|
||||
return { collectingPlan: { ...collectingPlan, next_followup: null },
|
||||
persistedFocus: { status: "skipped" as const, focus: null, questionId: null, prompt: null },
|
||||
contrastPacket, decision };
|
||||
}
|
||||
const followup = collectingPlan.next_followup;
|
||||
const persistedFocus = await persistServerOwnedFocus({
|
||||
accounting,
|
||||
@@ -1033,8 +1042,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
if (parsed.scorable.length === 0 || !parsed.case.candidateRange) {
|
||||
return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null };
|
||||
}
|
||||
const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity());
|
||||
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
|
||||
if (parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) {
|
||||
if (!identity.read_only && parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) {
|
||||
const persisted = parsed.latestResult
|
||||
? await persistPlanFocus(parsed, parsed.latestResult)
|
||||
: null;
|
||||
@@ -1050,6 +1060,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
};
|
||||
}
|
||||
const scored = await scoreAndPersistCurrentEvidence(targetCaseId);
|
||||
if (scored.readOnly) {
|
||||
return { status: "skipped" as const, executedMethods: [] as const, errorCode: "result_identity_read_only", errorKind: null, cached: false, openQuestion: null };
|
||||
}
|
||||
const latest = scored.parsed.case.stage === "block_scan"
|
||||
? null
|
||||
: {
|
||||
@@ -1108,6 +1121,19 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
try {
|
||||
let dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
let parsed = parseDossierForTools(dossier);
|
||||
const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity());
|
||||
if (identity.read_only) {
|
||||
const storedProjection = projectTurnDecision(dossier);
|
||||
const projection = { ...storedProjection, read_only: true,
|
||||
notice: OLD_ALGORITHM_NOTICE, can_adopt: false, current_question: null, choice_card: null,
|
||||
current_probe: null, inference: null,
|
||||
candidate_summary: { ...(storedProjection.candidate_summary as Record<string, unknown>), selection_allowed: false, validated: false },
|
||||
can_recompare: identity.can_recompare,
|
||||
next_action: { type: identity.can_recompare ? "compare_candidates" : "read_only", selection_allowed: false, validated: false } };
|
||||
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
|
||||
hasReadCase = true;
|
||||
return projection;
|
||||
}
|
||||
if (input.resume === true && parsed.case.status === "paused") {
|
||||
await transitionV9CaseStatus(
|
||||
accounting,
|
||||
@@ -1925,6 +1951,17 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
await receipt("rectification-compare-candidates", "candidates.comparing", "started", { inputFingerprint });
|
||||
try {
|
||||
const scored = await scoreAndPersistCurrentEvidence(input.caseId);
|
||||
if (scored.readOnly) {
|
||||
const projection = { read_only: true, notice: OLD_ALGORITHM_NOTICE,
|
||||
algorithm_version: scored.persisted.algorithmVersion, cached: false,
|
||||
candidates: scored.persisted.candidates, can_adopt: false,
|
||||
selection_allowed: false, confirmation_allowed: false, open_question: null };
|
||||
await receipt("rectification-compare-candidates", "candidates.comparing", "completed", {
|
||||
inputFingerprint, resultFingerprint: hashResult(projection),
|
||||
engineVersion: scored.persisted.algorithmVersion ?? null, executedMethods: [],
|
||||
});
|
||||
return projection;
|
||||
}
|
||||
const latest = scored.parsed.case.stage === "block_scan"
|
||||
? null
|
||||
: {
|
||||
@@ -2000,6 +2037,27 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
await receipt("rectification-read-diagnostics", "diagnostics.completed", "started", { inputFingerprint });
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
const liveIdentity = await readV9EngineScoringIdentity();
|
||||
if (!scoringIdentityIsTrusted(liveIdentity)) {
|
||||
const identity = resultIdentityView(dossier, liveIdentity);
|
||||
if (!identity.read_only) throw new RectificationToolServiceError("engine_identity_unavailable");
|
||||
const projection = {
|
||||
...identity,
|
||||
notice: OLD_ALGORITHM_NOTICE,
|
||||
diagnostics: null,
|
||||
can_confirm_exact_minute: false,
|
||||
executed_methods: [],
|
||||
};
|
||||
await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", {
|
||||
inputFingerprint,
|
||||
resultFingerprint: hashResult(projection),
|
||||
engineVersion: identity.algorithm_version,
|
||||
executedMethods: [],
|
||||
});
|
||||
return projection;
|
||||
}
|
||||
// With a trusted identity this is an independent calculation, not a
|
||||
// validation of the stored candidate. Keep its actual response source.
|
||||
const parsed = parseDossierForTools(dossier);
|
||||
if (parsed.scorable.length === 0) {
|
||||
throw new RectificationToolServiceError("no_scorable_evidence");
|
||||
@@ -2015,12 +2073,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
parsed.evidence,
|
||||
),
|
||||
});
|
||||
const readOnly = !scoringIdentityMatches(diagnostics, liveIdentity);
|
||||
const projection = {
|
||||
engine_result_id: diagnostics.engineResultId,
|
||||
algorithm_version: diagnostics.algorithmVersion,
|
||||
policy_version: diagnostics.policyVersion,
|
||||
read_only: readOnly,
|
||||
notice: readOnly ? OLD_ALGORITHM_NOTICE : null,
|
||||
diagnostics: diagnostics.diagnostics,
|
||||
missing_layers: diagnostics.missingLayers,
|
||||
can_confirm_exact_minute: diagnostics.canConfirmExactMinute,
|
||||
can_confirm_exact_minute: !readOnly && diagnostics.canConfirmExactMinute,
|
||||
};
|
||||
await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", {
|
||||
inputFingerprint,
|
||||
@@ -2052,6 +2114,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
throw new RectificationToolServiceError("no_candidate_result");
|
||||
}
|
||||
const latest = parsed.latestResult;
|
||||
await assertV9ResultWritable(accounting, userId, input.caseId, latest.resultId);
|
||||
const { decision } = sessionAwareFollowupForParsed(parsed, latest);
|
||||
const currentSnapshot = snapshotSourceFromDossier(parsed, null);
|
||||
const storedSnapshot = storedSnapshotSource(latest);
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
-- BUG-984: result provenance comes from the last completed result in the
|
||||
-- selected attempt. Preserve owner/turn/attempt checks and the public shape.
|
||||
-- Compatible with old writers; no table changes or historical row updates.
|
||||
|
||||
begin;
|
||||
|
||||
create or replace function public.get_agentic_rectification_turn_receipt(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_turn_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_turn public.agentic_rectification_turns%rowtype;
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_attempt_id uuid;
|
||||
v_phases jsonb;
|
||||
v_tool_activities jsonb;
|
||||
v_tools jsonb;
|
||||
v_methods jsonb;
|
||||
v_engine_version text;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null or p_turn_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_turn
|
||||
from public.agentic_rectification_turns
|
||||
where id = p_turn_id and case_id = p_case_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_attempt_id := v_turn.successful_attempt_id;
|
||||
if v_attempt_id is null then
|
||||
select id into v_attempt_id
|
||||
from public.agentic_rectification_run_attempts
|
||||
where turn_id = p_turn_id
|
||||
order by attempt_number desc
|
||||
limit 1;
|
||||
end if;
|
||||
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name)
|
||||
order by rp.sequence, rp.created_at, rp.id
|
||||
), '[]'::jsonb) into v_phases
|
||||
from public.agentic_rectification_run_phases rp
|
||||
where rp.turn_id = p_turn_id
|
||||
and (
|
||||
(v_attempt_id is not null and rp.attempt_id = v_attempt_id)
|
||||
or (v_attempt_id is null and rp.attempt_id is null)
|
||||
);
|
||||
|
||||
with latest_terminal as materialized (
|
||||
select distinct on (tr.tool_name)
|
||||
tr.id,
|
||||
tr.tool_name,
|
||||
tr.status,
|
||||
tr.executed_methods,
|
||||
tr.started_at,
|
||||
tr.safe_error_code,
|
||||
tr.result_fingerprint
|
||||
from public.agentic_rectification_tool_receipts tr
|
||||
where tr.turn_id = p_turn_id
|
||||
and tr.status in ('completed', 'failed')
|
||||
and (
|
||||
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||
or (v_attempt_id is null and tr.attempt_id is null)
|
||||
)
|
||||
order by tr.tool_name, tr.started_at desc, tr.id desc
|
||||
),
|
||||
latest_started as materialized (
|
||||
select distinct on (tr.tool_name)
|
||||
tr.tool_name,
|
||||
tr.started_at
|
||||
from public.agentic_rectification_tool_receipts tr
|
||||
where tr.turn_id = p_turn_id
|
||||
and tr.status = 'started'
|
||||
and (
|
||||
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||
or (v_attempt_id is null and tr.attempt_id is null)
|
||||
)
|
||||
order by tr.tool_name, tr.started_at desc, tr.id desc
|
||||
)
|
||||
select
|
||||
coalesce(jsonb_agg(
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'tool', terminal.tool_name,
|
||||
'status', terminal.status,
|
||||
'methods', case
|
||||
when terminal.status = 'completed' then terminal.executed_methods
|
||||
else '[]'::jsonb
|
||||
end,
|
||||
'started_at', coalesce(started.started_at, terminal.started_at),
|
||||
'elapsed_ms', greatest(
|
||||
0,
|
||||
(extract(epoch from (
|
||||
terminal.started_at - coalesce(started.started_at, terminal.started_at)
|
||||
)) * 1000)::int
|
||||
),
|
||||
'error', terminal.safe_error_code,
|
||||
'result_fingerprint', terminal.result_fingerprint
|
||||
)) order by coalesce(started.started_at, terminal.started_at), terminal.id
|
||||
), '[]'::jsonb),
|
||||
coalesce(jsonb_agg(terminal.tool_name order by coalesce(started.started_at, terminal.started_at), terminal.id)
|
||||
filter (where terminal.status = 'completed'), '[]'::jsonb)
|
||||
into v_tool_activities, v_tools
|
||||
from latest_terminal terminal
|
||||
left join latest_started started on started.tool_name = terminal.tool_name;
|
||||
|
||||
with latest_terminal as materialized (
|
||||
select distinct on (tr.tool_name)
|
||||
tr.tool_name,
|
||||
tr.status,
|
||||
tr.executed_methods
|
||||
from public.agentic_rectification_tool_receipts tr
|
||||
where tr.turn_id = p_turn_id
|
||||
and tr.status in ('completed', 'failed')
|
||||
and (
|
||||
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||
or (v_attempt_id is null and tr.attempt_id is null)
|
||||
)
|
||||
order by tr.tool_name, tr.started_at desc, tr.id desc
|
||||
)
|
||||
select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods
|
||||
from (
|
||||
select distinct jsonb_array_elements_text(terminal.executed_methods) as method
|
||||
from latest_terminal terminal
|
||||
where terminal.status = 'completed'
|
||||
) methods;
|
||||
|
||||
select tr.engine_version into v_engine_version
|
||||
from public.agentic_rectification_tool_receipts tr
|
||||
where tr.turn_id = p_turn_id and tr.engine_version is not null
|
||||
and tr.status = 'completed'
|
||||
and (
|
||||
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
|
||||
or (v_attempt_id is null and tr.attempt_id is null)
|
||||
)
|
||||
order by tr.started_at desc, tr.id desc
|
||||
limit 1;
|
||||
|
||||
return jsonb_build_object(
|
||||
'turn_id', v_turn.id,
|
||||
'attempt_id', v_attempt_id,
|
||||
'status', v_turn.status,
|
||||
'skill_name', v_case.skill_name,
|
||||
'skill_version', v_case.skill_version,
|
||||
'engine_version', v_engine_version,
|
||||
'phases', v_phases,
|
||||
'tool_activities', v_tool_activities,
|
||||
'tools', v_tools,
|
||||
'methods', v_methods,
|
||||
'started_at', v_turn.created_at,
|
||||
'completed_at', v_turn.completed_at
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
commit;
|
||||
@@ -120,6 +120,52 @@ test("block_scan RPCs are service_role-only and advance a declared period", { sk
|
||||
assert.equal(opened.error, null, rpcError(opened.error));
|
||||
const caseId = String((opened.data as Record<string, unknown>).case_id);
|
||||
|
||||
// BUG-984: identities belong to completed results, never deployment/start rows.
|
||||
// All rows are synthetic; the timestamp/id order, not the version text, wins.
|
||||
const turnId = fixture.psql(`
|
||||
insert into public.agentic_rectification_turns (case_id, status, model_name, assistant_message)
|
||||
values ('${caseId}', 'completed', 'fixture', 'fixture') returning id;
|
||||
`).split("\n")[0];
|
||||
const receipt = async (owner = userId, turn = turnId) => service.rpc("get_agentic_rectification_turn_receipt", {
|
||||
p_user_id: owner, p_case_id: caseId, p_turn_id: turn,
|
||||
});
|
||||
const addReceipt = (id: string, status: string, version: string | null, second: number, attempt: string | null = null, tool = "rectification-compare-candidates") => fixture.psql(`
|
||||
insert into public.agentic_rectification_tool_receipts
|
||||
(id, case_id, turn_id, tool_name, public_phase, status, engine_version, started_at, attempt_id)
|
||||
values ('${id}', '${caseId}', '${turnId}', '${tool}', 'candidates.updated', '${status}',
|
||||
${version ? `'${version}'` : "null"}, '2026-09-20 00:00:${String(second).padStart(2, "0")}+00',
|
||||
${attempt ? `'${attempt}'` : "null"});
|
||||
`);
|
||||
const version = (n: number) => `rectification-v5-matrix-scoring-${n}`;
|
||||
addReceipt("10000000-0000-4000-8000-000000000001", "started", version(8), 1);
|
||||
addReceipt("10000000-0000-4000-8000-000000000002", "completed", version(7), 2);
|
||||
assert.equal((await receipt()).data?.engine_version, version(7));
|
||||
addReceipt("10000000-0000-4000-8000-000000000003", "completed", version(9), 3);
|
||||
addReceipt("10000000-0000-4000-8000-000000000004", "completed", version(10), 4, null, "rectification-read-diagnostics");
|
||||
addReceipt("10000000-0000-4000-8000-000000000005", "failed", version(99), 5);
|
||||
assert.equal((await receipt()).data?.engine_version, version(10));
|
||||
// Same timestamp: stable id tie-break, independent of insertion/lexical version order.
|
||||
addReceipt("10000000-0000-4000-8000-000000000006", "completed", version(11), 4);
|
||||
assert.equal((await receipt()).data?.engine_version, version(11));
|
||||
const attempt1 = fixture.psql(`
|
||||
insert into public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number, status, completed_at)
|
||||
values ('${caseId}', '${turnId}', 1, 'completed', now()) returning id;
|
||||
`).split("\n")[0];
|
||||
addReceipt("10000000-0000-4000-8000-000000000007", "completed", version(10), 6, attempt1);
|
||||
fixture.psql(`update public.agentic_rectification_turns set successful_attempt_id = '${attempt1}' where id = '${turnId}'`);
|
||||
const attempt2 = fixture.psql(`
|
||||
insert into public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number, status, completed_at)
|
||||
values ('${caseId}', '${turnId}', 2, 'failed', now()) returning id;
|
||||
`).split("\n")[0];
|
||||
addReceipt("10000000-0000-4000-8000-000000000008", "failed", version(99), 7, attempt2);
|
||||
assert.equal((await receipt()).data?.engine_version, version(10), "failed retry cannot replace successful attempt");
|
||||
fixture.psql(`update public.agentic_rectification_turns set successful_attempt_id = null where id = '${turnId}'`);
|
||||
assert.equal((await receipt()).data?.engine_version, null, "latest failed-only attempt has no result provenance");
|
||||
assert.match(rpcError((await receipt("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")).error), /case_not_found/);
|
||||
assert.match(rpcError((await receipt(userId, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")).error), /turn_not_found/);
|
||||
assert.equal(fixture.psql(`select engine_version from public.agentic_rectification_tool_receipts where id = '10000000-0000-4000-8000-000000000002'`), version(7), "historical rows are not relabelled");
|
||||
assert.equal(fixture.psql(`select has_function_privilege('authenticated', 'public.get_agentic_rectification_turn_receipt(uuid,uuid,uuid)', 'EXECUTE')`), "f");
|
||||
|
||||
const staged = await service.rpc("set_agentic_rectification_case_stage", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
|
||||
+5085
File diff suppressed because it is too large
Load Diff
@@ -735,14 +735,16 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.doesNotMatch(chat, /send\("message", choiceCardUserMessage/);
|
||||
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
|
||||
assert.doesNotMatch(chat, /choiceCardUserMessage/);
|
||||
assert.match(caseProjection, /choice_card: choiceCardFromCaseDossier/);
|
||||
// 原值: 直接卡片投影;新值: 只读时 null;原因: BUG-984 不能选择旧结果。
|
||||
assert.match(caseProjection, /choice_card: identity\.read_only \? null : choiceCardFromCaseDossier/);
|
||||
// 原值: 容器 map 用 `choiceCard && choiceCard.focus_id === question.focus_id` 和 `disabled={!liveQuestion}`。
|
||||
// 新值: 行组件用 liveChoiceCard / liveQuestion;容器的 live 卡判定仍比对 latestLiveQuestion。
|
||||
// 原因: BUG-725。
|
||||
assert.match(messageEntry, /liveChoiceCard\s+&& liveChoiceCard\.focus_id === question\.focus_id/);
|
||||
assert.match(messageEntry, /disabled=\{!liveQuestion\}/);
|
||||
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === latestLiveQuestion\.focus_id/);
|
||||
assert.match(caseProjection, /current_question: projectCurrentQuestion/);
|
||||
// 原值: 无条件问题;新值: 只读不提供可写问题;原因: BUG-984。
|
||||
assert.match(caseProjection, /current_question: identity\.read_only \? null : projectCurrentQuestion/);
|
||||
assert.match(caseProjection, /overlayPublicDecision/);
|
||||
assert.match(caseProjection, /const fields = publicDecisionFields\(decision\)/);
|
||||
// 原值: 断言读 cases/[caseId]/route.ts 的 interview 投影
|
||||
|
||||
@@ -85,7 +85,9 @@ const EVIDENCE = [
|
||||
function blockPayload(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
evidence_ledger_fingerprint: "f".repeat(64),
|
||||
algorithm_version: "test",
|
||||
// Explicit fixture identity matches the test deployment; no unknown identity bypass.
|
||||
algorithm_version: "rectification-v5",
|
||||
decision_policy_version: "rectification-candidate-policy-v2",
|
||||
minute_step: 10,
|
||||
blocks: [...BLOCKS],
|
||||
...extra,
|
||||
|
||||
@@ -366,6 +366,8 @@ test("persistNextInterviewAfterChoice after family denial asks the invite, not o
|
||||
};
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
// 新增服务端身份重读 fixture;原采集/提示断言不变。
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: FOCUS_ID,
|
||||
|
||||
@@ -206,7 +206,8 @@ const ENGINE_SCORE = {
|
||||
success: true,
|
||||
endpoint: "rectification_v5_score",
|
||||
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
|
||||
algorithm_version: "rectification-event-contract-v2",
|
||||
// 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同仍独立保留。
|
||||
algorithm_version: "rectification-v5",
|
||||
event_contract_version: "rectification-event-contract-v2",
|
||||
decision_policy_version: "rectification-candidate-policy-v2",
|
||||
execution_ledger_version: "rectification-execution-ledger-v2",
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
v9EngineVersion,
|
||||
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
|
||||
import { openRectificationCase } from "../src/lib/rectification-agentic/v9/case-service.ts";
|
||||
import { loadV9TurnReceipt } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { loadV9TurnReceipt, parseV9CaseDossier, evidenceLedgerFingerprint, candidateRangeFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { scoreAndPersistCurrentEvidence } from "../src/lib/rectification-agentic/v9/score-persist.ts";
|
||||
import { acceptV9Candidate, confirmV9BirthTime, advanceV9CaseFromBlockScan } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { assertV9ResultWritable, resultIdentityView } from "../src/lib/rectification-agentic/v9/result-identity.ts";
|
||||
import { ensureNonTerminalTurnExit, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { dossierResponseWithIdentity } from "../src/lib/rectification-agentic/v9/case-dossier-response.ts";
|
||||
import { recompareHistoricalResult } from "../src/lib/rectification-surface-state.ts";
|
||||
import { resolveSkillPackageVersion } from "../src/lib/skill-package-registry.ts";
|
||||
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
@@ -80,7 +86,7 @@ test("native versions invalidate matching-input old scores while same-version sc
|
||||
assert.equal(previous.algorithmVersion, PREVIOUS);
|
||||
});
|
||||
|
||||
test("policy-only or stale algorithm overrides bypass native versions under the existing cache policy", async (t) => {
|
||||
test("partial override is completed by native identity; complete override needs no request", async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, "fetch", async () => {
|
||||
@@ -91,22 +97,245 @@ test("policy-only or stale algorithm overrides bypass native versions under the
|
||||
const stored = { ...fingerprints, algorithmVersion: PREVIOUS, policyVersion: golden.versions.decision_policy_version };
|
||||
process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version;
|
||||
const policyOnly = await readV9EngineScoringIdentity();
|
||||
assert.equal(policyOnly.algorithmVersion, null);
|
||||
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, policyOnly), true);
|
||||
// 原值 null/true;新值 CURRENT/false;原因:部分覆盖不能绕过实际算法身份。
|
||||
assert.equal(policyOnly.algorithmVersion, CURRENT);
|
||||
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, policyOnly), false);
|
||||
process.env.RECTIFICATION_ALGORITHM_VERSION = PREVIOUS;
|
||||
const staleOverride = await readV9EngineScoringIdentity();
|
||||
assert.equal(staleOverride.algorithmVersion, PREVIOUS);
|
||||
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, staleOverride), true);
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(fetches, 1); // 原值 0;部分覆盖必须补齐,完整覆盖仍不请求。
|
||||
});
|
||||
|
||||
test("unreachable versions retain the existing unknown-identity cache fallback, not proof of rescore", async (t) => {
|
||||
test("unreachable versions never validate a cached result as current", async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("fixture versions unavailable"); });
|
||||
const live = await readV9EngineScoringIdentity();
|
||||
assert.deepEqual(live, { algorithmVersion: null, policyVersion: null });
|
||||
const fingerprints = { evidenceLedgerFingerprint: "e".repeat(64), candidateRangeFingerprint: "c".repeat(64) };
|
||||
assert.equal(cachedEngineScoreIsReusable({ ...fingerprints, algorithmVersion: PREVIOUS }, fingerprints, live), true);
|
||||
// 原值 true;新值 false;原因:BUG-984 策略 b,只读不是当前缓存命中。
|
||||
assert.equal(cachedEngineScoreIsReusable({ ...fingerprints, algorithmVersion: PREVIOUS }, fingerprints, live), false);
|
||||
});
|
||||
|
||||
for (const storedVersion of [PREVIOUS, CURRENT]) {
|
||||
test(`native late-night block cache checks actual identity: ${storedVersion}`, async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8"));
|
||||
const range = { start_time: block.request.start_time, end_time: block.request.end_time };
|
||||
const raw = dossierFixture({ stage: "block_scan", candidateRange: range, latestResult: null });
|
||||
const fingerprint = evidenceLedgerFingerprint(parseV9CaseDossier(raw)!.evidence);
|
||||
raw.case.block_scan = {
|
||||
...block.response, algorithm_version: storedVersion,
|
||||
decision_policy_version: block.response.decision_receipt.decision_policy_version,
|
||||
evidence_ledger_fingerprint: fingerprint,
|
||||
candidate_range_fingerprint: candidateRangeFingerprint(range, "a".repeat(64)),
|
||||
};
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
|
||||
write_agentic_rectification_block_scan: (_fn, args) => { raw.case.block_scan = args.p_block_scan as Record<string, unknown>; return {}; },
|
||||
});
|
||||
const requests: string[] = [];
|
||||
t.mock.method(globalThis, "fetch", async (url: unknown) => {
|
||||
requests.push(String(url));
|
||||
return Response.json(String(url).endsWith("/versions") ? golden.versions : block.response);
|
||||
});
|
||||
const opened = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never);
|
||||
assert.equal(opened.case.result_identity.can_recompare, storedVersion === PREVIOUS);
|
||||
assert.equal(opened.case.result_identity.read_only, storedVersion === PREVIOUS);
|
||||
const result = await scoreAndPersistCurrentEvidence({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID });
|
||||
assert.equal(result.persisted.algorithmVersion, CURRENT);
|
||||
assert.equal(result.persisted.cached, storedVersion === CURRENT);
|
||||
assert.equal(requests.filter((url) => url.endsWith("/block_scan")).length, storedVersion === CURRENT ? 0 : 1);
|
||||
assert.ok(requests.some((url) => url.endsWith("/versions")));
|
||||
});
|
||||
}
|
||||
|
||||
for (const storedVersion of [PREVIOUS, CURRENT]) {
|
||||
test(`native minute cache checks actual identity: ${storedVersion}`, async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
const range = { start_time: golden.request.start_time, end_time: golden.request.end_time };
|
||||
const raw = dossierFixture({ candidateRange: range, latestResult: null });
|
||||
raw.latest_result = {
|
||||
...golden.score, ...golden.score.decision_receipt,
|
||||
result_id: golden.score.result_id, candidates: golden.score.candidate_decisions,
|
||||
algorithm_version: storedVersion, decision_receipt: golden.score.decision_receipt,
|
||||
evidence_ledger_fingerprint: evidenceLedgerFingerprint(parseV9CaseDossier(raw)!.evidence),
|
||||
candidate_range_fingerprint: candidateRangeFingerprint(range, "a".repeat(64)),
|
||||
};
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
|
||||
persist_agentic_rectification_candidate_v2: (_fn, args) => ({
|
||||
...golden.score.decision_receipt, result_id: args.p_engine_result_id,
|
||||
candidates: args.p_candidates, algorithm_version: args.p_algorithm_version,
|
||||
decision_policy_version: args.p_decision_policy_version, decision_receipt: args.p_decision_receipt,
|
||||
}),
|
||||
});
|
||||
const requests: string[] = [];
|
||||
t.mock.method(globalThis, "fetch", async (url: unknown) => {
|
||||
requests.push(String(url));
|
||||
assert.ok(String(url).endsWith("/versions") || String(url).endsWith("/score"));
|
||||
return Response.json(String(url).endsWith("/versions") ? golden.versions : golden.score);
|
||||
});
|
||||
const opened = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never);
|
||||
assert.equal(opened.case.result_identity.can_recompare, storedVersion === PREVIOUS);
|
||||
assert.equal(opened.case.result_identity.read_only, storedVersion === PREVIOUS);
|
||||
const result = await scoreAndPersistCurrentEvidence({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, keepAnswers: true });
|
||||
assert.equal(result.persisted.algorithmVersion, CURRENT);
|
||||
assert.equal(result.persisted.cached, storedVersion === CURRENT);
|
||||
assert.equal(requests.filter((url) => url.endsWith("/score")).length, storedVersion === CURRENT ? 0 : 1);
|
||||
assert.ok(requests.some((url) => url.endsWith("/versions")));
|
||||
});
|
||||
}
|
||||
|
||||
test("stale history exposes explicit recompare and real tools replace old provenance", async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8"));
|
||||
const range = { start_time: block.request.start_time, end_time: block.request.end_time };
|
||||
const raw = dossierFixture({ stage: "block_scan", candidateRange: range, latestResult: null, blockScan: { ...block.response, algorithm_version: PREVIOUS } });
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
|
||||
write_agentic_rectification_block_scan: (_fn, args) => { raw.case.block_scan = args.p_block_scan as Record<string, unknown>; return {}; },
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_domain: args.p_target_domain, expected_answer_schema: args.p_expected_answer_schema, status: "active" }, idempotent: false }),
|
||||
});
|
||||
let scans = 0;
|
||||
t.mock.method(globalThis, "fetch", async (url: unknown) => {
|
||||
if (String(url).endsWith("/versions")) return Response.json(golden.versions);
|
||||
assert.ok(String(url).endsWith("/block_scan"));
|
||||
scans++;
|
||||
return Response.json(block.response);
|
||||
});
|
||||
const tools = createRectificationV9Tools({ accounting: accounting.client as never, userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID });
|
||||
const read = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> }).execute({ caseId: CASE_ID });
|
||||
assert.equal(read.can_recompare, true);
|
||||
assert.equal((read.next_action as Record<string, unknown>).type, "compare_candidates");
|
||||
assert.equal(scans, 0, "opening history is not forced recomputation");
|
||||
await (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID });
|
||||
assert.equal(scans, 1);
|
||||
const refreshed = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never);
|
||||
assert.equal(refreshed.case.result_identity.read_only, false);
|
||||
assert.equal(refreshed.case.result_identity.algorithm_version, CURRENT);
|
||||
assert.equal(refreshed.case.result_identity.policy_version, golden.versions.decision_policy_version);
|
||||
const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(component, /resultNotice && canRecompare && !historyReadonly/);
|
||||
assert.match(component, /recompareHistoricalResult\(\{ canRecompare, historyReadonly, busy, send \}\)/);
|
||||
assert.match(component, /if \(\(action === "message" && !trimmed\) \|\| conversationReadonly\) return/);
|
||||
const sent: string[] = [];
|
||||
const send = async (action: "message", text: string) => { sent.push(`${action}:${text}`); };
|
||||
await recompareHistoricalResult({ canRecompare: true, historyReadonly: false, busy: false, send });
|
||||
assert.deepEqual(sent, ["message:请按当前算法重新比较候选。"]);
|
||||
for (const state of [{ canRecompare: false, historyReadonly: false, busy: false }, { canRecompare: true, historyReadonly: true, busy: false }, { canRecompare: true, historyReadonly: false, busy: true }]) {
|
||||
await recompareHistoricalResult({ ...state, send });
|
||||
}
|
||||
assert.equal(sent.length, 1, "unknown identity, terminal history and busy clicks cannot dispatch");
|
||||
});
|
||||
|
||||
test("partial conflicts and incomplete native pairs never authorize reuse", async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
process.env.RECTIFICATION_ALGORITHM_VERSION = PREVIOUS;
|
||||
t.mock.method(globalThis, "fetch", async () => Response.json(golden.versions));
|
||||
assert.deepEqual(await readV9EngineScoringIdentity(), { algorithmVersion: null, policyVersion: null });
|
||||
delete process.env.RECTIFICATION_ALGORITHM_VERSION;
|
||||
t.mock.method(globalThis, "fetch", async () => Response.json({ algorithm_version: CURRENT }));
|
||||
const incomplete = await readV9EngineScoringIdentity();
|
||||
assert.equal(incomplete.policyVersion, null);
|
||||
assert.equal(cachedEngineScoreIsReusable({ algorithmVersion: CURRENT, evidenceLedgerFingerprint: "e", candidateRangeFingerprint: "r" }, { evidenceLedgerFingerprint: "e", candidateRangeFingerprint: "r" }, incomplete), false);
|
||||
});
|
||||
|
||||
for (const stage of ["minute", "block_scan"] as const) {
|
||||
for (const failure of ["http", "timeout", "partial"] as const) {
|
||||
test(`${stage} ${failure}: historical result stays read-only without recompute; server mutations reject`, async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
if (failure === "partial") process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version;
|
||||
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8"));
|
||||
const range = { start_time: golden.request.start_time, end_time: golden.request.end_time };
|
||||
const raw = dossierFixture({ stage, candidateRange: range, latestResult: {
|
||||
...golden.score, ...golden.score.decision_receipt,
|
||||
result_id: golden.score.result_id, candidates: golden.score.candidate_decisions,
|
||||
algorithm_version: PREVIOUS, decision_receipt: golden.score.decision_receipt,
|
||||
}, blockScan: { ...block.response, algorithm_version: PREVIOUS } });
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => raw,
|
||||
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
|
||||
});
|
||||
const requests: string[] = [];
|
||||
t.mock.method(globalThis, "fetch", async (url: unknown) => {
|
||||
requests.push(String(url));
|
||||
assert.ok(String(url).endsWith("/versions"), "no scoring/oracle call on identity failure");
|
||||
if (failure === "timeout") throw new DOMException("fixture timeout", "TimeoutError");
|
||||
return Response.json({ error: "fixture_unavailable" }, { status: 503 });
|
||||
});
|
||||
const input = { accounting: accounting.client, userId: USER_ID, caseId: CASE_ID };
|
||||
const result = await scoreAndPersistCurrentEvidence(input);
|
||||
assert.equal(result.readOnly, true);
|
||||
assert.equal(result.persisted.cached, false);
|
||||
assert.equal(result.persisted.algorithmVersion, PREVIOUS);
|
||||
assert.equal(result.persisted.selectionAllowed, false);
|
||||
assert.equal(result.persisted.confirmationAllowed, false);
|
||||
const response = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], {
|
||||
status: "verified", requiresSkillAdoption: false,
|
||||
} as never);
|
||||
assert.match(response.case.result_notice ?? "", /按旧算法产出/);
|
||||
assert.equal(response.case.result_identity.algorithm_version, PREVIOUS);
|
||||
assert.equal(response.case.result_identity.can_recompare, false);
|
||||
assert.equal(response.choice_card, null);
|
||||
const candidateId = golden.score.candidate_decisions[0].candidate_id;
|
||||
await assert.rejects(acceptV9Candidate(accounting.client, USER_ID, CASE_ID, golden.score.result_id, candidateId, TURN_ID), /result_identity_read_only/);
|
||||
await assert.rejects(confirmV9BirthTime(accounting.client, USER_ID, CASE_ID, {
|
||||
resultId: golden.score.result_id, candidateId, requestId: TURN_ID, consentQuote: "fixture", sourceTurnId: TURN_ID,
|
||||
}), /result_identity_read_only/);
|
||||
await assert.rejects(advanceV9CaseFromBlockScan(accounting.client, USER_ID, CASE_ID, range), /result_identity_read_only/);
|
||||
const tools = createRectificationV9Tools({ ...input, turnId: TURN_ID, accounting: accounting.client as never });
|
||||
const projection = await (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<{ read_only: boolean }> }).execute({ caseId: CASE_ID });
|
||||
assert.equal(projection.read_only, true);
|
||||
const completed = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_tool_receipt" && call.args.p_status === "completed");
|
||||
assert.equal(completed?.args.p_engine_version, PREVIOUS);
|
||||
const readProjection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> }).execute({ caseId: CASE_ID });
|
||||
assert.equal(readProjection.read_only, true);
|
||||
assert.equal((readProjection.candidate_summary as Record<string, unknown>).selection_allowed, false);
|
||||
const diagnostics = await (tools["rectification-read-diagnostics"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> }).execute({ caseId: CASE_ID });
|
||||
assert.equal(diagnostics.read_only, true);
|
||||
assert.match(String(diagnostics.notice), /按旧算法产出/);
|
||||
assert.equal(diagnostics.algorithm_version, PREVIOUS);
|
||||
assert.equal(diagnostics.can_confirm_exact_minute, false);
|
||||
assert.equal(diagnostics.diagnostics, null);
|
||||
assert.deepEqual(diagnostics.executed_methods, []);
|
||||
const diagnosticsReceipt = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_tool_receipt" && call.args.p_tool_name === "rectification-read-diagnostics" && call.args.p_status === "completed");
|
||||
assert.equal(diagnosticsReceipt?.args.p_engine_version, PREVIOUS);
|
||||
await assert.rejects((tools["rectification-offer-candidates"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID }), /result_identity_read_only/);
|
||||
assert.equal((await ensureNonTerminalTurnExit(input)).persisted, false);
|
||||
assert.equal((await persistNextInterviewIfIdle(input)).persisted, false);
|
||||
assert.ok(accounting.calls.every((call) => call.fn.startsWith("get_") || call.fn === "insert_agentic_rectification_tool_receipt"), "no candidate, focus, inference or validation writes");
|
||||
assert.ok(requests.length > 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("missing stage source, absent results and nonlatest result IDs cannot authorize candidate writes", async (t) => {
|
||||
isolateIdentityEnv(t);
|
||||
process.env.RECTIFICATION_ALGORITHM_VERSION = CURRENT;
|
||||
process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version;
|
||||
const latest = { ...golden.score, result_id: golden.score.result_id, candidates: golden.score.candidate_decisions };
|
||||
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8")).response;
|
||||
for (const raw of [
|
||||
dossierFixture({ stage: "block_scan", latestResult: latest, blockScan: null }),
|
||||
dossierFixture({ stage: "minute", latestResult: null, blockScan: block }),
|
||||
dossierFixture({ stage: "minute", latestResult: null, blockScan: null }),
|
||||
dossierFixture({ stage: "minute", latestResult: latest }),
|
||||
]) {
|
||||
const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => raw });
|
||||
await assert.rejects(assertV9ResultWritable(accounting.client, USER_ID, CASE_ID, TURN_ID), /result_identity_read_only/);
|
||||
assert.ok(accounting.calls.every((call) => call.fn.startsWith("get_")));
|
||||
}
|
||||
const empty = dossierFixture({ latestResult: null, blockScan: null });
|
||||
const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => empty });
|
||||
await assertV9ResultWritable(accounting.client, USER_ID, CASE_ID); // ordinary new-case collection remains allowed
|
||||
await assert.rejects(assertV9ResultWritable(accounting.client, USER_ID, CASE_ID, null), /result_identity_read_only/);
|
||||
assert.equal(resultIdentityView(parseV9CaseDossier(empty)!, { algorithmVersion: null, policyVersion: null }).read_only, false);
|
||||
});
|
||||
|
||||
async function runGoldenToolSequence(t: test.TestContext, options: {
|
||||
@@ -164,7 +393,18 @@ async function runGoldenToolSequence(t: test.TestContext, options: {
|
||||
for (const name of ["rectification-compare-candidates", "rectification-read-diagnostics"] as const) {
|
||||
const execute = () => (tools[name] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID });
|
||||
if (options.diagnosticsFailure && name === "rectification-read-diagnostics") await assert.rejects(execute);
|
||||
else await execute();
|
||||
else {
|
||||
const projection = await execute() as { read_only?: boolean; algorithm_version?: string; can_confirm_exact_minute?: boolean };
|
||||
if (name === "rectification-compare-candidates" && options.compareVersion && options.compareVersion !== CURRENT) {
|
||||
assert.equal(projection.read_only, true, "rolling response mismatch stays read-only after saving true provenance");
|
||||
assert.equal(projection.algorithm_version, options.compareVersion);
|
||||
}
|
||||
if (name === "rectification-read-diagnostics" && options.diagnosticsVersion && options.diagnosticsVersion !== CURRENT) {
|
||||
assert.equal(projection.read_only, true);
|
||||
assert.equal(projection.algorithm_version, options.diagnosticsVersion);
|
||||
assert.equal(projection.can_confirm_exact_minute, false, "rolling diagnostics cannot claim current validation");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(calls.includes("/api/rectification/v5/score"));
|
||||
const persisted = accounting.calls.find((call) => call.fn === "persist_agentic_rectification_candidate_v2");
|
||||
@@ -187,7 +427,7 @@ test("failed diagnostics do not claim an engine result identity", async (t) => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("BUG-984 blocker: real tool sequence permits mixed completed identities in one turn", async (t) => {
|
||||
test("mixed completed identities require chronological aggregation (covered by database regression)", async (t) => {
|
||||
// Identity-only mutation models rolling backend versions; all response shape and values
|
||||
// remain the real native golden. This is not a claim to have run future algorithms.
|
||||
const version9 = "rectification-v5-matrix-scoring-9";
|
||||
@@ -196,8 +436,8 @@ test("BUG-984 blocker: real tool sequence permits mixed completed identities in
|
||||
const completed = receipts.filter((call) => call.args.p_status === "completed");
|
||||
assert.equal(new Set(completed.map((call) => call.args.p_turn_id)).size, 1);
|
||||
assert.deepEqual(completed.map((call) => call.args.p_engine_version), [version9, version10]);
|
||||
// Preserve the blocker as positive evidence, not a false passing acceptance test.
|
||||
// F2 acceptance must replace this diagnostic once the approved aggregate fix lands.
|
||||
// This proves why string max is invalid; database-rectification-block-scan.test.ts
|
||||
// executes the authorized aggregate migration and verifies chronological selection.
|
||||
assert.equal([version9, version10].sort().at(-1), version9);
|
||||
assert.notEqual([version9, version10].sort().at(-1), completed.at(-1)?.args.p_engine_version);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,8 @@ const ENGINE_SCORE = {
|
||||
success: true,
|
||||
endpoint: "rectification_v5_score",
|
||||
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
|
||||
algorithm_version: "rectification-event-contract-v2",
|
||||
// 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同不变。
|
||||
algorithm_version: "rectification-v5",
|
||||
event_contract_version: "rectification-event-contract-v2",
|
||||
decision_policy_version: "rectification-candidate-policy-v2",
|
||||
execution_ledger_version: "rectification-execution-ledger-v2",
|
||||
|
||||
@@ -74,7 +74,8 @@ test("question-on-turn migration adds asked_turn_id and answer_option", () => {
|
||||
|
||||
test("GET turns attach question by asked_turn_id and never by timestamps", () => {
|
||||
assert.match(caseProjection, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/);
|
||||
assert.match(caseProjection, /question: turn\.question/);
|
||||
// 原值 turn.question;新值只读 null;原因 BUG-984,仍以 asked_turn_id 绑定历史。
|
||||
assert.match(caseProjection, /question: identity\.read_only \? null : turn\.question/);
|
||||
assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/);
|
||||
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
|
||||
// 原值: if (!focus.askedTurnId) continue
|
||||
|
||||
@@ -516,7 +516,8 @@ test("list focuses RPC error marks GET questions unavailable and warns", async (
|
||||
// 新值: 投影在 case-dossier-response.ts,GET 仍 return NextResponse.json(dossierResponse)
|
||||
// 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。
|
||||
assert.match(caseDossierResponse, /question_source: questionSourceFromFocusList\(listed\)/);
|
||||
assert.match(casesRoute, /return NextResponse\.json\(dossierResponse/);
|
||||
// 原值同步 dossierResponse;新值 await 身份投影;原因 BUG-984 历史刷新同样核验。
|
||||
assert.match(casesRoute, /return NextResponse\.json\(await dossierResponseWithIdentity/);
|
||||
});
|
||||
|
||||
test("walkthrough-shaped chain rebuilds each assistant question from asked_turn_id", () => {
|
||||
|
||||
@@ -217,7 +217,8 @@ test("missing current_question is explicit only for resumable cases", () => {
|
||||
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
|
||||
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /readonly && \(/);
|
||||
// 原值 readonly;新值 historyReadonly;原因:结果身份只读不等于 Case 已结束。
|
||||
assert.match(chat, /historyReadonly && \(/);
|
||||
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
|
||||
});
|
||||
|
||||
|
||||
@@ -638,7 +638,8 @@ test("accept-candidate requires a server-persisted result; no tool means no minu
|
||||
resultId: RESULT_ID,
|
||||
candidateId: CANDIDATE_ID,
|
||||
}),
|
||||
(error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"),
|
||||
// 原值 candidate_not_found;新值 result_identity_read_only;原因:无来源在写 RPC 前即拒绝,仍不得产生候选。
|
||||
(error: unknown) => error instanceof Error && error.message.includes("result_identity_read_only"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
|
||||
test("accepted is never upgraded to confirmed by the accept path", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
accept_agentic_rectification_candidate_for_case_v2: () => ({
|
||||
success: true,
|
||||
saved_time: "05:02",
|
||||
@@ -56,6 +57,7 @@ test("accepted is never upgraded to confirmed by the accept path", async () => {
|
||||
|
||||
test("accept fails closed if the accept-only RPC reports confirmed", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
accept_agentic_rectification_candidate_for_case_v2: () => ({
|
||||
success: true,
|
||||
saved_time: "05:02",
|
||||
@@ -76,6 +78,7 @@ test("accept fails closed if the accept-only RPC reports confirmed", async () =>
|
||||
test("confirmed requires the engine gate plus explicit grounded consent", async () => {
|
||||
// confirmation_allowed=false on the stored result blocks confirmation.
|
||||
const blocked = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
confirm_agentic_rectification_candidate_for_case_v2: () => {
|
||||
throw new Error("agentic_rectification_confirmation_blocked");
|
||||
},
|
||||
@@ -93,6 +96,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
|
||||
|
||||
// Confirming a time that is not the representative minute is rejected.
|
||||
const mismatch = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
confirm_agentic_rectification_candidate_for_case_v2: () => {
|
||||
throw new Error("agentic_rectification_confirm_time_mismatch");
|
||||
},
|
||||
@@ -110,6 +114,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
|
||||
|
||||
// Consent quote must be grounded in the source turn's message.
|
||||
const ungrounded = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
confirm_agentic_rectification_candidate_for_case_v2: () => {
|
||||
throw new Error("agentic_rectification_consent_not_grounded");
|
||||
},
|
||||
@@ -178,6 +183,7 @@ test("confirm-birth-time binds consent to the server-owned current turn", async
|
||||
|
||||
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
accept_agentic_rectification_candidate_for_case_v2: () => ({
|
||||
success: true,
|
||||
saved_time: "05:02",
|
||||
@@ -200,6 +206,7 @@ test("candidate ownership is case-scoped: the RPC always receives the case id",
|
||||
|
||||
test("accept is idempotent: replaying the same selection succeeds without a second write", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
|
||||
accept_agentic_rectification_candidate_for_case_v2: () => ({
|
||||
success: true,
|
||||
saved_time: "05:02",
|
||||
@@ -742,7 +749,15 @@ test("compare-candidates reuses a matching fingerprint without calling the engin
|
||||
}
|
||||
});
|
||||
|
||||
test("compare-candidates rescores when stored policy lags the live engine", async () => {
|
||||
test("compare-candidates rescores when stored policy lags the live engine", async (t) => {
|
||||
const algorithm = process.env.RECTIFICATION_ALGORITHM_VERSION;
|
||||
const policy = process.env.RECTIFICATION_DECISION_POLICY_VERSION;
|
||||
delete process.env.RECTIFICATION_ALGORITHM_VERSION;
|
||||
delete process.env.RECTIFICATION_DECISION_POLICY_VERSION;
|
||||
t.after(() => {
|
||||
process.env.RECTIFICATION_ALGORITHM_VERSION = algorithm;
|
||||
process.env.RECTIFICATION_DECISION_POLICY_VERSION = policy;
|
||||
});
|
||||
const rawDossier = dossierFixture();
|
||||
const parsed = parseV9CaseDossier(rawDossier);
|
||||
const compute = parseV9ComputeProjection(computeFixture());
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
|
||||
|
||||
// Unit fixtures below describe this explicit engine/policy pair. Production now
|
||||
// requires both; identity failure tests clear these and exercise real discovery.
|
||||
process.env.RECTIFICATION_ALGORITHM_VERSION = "rectification-v5";
|
||||
process.env.RECTIFICATION_DECISION_POLICY_VERSION = "rectification-candidate-policy-v2";
|
||||
|
||||
export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier";
|
||||
export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user