Compare commits

..

2 Commits

Author SHA1 Message Date
Jesse_Chen f127ada413 docs(rectification): add adopt-flow fix task brief (exit fallback, 改选, opening domain, probe_year) 2026-09-03 01:40:56 +00:00
Jesse_Chen 35e5781e66 fix(rectification): gate collect-phase adopt cards and keep post-adopt verify answerable
Independent Staging Quality Gate / validate (push) Successful in 9m50s
Independent Staging Quality Gate / publish (push) Has started running
Public can_adopt follows session_outcome; reverse_verify reuses the persisted question id; offer cards settle on the owning message with a status-bar handoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 09:29:34 +08:00
32 changed files with 1868 additions and 110 deletions
@@ -0,0 +1,93 @@
# 任务书 · 采用流程修复单:采集期出口兜底、「改选」可点、开场题不占 education、核对题年份持久化(2026-09-03)
基线:`origin/staging` `35e5781e``TASK-rectification-adopt-flow-20260902.md` 的实现,BUG-497~500)。本任务书是对该实现 review 后的修复单,**不改 `35e5781e` 已定的形态**(公开 `can_adopt` 按 session_outcome 收紧、accept 409、核对题沿用持久化 questionId、卡归出卡消息、状态条承接),只修 1 个 P1 和 3 个 P2。
## 0. Review 结论摘要
`35e5781e` 门禁全绿(tsc / lint 0 错 / 1109 测试 fail=0),两个 P0 的修法与任务书一致。但收紧公开 `can_adopt` 之后,**非终止轮出口检查还在看内部 `canAdopt`**,会把一种此前靠"漏出的采用卡"兜住的状态变成真正的死角;另外三处是形态没做完或副作用。
## 1. P1 · 采集期"无题、无卡、无出口"
**位置**`frontend/src/lib/rectification-agentic/v9/answer-choice.ts` `inspectNonTerminalTurnExit`(行号线索 ~992–1013,按符号名定位)。
**现象(静态推演)**`satisfied` 的最后一项是 `decision.canAdopt`(内部能力位)。当 `session_outcome = collect_evidence`、内部 `canAdopt = true``buildMethodFollowupPlan``next_followup` / `deferred_followup` 都为 null(题库耗尽)时:
1. `persistNextInterviewIfIdle` 走到 `if (!followup) return { persisted:false }`,不建题;
2. `ensureNonTerminalTurnExit``decision.canAdopt``satisfied = true`**不调用 `persistExhaustionCollect` 兜底**
3. 公开 `can_adopt` 现在为 false → 前端不出采用卡;`showCollectStop` 依赖活跃采集题 → 「先这样」也不出;
4. 用户只剩空 composer,随便发一句话再跑一轮回到同一状态。
`35e5781e` 之前这个状态会漏出采用卡(就是 BUG-497 的现象),所以没人碰到过死角;现在门关了,出口必须补上。
**修法**
1. `inspectNonTerminalTurnExit``satisfied``decision.canAdopt` 改成 `publicCanAdopt(decision)`(从 `core/rectification-decision` 导入)。语义:只有**用户在界面上真的能采用**时,"没有下一问"才算满足;否则走 `persistExhaustionCollect`(它建的题自带「先这样」,`answerChoice` 的 stop 路径会把 outcome 推到 `provisional_range_user_stopped`,公开 `can_adopt` 随之打开)。
2. `persistNextInterviewIfIdle``shouldSkipFollowupPersist({ canAdopt: decision.canAdopt, ... })` 同步改成 `publicCanAdopt(decision)`。现有 nextAction 白名单已保证采集/区分/holdout 不会跳过持久化,这一改只是让"跳过持久化、改出 adopt 旁白"与公开门一致,不得出现"旁白说可以采用、界面却没卡"的分叉。
3. 确认 `persistExhaustionCollect``collect_evidence` + 内部 `canAdopt = true` 下能返回一道题(`exhaustionSpokenCollectFollowup` 若也为 null,则至少给 `occupation` 兜底题——现有逻辑已有,验证即可)。
**测试**`tests/rectification-adopt-flow-fix-20260903.test.ts` 新建):
- 构造 dossier`sessionOutcome = collect_evidence`、内部 `canAdopt = true`、无 active focus、题库耗尽(evidence 覆盖所有 collect 域或 declined 全部)。断言 `ensureNonTerminalTurnExit` 返回 `persisted = true`,写入的 focus `intent = collect_method_evidence``projectCurrentQuestion(focus).kind === "collect_spoken"`,并有 `rectification_nonterminal_exit_repaired` warn。用 fake accounting,不需要真实环境。
- 对照:`sessionOutcome = adopt_representative``publicCanAdopt = true`)同样条件下 `satisfied = true`、不建题(保留既有语义)。
## 2. P2-a · 状态条「改选」是死的
**位置**`rectification-agentic-chat.tsx` 状态条 `<span className="rectification-adopt-status__link">改选</span>`~1501);`offerSectionRef`~439、~1438)赋了 ref 但没有任何 `scrollIntoView` 调用。
**修法**
1. 改成 `<button type="button" className="rectification-adopt-status__link">改选</button>`onClick`offerSectionRef.current?.scrollIntoView({ block: "center", behavior: "smooth" })`,并给该消息一次 `rectification-message-flash`globals.css 加一个 600ms 的背景闪烁 keyframe,复用现有 `.rectification-message-entry` 的色板)。
2. `showSelectionCards` 为 false(出卡消息还没重建,或 `readonly`)时不渲染「改选」。
3. `globals.css` `.rectification-adopt-status__link` 补 button reset(去边框/背景、光标 pointer、焦点环沿用 `.rectification-candidate` 的样式)。
4. 测试:源码锁 `rectification-adopt-status__link` 出现在 `<button` 上、文件内存在 `offerSectionRef.current?.scrollIntoView`;不做 DOM 测试。
## 3. P2-b · 开场题把 `education` 记成"已问/已拒"
**位置**`method-followup.ts` `OPENING_COLLECT_DOMAIN = "education"`~1068)、`collectQuestionDomain`、开场 followup `domain: OPENING_COLLECT_DOMAIN`~1800);`server-focus.ts` `persistCollectFocus``targetDomain` 兜底;`declinedDomains` / `domainCollectFocusAsked`~351/~366)。
**问题**:开场题是"随便一件带大概时间的经历",不是教育题。现在它以 `target_domain = education``questionId = collect:education:collect_method_evidence` 持久化:
- 用户对开场题点「先这样」或不答 → `declinedSkippedTopics` 里出现 `education``declinedDomains` 含 education → `exhaustionSpokenCollectFollowup` 跳过教育、D5 refine 的 `!declined.has("education")` 分支全部关闭;
- 用户答完开场题(说的是搬家)→ `domainCollectFocusAsked(rows, "education")` 为真 → 之后真正的教育采集题被打上 `collect_retry`,文案变成"再补一件"。
**修法**(不改 DB check 约束,`target_domain` 只能是 education/career/relationship/relocation/finance/health/family/other):
1. `OPENING_COLLECT_DOMAIN` 改为 `"other"``collectQuestionDomain(null | "unknown" | "active_focus")` 返回 `"other"`。stable id 变为 `collect:other:collect_method_evidence``persistableFocusDomain("other")` 本来就合法。
2. `declinedDomains``domainCollectFocusAsked` 忽略 `target_domain === "other"``questionId``collect:other:` 开头的行(occupation 走的是 `collect:occupation:` 前缀,不受影响;`occupationCollectFocusClosed` 已按 questionId 前缀判断,确认不回归)。
3. `tests/rectification-adopt-flow-20260902.test.ts` 里 "opening collect domain is education rather than unknown" 改为断言 `"other"`——三栏说明:旧 `education` → 新 `other` → 保留语义"不得为 unknown、stable id 不含 unknown"。
4. 新测试:closedCollectFocuses 里只有开场题(`collect:other:…`status `skipped`)时,`exhaustionSpokenCollectFollowup` 仍会给出 education 题;`buildMethodFollowupPlan` 的教育采集 followup 不带 `collect_retry`
## 4. P2-c · 核对题年份靠正则从题干文本抠
**位置**`method-followup.ts` `keepAcceptedFocus` 分支 `promptYear`~1592)——`text.match(/19\d{2}|20\d{2}/)`
**问题**`schema.probe_year` 缺失时才走到它,但它是"从已展示文案反推数据",与"不得用字符串反推状态"的红线同一个方向;一旦题干写了两个年份("2016 年上大学、2020 年毕业")就取错。
**修法**
1. 数据源头补齐:`server-focus.ts` `expectedAnswerSchemaFor` / `persistServerOwnedFocus``rectification-v9-tools.ts` set-focus 建 reverse_verify / out_of_sample_check focus 时,schema 必写 `probe_year`(取 `followup.probe_year`,来自匹配的 verify 探针;没有就不写,不得从题干抠)。
2. `keepAcceptedFocus``keepProbeYear = matchingVerify?.year || schemaYear`,删除 `promptYear` 兜底。年份仍缺时不给 `probe_year``validateSpokenPrompt``year_missing` 只在 `probe_year` 非空时触发(现有语义),不会误伤。
3. 测试:`rectification-question-ownership.test.ts` 加一条:采用后 set-focus 建的 reverse_verify focus`expectedAnswerSchema.probe_year` 等于探针年份;`keepAcceptedFocus` 在 schema 无 `probe_year`、无匹配探针时 followup 不带 `probe_year`,且 `method-followup.ts` 源码不再含 `/19\\d{2}|20\\d{2}/`
## 5. 硬红线
1. 公开 `can_adopt` 只能收紧;`collect_evidence` / `discriminate_candidates` / `validate_holdout` 不得放行。确认门恒 fail-closed。
2. 选项、answer_class、计分服务端所有,Agent 只写 `spokenPrompt`;不恢复问题槽、不改 `asked_turn_id` 契约。
3. 不得用正文/题干字符串反推状态或数据(本单 §4 就是在删一处)。
4. 不改 DB check 约束、不加迁移;Python 引擎不动;不 bump Skill 10.0.14。
5. `./node_modules/.bin/tsc --noEmit` 通过(不用 `npx tsc`);`npm run lint --prefix frontend` 0 错误;`rectification-*` / `consultation-*` / `consult-*` / `chat-*` 测试 fail=0;改动的既有断言逐条三栏说明(旧 → 新 → 保留语义)。
6. 无凭据不得声称已真实环境验证。
## 6. 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/rectification-adopt-flow-fix-20260903 \
../.worktrees/rectification-adopt-flow-fix-20260903 origin/staging
```
`TASK-rectification-adopt-flow-20260902.md``docs/BUG_HISTORY.md`BUG-497500,以及 BUG-313/323 链)。BUG 编号从 **BUG-501** 起(先 grep 确认最大号):§1 记 BUG-501(复发自:无;相关 BUG-497),§2/§3/§4 各一条或并入 BUG-499/498 的"最近更新",由实现者判断,但 §1 必须独立成条。**行号是线索,按符号名定位。**
## 7. 验收标准
1. `collect_evidence` + 内部 `canAdopt = true` + 题库耗尽 → `ensureNonTerminalTurnExit` 建出 `collect_spoken` 兜底题(含 warn);`adopt_representative` 同条件不建题。
2. 「改选」是 `<button>`,点击滚到出卡消息;`showSelectionCards` 为 false 时不渲染。
3. 开场题 `target_domain = other`、id `collect:other:collect_method_evidence`;跳过开场题后 education 不进 declined、教育采集题无 `collect_retry`
4. reverse_verify focus schema 带 `probe_year``method-followup.ts` 无年份正则。
5. tsc + lint + 四组测试 fail=0BUG_HISTORY 追加条目。
6. 真实环境人工清单(部署后,沿用上一单 §9.7):采集阶段把题答到没题 → 出现"先这样"入口而不是空 composer;采用后点「改选」能跳回卡片。
+64
View File
@@ -7649,3 +7649,67 @@
- 复发自:BUG-451(分章后仍把写作失败压成整份 schema 码,截断预算与失败分类未按中文口径收口)
- 修复版本:待发布
## BUG-497 | 采集阶段同时出现采集题和采用卡
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`publicDecisionFields``POST .../candidates/accept``parseRectificationCandidateResult``rectification-agentic-chat`
- 用户现象:采集题还在问时,同一条助手消息下面出现三张「采用此时间」卡。
- 触发条件:`session_outcome=collect_evidence`,内部 `capability.canAdopt=true`,当前题是 `collect_spoken`
- 根因:`collect()``canAdopt` 原样投影成公开 `can_adopt`。前端 `showSelectionCards` 只靠 `!showLiveChoiceCard`,采集题没有 A–D 卡,采用卡就露出来。v9 删掉 offer 门后,BUG-313 / BUG-323 的防复发条款失效。
- 修复:公开 `can_adopt` 只在 `adopt_representative` / `provisional_range_user_stopped` / `awaiting_confirmation` / `validated_range` 为真(后者是 holdout 已过、ready_to_adopt 的既有语义,不是新开口)。accept 在 `can_adopt=false` 时 409;前端再按 `session_outcome` 双保险。采集阶段只显示只读范围行,composer 旁提供「先这样」。
- 验证:`rectification-adopt-flow-20260902``rectification-decide-next-action``rectification-confirmation-gate``rectification-candidate-result`accept 源码锁 409 在 RPC 之前。
- 防复发:`can_adopt` 只能收紧,不得在 `collect_evidence` / `discriminate_candidates` / `validate_holdout` 放行。`exact_minute_confirmed` / `completed_with_range` 不得新增放行。
- 相关记录:BUG-313、BUG-323
- 复发自:BUG-313、BUG-323
- 修复版本:待发布
## BUG-498 | 采用后核对题 choice_card 为空导致不可答
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`keepAcceptedFocus``projectRectificationChoiceCard``rectification-set-focus`
- 用户现象:点「采用此时间」后出现核对题,嵌入卡 disabled,点不了 A–D。
- 触发条件:已采用,active focus 为 `reverse_verify:education_style:score`,随后 GET / set-focus 重算 followup。
- 根因:承接分支用 `method_id: active_focus` 重建 `question_id`,与已持久化 id 不一致;`projectRectificationChoiceCard` 按设计拒绝串题,返回 null。核对题 schema 还被最高增益区分探针盖上 relocation identity。
- 修复:`keepAcceptedFocus` / `out_of_sample_check` 沿用已持久化 `questionId``ask_theme``domain` 和选项。不放宽 choice-card 校验。reverse_verify schema 不盖区分探针。spokenPrompt 含钟点时保留服务端题干以免卡片不可解析。区分题 keep 仍走 `probe:` semantic_key。
- 验证:`rectification-adopt-flow-20260902``rectification-question-ownership` 采用后续跑 set-focus。
- 防复发:无 semantic_key 的 followup 必须 `focus.questionId === frame.question_id`
- 相关记录:BUG-492、BUG-497
- 复发自:无
- 修复版本:待发布
## BUG-499 | 采用卡不沉淀进历史,列表末尾裸按钮
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`rectification-agentic-chat``attachOfferResultToTurns`、核对题 skip 文案
- 用户现象:采用后三张卡消失,列表末尾出现「用这个时间看盘」;续跑结束后卡又出现在新消息下,与核对题并存。被关掉的采集题无痕消失。
- 触发条件:采用后续跑 `busy``savedStatus=accepted` 渲染 `rectification-consult-handoff`
- 根因:卡永远锚在最新助手消息;采用后的界面动作散落在列表末尾,不跟出卡消息走。
- 修复:出卡消息拥有 `candidateOffer`;采用后原地结算「已采用 / 改选」;状态条承接「改选 / 用这个时间看盘」;核对题「这题跳过」;被关掉的采集题标「已跳过」。
- 验证:`rectification-agentic-entry``rectification-activity-receipt``rectification-adopt-flow-20260902` 组件源码锁。
- 防复发:已采用后不得把卡锚到最新消息;不得再渲染 `rectification-consult-handoff`
- 相关记录:BUG-497、BUG-490
- 复发自:无
- 修复版本:待发布
## BUG-500 | 校正运行耗时不可观测
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`get_agentic_rectification_turn_receipt``tool-service``rectification-compare-candidates`、聊天进度文案
- 用户现象:采用后续跑约三分钟,回执只有 tool/status/methods;开场 set-focus 失败原因也看不见。
- 触发条件:compare-candidates 走引擎;set-focus `invalid_spoken_prompt`;超过 45 秒仍显示「正在处理」。
- 根因:回执不暴露 `started_at` / `elapsed_ms`;失败只有 `safeErrorCode`;进度文案不跟活跃步骤切换;`read_only` 仍可能重跑 vedastro-validate。
- 修复:回执增加每步耗时和 fingerprint detailcompare 记引擎各段;45 秒后按当前工具换文案;minute-sensitive 已 `passed` 时跳过 vedastro-validate`failed` 仍重试且不重算排名。
- 验证:`rectification-adopt-flow-20260902` 的 elapsed_ms / 45 秒文案;`rectification-eight-method` 失败校验重试。
- 防复发:失败行不得用 GET 时的 `now()` 计算耗时;确认门语义不因沿用校验而放宽。
- 相关记录:BUG-497、BUG-498
- 复发自:无
- 修复版本:待发布
+2
View File
@@ -34,6 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
| 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 |
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
## 服务端探针 → Agent 题干
@@ -52,5 +53,6 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 不得虚构星盘事实或唯一出生分钟。
- 问哪道题、年份、选项语义仍由服务端 focus 唯一所有;Agent 用 `spokenPrompt` 写题干,不得改年份、不得改选项含义。正文不得出题、复述或改写题干——题干作为同一条消息里正文之后的独立段落出现。
- 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。
- 正文必须先用一句话承接用户本轮给出的事实(年份+事件);不得预告选项。
- 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。
- 技法审计表仍在本命回答文末。
@@ -101,6 +101,40 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
try {
const { loadV9CaseDossier, evidenceLedgerFingerprint } = await import(
"@/lib/rectification-agentic/v9/tool-service"
);
const { decideFromDossier, overlayPublicDecision } = await import(
"@/lib/rectification-agentic/v9/interview-state"
);
const { publicDecisionFields } = await import(
"@/lib/rectification-agentic/core/rectification-decision"
);
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const fields = publicDecisionFields(decision);
const overlaid = dossier.latestResult
? overlayPublicDecision(dossier.latestResult, decision)
: fields;
if (overlaid.can_adopt !== true) {
return NextResponse.json(
{ error: "adoption_not_allowed", session_outcome: fields.session_outcome },
{ status: 409 },
);
}
} catch (error) {
if (error instanceof RectificationToolServiceError) {
return errorResponse(error);
}
return NextResponse.json(
{ error: "暂时无法采用该候选时间", code: "candidate_accept_failed" },
{ status: 503 },
);
}
try {
const { data, error } = await accounting.rpc(
"accept_agentic_rectification_candidate_for_case_v2",
@@ -16,7 +16,7 @@ import {
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision";
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
@@ -99,7 +99,16 @@ export function dossierResponse(
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const listed = options.listed ?? { focuses: [] as const, available: true };
const turns = attachQuestionsToTurns(dossier.turns, listed.focuses);
const turnsWithQuestions = attachQuestionsToTurns(dossier.turns, listed.focuses);
const fields = publicDecisionFields(decision);
const overlaid = dossier.latestResult
? overlayPublicDecision(dossier.latestResult, decision)
: fields;
const turns = attachOfferResultToTurns(turnsWithQuestions, {
resultId: dossier.latestResult?.resultId ?? null,
canAdopt: overlaid.can_adopt === true,
acceptedTime: dossier.case.acceptedTime,
});
return {
case: {
case_id: dossier.case.caseId,
@@ -125,12 +134,13 @@ export function dossierResponse(
created_at: turn.createdAt,
receipt: turnReceipt(turn.id, receipts),
question: turn.question,
offer_result_id: turn.offer_result_id,
})),
evidence: dossier.evidence,
latest_result: dossier.latestResult
? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true)
: null,
interview: publicDecisionFields(decision),
interview: fields,
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
question_source: questionSourceFromFocusList(listed),
@@ -169,6 +179,9 @@ function turnReceipt(
tool: activity.tool,
status: activity.status,
methods: activity.methods,
started_at: activity.startedAt ?? null,
elapsed_ms: activity.elapsedMs ?? null,
detail: activity.detail ?? null,
})),
tools: receipt.tools,
methods: receipt.methods,
+45 -1
View File
@@ -3080,7 +3080,51 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-refinement p,
.rectification-refinement__stage { margin: 0; color: var(--color-ink-secondary); }
.rectification-consult-handoff { display: flex; }
.rectification-consult-handoff { display: none; }
.rectification-readonly-range {
width: calc(100% - var(--assistant-content-inset));
margin: var(--space-3) 0 var(--space-4);
margin-inline-start: var(--assistant-content-inset);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-question-skipped {
margin: var(--space-2) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-adopt-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin: 0 0 var(--space-3);
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
color: var(--color-ink);
font-size: var(--type-body-sm);
}
.rectification-adopt-status__link {
color: var(--color-action);
font-weight: 650;
}
.rectification-collect-stop {
margin: var(--space-2) 0 0;
padding: 0;
border: 0;
background: transparent;
color: var(--color-ink-secondary);
font: inherit;
font-size: var(--type-caption);
cursor: pointer;
}
.rectification-collect-stop:disabled {
cursor: default;
opacity: .48;
}
.rectification-house-table {
display: grid;
gap: var(--space-3);
@@ -19,6 +19,7 @@ import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationLiveProgressLabel,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
@@ -28,7 +29,8 @@ import {
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
canRenderRectificationSelectionCards,
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
isRecommendedRectificationCandidate,
natalRecastMeaning,
parseRectificationCandidateResult,
@@ -57,7 +59,7 @@ import {
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_STOP_LABEL, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { CharacterRemaining } from "./character-remaining";
@@ -80,6 +82,7 @@ type PersistedTurn = Readonly<{
text: string | null;
status: string;
question?: unknown;
offer_result_id?: string | null;
receipt?: Readonly<{
status: string;
phases: readonly string[];
@@ -87,6 +90,9 @@ type PersistedTurn = Readonly<{
tool: string;
status: string;
methods?: readonly string[];
started_at?: string | null;
elapsed_ms?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods?: readonly string[];
@@ -109,9 +115,11 @@ type CurrentQuestionModel = Readonly<{
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown };
if (question.kind !== "choice" && question.kind !== "collect_spoken") return null;
if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") {
return null;
}
return {
kind: question.kind,
kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice",
prompt: typeof question.prompt === "string" && question.prompt.trim()
? question.prompt.trim()
: null,
@@ -126,6 +134,16 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n
return null;
}
function RectificationReadonlyRange({
range,
}: Readonly<{ range: readonly [string, string] }>) {
return (
<p className="rectification-readonly-range" role="status">
{range[0]}{range[1]}
</p>
);
}
function RectificationCandidateCards({
result,
acceptingCandidateId,
@@ -200,20 +218,31 @@ type RenderMessage = ChatMessageView & {
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
candidateOffer?: Readonly<{ resultId: string }>;
};
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const byId = new Map<string, TurnQuestion | null>();
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown };
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, parseTurnQuestion(turn.question));
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const question = byId.get(message.turnId) ?? undefined;
return { ...message, question: question ?? undefined };
const next = byId.get(message.turnId);
const question = next?.question ?? undefined;
return {
...message,
question: question ?? undefined,
candidateOffer: next?.offerResultId
? { resultId: next.offerResultId }
: message.candidateOffer,
};
});
}
@@ -271,8 +300,8 @@ function choiceCardFromQuestion(
answer_class: "unsure",
role: "primary",
})),
stop_label: CHOICE_STOP_LABEL,
stop_message: "先这样",
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
@@ -342,6 +371,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
failed,
turnId: turn.id,
question: parseTurnQuestion(turn.question) ?? undefined,
candidateOffer: typeof turn.offer_result_id === "string"
? { resultId: turn.offer_result_id }
: undefined,
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
@@ -404,6 +436,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const runAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const offerSectionRef = useRef<HTMLDivElement | null>(null);
const runStartedAtRef = useRef(0);
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
const liveBaseLabelRef = useRef("正在处理…");
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
@@ -447,6 +484,75 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onPendingChange?.(value);
}, [onPendingChange]);
const beginLiveRun = useCallback((baseLabel: string) => {
const now = Date.now();
runStartedAtRef.current = now;
stepStartedAtRef.current = now;
liveToolRef.current = null;
liveBaseLabelRef.current = baseLabel;
}, []);
const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => {
if (liveBaseLabelRef.current !== baseLabel) {
liveBaseLabelRef.current = baseLabel;
stepStartedAtRef.current = Date.now();
}
liveToolRef.current = tool;
return rectificationLiveProgressLabel({
tool,
baseLabel,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
}, []);
useEffect(() => {
if (!busy) return;
const id = window.setInterval(() => {
const label = rectificationLiveProgressLabel({
tool: liveToolRef.current,
baseLabel: liveBaseLabelRef.current,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
setMessages((current) => current.map((message) => {
if (message.state !== "thinking" && message.state !== "streaming") return message;
if (!message.activity || message.activity.label === label) return message;
return { ...message, activity: { ...message.activity, label } };
}));
}, 4000);
return () => window.clearInterval(id);
}, [busy]);
useEffect(() => {
if (!candidateResult?.resultId) return;
const canOffer = canShowRectificationSelectionCards(candidateResult);
const adopted = Boolean(candidateResult.selectedTime);
if (!canOffer && !adopted) return;
const resultId = candidateResult.resultId;
queueMicrotask(() => {
setMessages((current) => {
if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current;
if (adopted && current.some((message) => message.candidateOffer)) {
return current.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message
));
}
if (!canOffer || adopted) return current;
const owner = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.failed
));
if (!owner) return current;
return current.map((message) => (
message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message
));
});
});
}, [candidateResult]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
@@ -529,6 +635,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setPending(true);
beginLiveRun("正在处理…");
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
@@ -658,14 +765,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityTrace = freezeLiveThink(activityTrace);
currentActivity = nextActivityView(currentActivity, {
phase: "answer-composition",
label: "正在组织回答…",
label: rememberLiveActivity("正在组织回答…"),
});
frames.setAnswer(raw);
} else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) {
const activity = event.activity;
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity],
label: rememberLiveActivity(RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]),
});
frames.touch();
} else if (event.type === "attempt.reset") {
@@ -676,7 +783,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedTurnId = undefined;
currentActivity = nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
label: rememberLiveActivity("正在处理…", null),
});
frames.reset();
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
@@ -714,7 +821,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
label: rememberLiveActivity(RECTIFICATION_TOOL_PROGRESS_LABELS[tool], tool),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
@@ -736,7 +843,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_DONE_LABELS[tool],
label: rememberLiveActivity(RECTIFICATION_TOOL_DONE_LABELS[tool], tool),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
@@ -833,7 +940,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (runAbort.current === abortController) runAbort.current = null;
setPending(false);
}
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => {
const key = stableChoiceActionKey(focusId, optionId);
@@ -847,19 +954,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const submitStructuredChoice = useCallback(async (
action: typeof CHOICE_ACTION | typeof STOP_ACTION,
optionId: ChoiceKey | "stop",
override?: Readonly<{
focusId: string;
questionId: string | null;
probeId?: string | null;
caseRevision?: number | null;
}>,
) => {
if (!choiceCard || busy || readonly) return;
const focusId = choiceCard.focus_id;
const focusId = override?.focusId ?? choiceCard?.focus_id;
if (!focusId || busy || readonly) return;
if (!override && !choiceCard) return;
if (!isPersistedFocusId(focusId)) {
setError("当前选择题已失效,请等待下一问。");
return;
}
const questionId = choiceCard.question_id;
const questionId = override?.questionId ?? choiceCard?.question_id;
const probeId = override?.probeId ?? choiceCard?.probe_id;
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
const actionId = actionIdForChoice(focusId, optionId);
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
beginLiveRun(RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer);
setMessages((current) => [
...markQuestionAnswered(current, focusId, optionId),
{
@@ -888,9 +1005,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
actionId,
focusId,
questionId,
probeId: choiceCard.probe_id,
probeId: probeId ?? null,
optionId: optionId === "stop" ? undefined : optionId,
expectedRevision: choiceCard.case_revision ?? 0,
expectedRevision,
origin: "choice_click",
clientActionId: actionId,
}),
@@ -950,8 +1067,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setPending(false);
}
}, [
actionIdForChoice,
busy,
beginLiveRun,
caseId,
choiceCard,
loadCaseSnapshot,
@@ -1119,26 +1235,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
latestLiveQuestion
&& latestLiveQuestion.options?.length === 4
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify")
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey;
const selectionCardMessageKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& candidateResult?.canAdopt
&& canRenderRectificationSelectionCards(candidateResult)
&& latestSettledAssistant
&& !showLiveChoiceCard
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
candidateResult
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime)),
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
&& latestSettledAssistant
&& !showSelectionCards,
);
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const showCollectStop = Boolean(
latestLiveQuestion
&& latestLiveQuestion.kind === "collect_spoken"
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& !questionIsAnswered(latestLiveQuestion)
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const adoptedRangeLabel = candidateResult?.credibleRange
? `${candidateResult.credibleRange[0]}${candidateResult.credibleRange[1]}`
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const liveQuestionOnMessages = messages.some((message) => (
message.role === "assistant"
@@ -1185,6 +1315,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
function submitCollectStop() {
if (!latestLiveQuestion || latestLiveQuestion.kind !== "collect_spoken") return;
void submitStructuredChoice(STOP_ACTION, "stop", {
focusId: latestLiveQuestion.focus_id,
questionId: latestLiveQuestion.question_id,
probeId: latestLiveQuestion.probe_id,
caseRevision: choiceCard?.case_revision ?? 0,
});
}
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1254,11 +1394,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
question
&& currentQuestion?.focus_id === question.focus_id
&& !questionIsAnswered(question)
&& choiceCard
&& choiceCard.focus_id === question.focus_id
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
&& regeneratingMessageKey === null
&& (
question.kind === "collect_spoken"
|| (
choiceCard
&& choiceCard.focus_id === question.focus_id
&& (question.kind === "choice" || question.kind === "reverse_verify")
)
),
);
const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null;
const afterAnswer = question && displayedMessage.state === "settled"
@@ -1277,11 +1423,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{question.status === "skipped" && (
<p className="rectification-question-skipped" role="status">
{savedTime ? `已跳过(已采用 ${savedTime}` : "已跳过"}
</p>
)}
</div>
)
: undefined;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
<div
key={message.renderKey}
className="rectification-message-wrap rectification-message-entry"
ref={showSelectionCards && message.renderKey === selectionCardMessageKey ? offerSectionRef : undefined}
>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
@@ -1307,10 +1462,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<RectificationCandidateCards
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly}
readonly={readonly || busy || regeneratingMessageKey !== null}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
/>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange range={candidateResult.credibleRange} />
)}
</div>
);
})}
@@ -1319,13 +1477,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{savedTime}
</p>
)}
{savedTime && savedStatus === "accepted" && onStartConsultation && (
<div className="rectification-consult-handoff">
<Button type="button" onClick={onStartConsultation}>
</Button>
</div>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{readonly && (
<div className="rectification-terminal-actions">
@@ -1340,6 +1491,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{savedTime && savedStatus === "accepted" && (
<div className="rectification-adopt-status" role="status">
<span>
{savedTime}
{adoptedRangeLabel ? ` · 范围 ${adoptedRangeLabel}` : ""}
</span>
{showSelectionCards && (
<span className="rectification-adopt-status__link"></span>
)}
{onStartConsultation && (
<Button type="button" variant="outline" onClick={onStartConsultation}>
</Button>
)}
</div>
)}
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
<p className="rectification-composer-status" role="status">
{showQuestionLoadFailed
@@ -1378,6 +1545,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}}
onStop={stopRun}
/>
{showCollectStop && (
<button
type="button"
className="rectification-collect-stop"
disabled={!canSend}
onClick={submitCollectStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
<div className="composer-footer">
<ModelSelector
@@ -33,6 +33,36 @@ export const RECTIFICATION_ACTIVITY_PROGRESS_LABELS: Readonly<Record<PublicRecti
preparing_result: "正在准备结果…",
};
export const RECTIFICATION_SLOW_STEP_MS = 45_000;
export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000;
export const RECTIFICATION_TIMEOUT_WARN_BEFORE_MS = 20_000;
export function rectificationSlowProgressLabel(tool: PublicRectificationTool | string | null | undefined): string {
if (tool === "rectification-compare-candidates") return "引擎在比较候选,可能需要一两分钟";
if (tool === "rectification-read-diagnostics") return "引擎还在检查候选稳健性,可能需要一两分钟";
if (typeof tool === "string" && RECTIFICATION_TOOL_PROGRESS_LABELS[tool as PublicRectificationTool]) {
return `${RECTIFICATION_TOOL_DONE_LABELS[tool as PublicRectificationTool]}还在进行,可能需要一两分钟`;
}
return "这一步还在进行,可能需要一两分钟";
}
export function rectificationLiveProgressLabel(input: {
tool?: string | null;
baseLabel: string;
stepStartedAt: number;
runStartedAt: number;
now?: number;
}): string {
const now = input.now ?? Date.now();
if (now - input.runStartedAt >= RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS) {
return "即将超时,会自动重试或提示";
}
if (now - input.stepStartedAt >= RECTIFICATION_SLOW_STEP_MS) {
return rectificationSlowProgressLabel(input.tool);
}
return input.baseLabel;
}
export const RECTIFICATION_TOOL_PROGRESS_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "正在读取校正记录…",
"rectification-set-focus": "正在设置对话焦点…",
@@ -117,6 +117,24 @@ export type DecisionSessionOutcome =
| "adopt_representative"
| "awaiting_confirmation";
/** Public `can_adopt` is true only in these outcomes. Internal `decision.canAdopt` may still be true during collect. */
export const ADOPT_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
"adopt_representative",
"provisional_range_user_stopped",
"awaiting_confirmation",
"validated_range",
]);
export function sessionOutcomeAllowsAdopt(
outcome: DecisionSessionOutcome | string | null | undefined,
): outcome is DecisionSessionOutcome {
return typeof outcome === "string" && ADOPT_OUTCOMES.has(outcome as DecisionSessionOutcome);
}
export function publicCanAdopt(decision: Pick<RectificationDecision, "canAdopt" | "sessionOutcome">): boolean {
return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome);
}
export type CompletionStatus =
| "provisional_range_user_stopped"
| "validated_range"
@@ -617,7 +635,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
completion_status: decision.completionStatus,
validated: decision.validated,
can_offer_range: decision.canOfferRange,
can_adopt: decision.canAdopt,
can_adopt: publicCanAdopt(decision),
can_confirm_exact_minute: decision.canConfirmExactMinute,
selection_allowed: decision.selectionAllowed,
propose_allowed: decision.proposeAllowed,
@@ -11,6 +11,8 @@ import { RECTIFICATION_TERMINATION_COPY } from "../core/rectification-decision";
import {
CHOICE_STOP_LABEL,
CHOICE_STOP_MESSAGE,
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_SKIP_QUESTION_MESSAGE,
HOLDOUT_MESSAGE_PREFIX,
type ChoiceKey,
type RectificationChoiceCard,
@@ -57,6 +59,7 @@ export function isStructuredChoiceUserText(text: string | null | undefined): boo
const trimmed = text?.trim() ?? "";
if (!trimmed) return false;
if (trimmed === CHOICE_STOP_MESSAGE || trimmed === CHOICE_STOP_LABEL) return true;
if (trimmed === CHOICE_SKIP_QUESTION_MESSAGE || trimmed === CHOICE_SKIP_QUESTION_LABEL) return true;
if (trimmed.startsWith(`${HOLDOUT_MESSAGE_PREFIX}`) && STRUCTURED_CHOICE_LINE.test(trimmed)) return true;
return STRUCTURED_CHOICE_LINE.test(trimmed) && trimmed.length <= 90;
}
@@ -17,6 +17,8 @@ import type { InternalVargaObservation } from "./varga-observations";
export const CHOICE_MODE = "A/B/C/D";
export const CHOICE_STOP_LABEL = "先这样,先看当前范围";
export const CHOICE_STOP_MESSAGE = "先这样";
export const CHOICE_SKIP_QUESTION_LABEL = "这题跳过";
export const CHOICE_SKIP_QUESTION_MESSAGE = "这题跳过";
export const HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -91,6 +93,8 @@ export type ChoiceCardFollowup = Readonly<{
ask_theme: string;
domain: string | null;
user_prompt_hint: string;
intent?: string;
source?: string;
choice_kind?: EventProbeChoiceKind;
style_options?: readonly EventProbeStyleOption[];
semantic_key?: string;
@@ -375,6 +379,10 @@ export function buildChoiceFrame(
);
if (!hypothesis) return null;
const domain = followupDomain(followup);
const skipQuestion = followup.intent === "reverse_verify"
|| followup.intent === "out_of_sample_check"
|| followup.source === "reverse_verify"
|| followup.source === "oos_blind";
return {
question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`,
method_id: followup.method_id,
@@ -391,8 +399,8 @@ export function buildChoiceFrame(
option_c_answer_class: hypothesis.answerClasses[2],
option_d_answer_class: hypothesis.answerClasses[3],
choice_mode: CHOICE_MODE,
stop_label: CHOICE_STOP_LABEL,
stop_message: CHOICE_STOP_MESSAGE,
stop_label: skipQuestion ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: skipQuestion ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring,
choice_kind: hypothesisKind(followup, input.probes),
};
@@ -169,6 +169,14 @@ export function readVedastroMinuteSensitiveStatus(
return "not_evaluated";
}
export function hasPersistedVedastroValidation(
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
): boolean {
const gates = asRecord(decisionReceipt?.gates);
const exact = asRecord(gates?.exact_confirmation);
return Boolean(asRecord(exact?.vedastro_event_validation) || asRecord(decisionReceipt?.vedastro_event_validation));
}
function readVedastroFailureCode(
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
): string | null {
@@ -1065,12 +1065,37 @@ export function exhaustionSpokenCollectFollowup(input: {
};
}
export const OPENING_COLLECT_DOMAIN = "education";
export function collectQuestionDomain(domain: string | null | undefined): string {
if (domain && domain !== "unknown" && domain !== "active_focus") return domain;
return OPENING_COLLECT_DOMAIN;
}
export function parsePersistedFollowupQuestionId(questionId: string | null | undefined): {
method_id: string;
ask_theme: string;
scoring: boolean;
} | null {
if (!questionId) return null;
const match = /^(.*):([^:]+):(score|holdout)$/.exec(questionId.trim());
if (!match) return null;
const method_id = match[1]?.trim() ?? "";
const ask_theme = match[2]?.trim() ?? "";
if (!method_id || !ask_theme) return null;
return { method_id, ask_theme, scoring: match[3] === "score" };
}
function parsedIdScoring(questionId: string | null | undefined): boolean {
return parsePersistedFollowupQuestionId(questionId)?.scoring !== false;
}
export function spokenCollectFallbackFollowup(followup: MethodFollowup): MethodFollowup {
return {
method_id: followup.method_id,
intent: "collect_method_evidence",
ask_theme: followup.ask_theme,
domain: followup.domain,
domain: collectQuestionDomain(followup.domain),
kind_hint: followup.kind_hint,
user_prompt_hint: followup.user_prompt_hint,
must_not_label: false,
@@ -1537,31 +1562,90 @@ export function buildMethodFollowupPlan(input: {
input.eventProbes,
input.contrastPacket?.probes,
);
const keepNext = makeFollowup({
method_id: "active_focus",
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
information_gain: liveProbe.information_gain,
semantic_key: liveProbe.semantic_key,
candidate_split_hash: liveProbe.candidate_split_hash,
probe_year: liveProbe.year,
year_label: liveProbe.year_label,
probe_month: liveProbe.month,
choice_kind: liveProbe.choice_kind,
candidate_ids: liveProbe.candidate_ids ?? candidateIdsFromProbe(liveProbe),
expected_outcomes: liveProbe.expected_outcomes,
style_options: liveProbe.style_options,
}
: {}),
}, true, keepChoice);
const acceptedKeepHint = input.accepted
? "当前排盘已采用该时间。现在按该分钟核对。"
: "";
const keepNext = keepAcceptedFocus
? makeFollowup((() => {
const parsedId = parsePersistedFollowupQuestionId(focus.questionId);
const themeFromDomain = focus.targetDomain && focus.targetDomain in REVERSE_VERIFY_THEME
? REVERSE_VERIFY_THEME[focus.targetDomain as keyof typeof REVERSE_VERIFY_THEME]
: null;
const keepMethodId = (
parsedId && parsedId.method_id !== "active_focus"
? parsedId.method_id
: focus.intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify"
) as MethodFollowup["method_id"];
const keepAskTheme = (
parsedId && parsedId.ask_theme !== "active_focus"
? parsedId.ask_theme
: themeFromDomain ?? (focus.intent === "out_of_sample_check" ? "oos_blind" : "education_style")
) as MethodFollowup["ask_theme"];
const matchingVerify = (input.eventProbes ?? []).find((probe) => (
probe.domain === focus.targetDomain
|| REVERSE_VERIFY_THEME[probe.domain as keyof typeof REVERSE_VERIFY_THEME] === keepAskTheme
));
const schema = focus.expectedAnswerSchema;
const schemaYear = typeof schema?.probe_year === "number" && schema.probe_year > 0
? schema.probe_year
: undefined;
const promptYear = (() => {
const prompt = typeof schema?.prompt === "string" ? schema.prompt : "";
const choice = schema?.choice && typeof schema.choice === "object"
? schema.choice as Record<string, unknown>
: null;
const text = `${prompt} ${typeof choice?.prompt === "string" ? choice.prompt : ""}`;
const year = text.match(/19\d{2}|20\d{2}/);
return year ? Number(year[0]) : undefined;
})();
const keepProbeYear = matchingVerify?.year || schemaYear || promptYear;
return {
method_id: keepMethodId,
intent: focus.intent || "reverse_verify",
ask_theme: keepAskTheme,
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: `${acceptedKeepHint}先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。`,
source: focus.intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify",
...(keepProbeYear ? {
probe_year: keepProbeYear,
year_label: matchingVerify?.year_label ?? `${keepProbeYear} 年前后`,
} : {}),
...(existingChoice ? {
style_options: existingChoice.options.map((option) => ({
label: option.label,
answer_class: option.answer_class,
})),
} : matchingVerify?.style_options ? {
style_options: matchingVerify.style_options,
} : {}),
};
})(), parsedIdScoring(focus.questionId), true)
: makeFollowup({
method_id: "active_focus",
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
information_gain: liveProbe.information_gain,
semantic_key: liveProbe.semantic_key,
candidate_split_hash: liveProbe.candidate_split_hash,
probe_year: liveProbe.year,
year_label: liveProbe.year_label,
probe_month: liveProbe.month,
choice_kind: liveProbe.choice_kind,
candidate_ids: liveProbe.candidate_ids ?? candidateIdsFromProbe(liveProbe),
expected_outcomes: liveProbe.expected_outcomes,
style_options: liveProbe.style_options,
}
: {}),
}, true, keepChoice);
if (!(
keepChoice
&& focus.intent === "distinguish_candidates"
@@ -1713,7 +1797,7 @@ export function buildMethodFollowupPlan(input: {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
domain: OPENING_COLLECT_DOMAIN,
kind_hint: null,
user_prompt_hint: collect(
"可以先从最容易想起的一件带大概时间的经历开始。",
@@ -10,7 +10,7 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { spokenFollowupForUser, spokenCollectFallbackFollowup, type MethodFollowup } from "./method-followup";
import { spokenFollowupForUser, spokenCollectFallbackFollowup, collectQuestionDomain, type MethodFollowup } from "./method-followup";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import {
setV10ConversationFocus,
@@ -20,6 +20,8 @@ import {
type ConversationFocus,
} from "./tool-service";
export { parsePersistedFollowupQuestionId } from "./method-followup";
export type PersistServerFocusStatus =
| "created"
| "already_open"
@@ -38,14 +40,16 @@ export type PersistServerFocusResult = Readonly<{
export function stableFollowupQuestionId(followup: MethodFollowup): string {
if (followup.semantic_key) return `probe:${followup.semantic_key}`.slice(0, 160);
if (followup.probe_year && followup.domain) {
if (
followup.probe_year
&& followup.domain
&& followup.intent !== "reverse_verify"
&& followup.intent !== "out_of_sample_check"
) {
return `${followup.method_id}:${followup.domain}:${followup.probe_year}`.slice(0, 160);
}
if (followup.intent === "collect_method_evidence" && !followup.choice_frame) {
const domain = followup.domain && followup.domain !== "active_focus"
? followup.domain
: "unknown";
return `collect:${domain}:${followup.intent}`.slice(0, 160);
return `collect:${collectQuestionDomain(followup.domain)}:${followup.intent}`.slice(0, 160);
}
return (followup.choice_frame?.question_id ?? `${followup.method_id}:${followup.ask_theme}`).slice(0, 160);
}
@@ -98,15 +102,17 @@ export function expectedAnswerSchemaFor(
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
if (decisionReceipt?.inference_state !== undefined && !state) return null;
const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check";
const stamped = stampChoiceSchemaWithProbe(
schema,
state,
verifyOnly ? null : state,
questionId,
{
semantic_key: followup.semantic_key,
candidate_split_hash: followup.candidate_split_hash,
},
);
if (verifyOnly) return stamped;
return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped;
}
@@ -297,7 +303,10 @@ async function persistCollectFocus(input: {
questionId: id,
intent: input.followup.intent,
targetEvidenceId: null,
targetDomain: persistableFocusDomain(input.followup.domain),
targetDomain: persistableFocusDomain(input.followup.domain)
?? (input.followup.intent === "collect_method_evidence"
? collectQuestionDomain(input.followup.domain)
: null),
targetKind: null,
expectedAnswerSchema: schema,
askedTurnId: input.askedTurnId ?? null,
@@ -1,6 +1,6 @@
import { persistableFocusDomain } from "./server-focus";
import { parseAgentChoiceCopy } from "./choice-card";
import { persistableFocusDomain, stableFollowupQuestionId } from "./server-focus";
import { MACHINE_VOICE_LEXICON } from "./agent-voice-lexicon";
import { stableFollowupQuestionId } from "./server-focus";
import type { MethodFollowup } from "./method-followup";
export const SPOKEN_PROMPT_MIN = 8;
@@ -91,7 +91,10 @@ export function withSpokenPrompt(
const next: Record<string, unknown> = { ...schema, prompt: spokenPrompt };
const choice = schema.choice;
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
next.choice = { ...choice, prompt: spokenPrompt };
const overlaid = { ...choice, prompt: spokenPrompt };
next.choice = parseAgentChoiceCopy({ ...schema, choice: overlaid })
? overlaid
: choice;
}
return next;
}
@@ -840,6 +840,9 @@ export type V9TurnReceipt = Readonly<{
tool: PublicRectificationTool;
status: "completed" | "failed";
methods: readonly PublicRectificationMethod[];
startedAt?: string | null;
elapsedMs?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods: readonly PublicRectificationMethod[];
@@ -847,6 +850,33 @@ export type V9TurnReceipt = Readonly<{
completedAt: string | null;
}>;
export function parseToolActivityDetail(activity: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> | null {
const detail: Record<string, unknown> = {};
if (typeof activity.error === "string" && activity.error.trim()) {
detail.error = activity.error.trim();
}
const fingerprint = typeof activity.result_fingerprint === "string"
? activity.result_fingerprint.trim()
: "";
if (fingerprint.startsWith("{")) {
try {
const parsed = JSON.parse(fingerprint) as Record<string, unknown>;
if (typeof parsed.reason === "string" && parsed.reason.trim()) {
detail.reason = parsed.reason.trim();
}
for (const key of ["engine_compare_ms", "vedastro_validate_ms", "persist_ms"] as const) {
const value = parsed[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
detail[key] = Math.round(value);
}
}
} catch {
// Hash fingerprints stay opaque.
}
}
return Object.keys(detail).length > 0 ? detail : null;
}
export async function loadV9TurnReceipt(
accounting: AccountingClient,
userId: string,
@@ -871,12 +901,23 @@ export async function loadV9TurnReceipt(
const tool = activity.tool;
const status = activity.status;
if (!isPublicRectificationTool(tool) || (status !== "completed" && status !== "failed")) return [];
const elapsedRaw = activity.elapsed_ms;
const elapsedMs = typeof elapsedRaw === "number" && Number.isFinite(elapsedRaw)
? Math.max(0, Math.round(elapsedRaw))
: null;
const startedAt = typeof activity.started_at === "string" && activity.started_at.trim()
? activity.started_at
: null;
const detail = parseToolActivityDetail(activity);
return [{
tool,
status,
methods: status === "completed"
? rowArray(activity.methods).filter(isPublicRectificationMethod)
: [],
...(startedAt ? { startedAt } : {}),
...(elapsedMs !== null ? { elapsedMs } : {}),
...(detail ? { detail } : {}),
}];
});
return {
@@ -119,6 +119,48 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
});
}
export function attachOfferResultToTurns<T extends {
id: string;
role: string;
question?: TurnQuestion | null;
}>(
turns: readonly T[],
input: {
resultId: string | null | undefined;
canAdopt: boolean;
acceptedTime: string | null | undefined;
},
): Array<T & { offer_result_id: string | null }> {
const resultId = input.resultId ?? null;
const offered = Boolean(resultId && (input.canAdopt || input.acceptedTime));
if (!offered || !resultId) {
return turns.map((turn) => ({ ...turn, offer_result_id: null }));
}
const firstVerify = turns.findIndex((turn) => (
turn.role === "assistant" && turn.question?.kind === "reverse_verify"
));
const searchUntil = firstVerify >= 0 ? firstVerify : turns.length;
let ownerIndex = -1;
for (let index = searchUntil - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
ownerIndex = index;
break;
}
}
if (ownerIndex < 0) {
for (let index = turns.length - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
ownerIndex = index;
break;
}
}
}
return turns.map((turn, index) => ({
...turn,
offer_result_id: index === ownerIndex ? resultId : null,
}));
}
export function questionIsAnswered(question: TurnQuestion): boolean {
return question.status !== "active";
}
@@ -19,7 +19,11 @@ import {
type ConfirmationGate,
} from "./rectification-agentic/v9/confirmation-gate";
import { MIN_SEPARATION_LEAD } from "./rectification-agentic/core/candidate-separation";
import { engineCapabilityCeilingFromReceipt } from "./rectification-agentic/core/rectification-decision";
import {
engineCapabilityCeilingFromReceipt,
sessionOutcomeAllowsAdopt,
type DecisionSessionOutcome,
} from "./rectification-agentic/core/rectification-decision";
export type RectificationCandidate = Readonly<{
candidateId: string;
@@ -74,6 +78,8 @@ export type RectificationCandidateResult = Readonly<{
confirmationGate: ConfirmationGate;
validated: boolean;
completionStatus: "provisional_range_user_stopped" | "validated_range" | "exact_minute_confirmed" | null;
sessionOutcome: DecisionSessionOutcome | null;
credibleRange: readonly [string, string] | null;
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -238,6 +244,31 @@ export function rectificationDecisionReceiptAllowsAdoption(value: unknown): bool
return ceiling.acceptanceAllowed && ceiling.selectionAllowed;
}
function parseSessionOutcome(value: unknown): DecisionSessionOutcome | null {
if (typeof value !== "string") return null;
if (
sessionOutcomeAllowsAdopt(value)
|| value === "collect_evidence"
|| value === "discriminate_candidates"
|| value === "validate_holdout"
|| value === "provisional_range"
|| value === "completed_with_range"
|| value === "validated_range"
|| value === "exact_minute_confirmed"
) {
return value as DecisionSessionOutcome;
}
return null;
}
function parseCredibleRange(value: unknown): readonly [string, string] | null {
if (!Array.isArray(value) || value.length !== 2) return null;
const start = time(value[0]);
const end = time(value[1]);
if (!start || !end) return null;
return [start, end];
}
export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null {
const snapshot = record(value);
if (!snapshot || typeof snapshot.resultId !== "string") return null;
@@ -317,6 +348,8 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
|| snapshot.completion_status === "exact_minute_confirmed"
? (snapshot.completionStatus ?? snapshot.completion_status) as RectificationCandidateResult["completionStatus"]
: null,
sessionOutcome: parseSessionOutcome(snapshot.sessionOutcome ?? snapshot.session_outcome),
credibleRange: parseCredibleRange(snapshot.credibleRange ?? snapshot.credible_range),
};
}
@@ -330,6 +363,21 @@ export function canRenderRectificationSelectionCards(
);
}
export function canShowRectificationSelectionCards(
result: RectificationCandidateResult | null,
): boolean {
return canRenderRectificationSelectionCards(result)
&& sessionOutcomeAllowsAdopt(result?.sessionOutcome);
}
export function canShowRectificationReadonlyRange(
result: RectificationCandidateResult | null,
): boolean {
if (!result?.credibleRange) return false;
const outcome = result.sessionOutcome;
return outcome === "collect_evidence" || outcome === "discriminate_candidates";
}
export function isRecommendedRectificationCandidate(
result: RectificationCandidateResult,
candidate: RectificationCandidate,
+1 -1
View File
@@ -63,7 +63,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
1. rectification-read-case
2. display_date_label
3. rectification-record-evidence-batch
4. rectification-set-focus spokenPrompt /2-4 set-focus spokenPrompt next_followup=null 2-4 choice collect_spoken
4. rectification-set-focus spokenPrompt /2-4 set-focus spokenPrompt next_followup=null 2-4 +case.accepted_time choice collect_spoken
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill
2016 9
+37 -3
View File
@@ -64,6 +64,7 @@ import {
buildMethodFollowupPlan,
buildNextUserAction,
spokenCollectFallbackFollowup,
collectQuestionDomain,
} from "@/lib/rectification-agentic/v9/method-followup";
import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet";
import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification-label";
@@ -858,6 +859,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const ranked = [...candidates].sort((left, right) => left.rank - right.rank);
const primary = ranked[0]?.time;
const runnerUp = ranked[1]?.time;
let vedastroMs = 0;
let persistMs = 0;
if (
latest.selectionAllowed
&& primary
@@ -865,12 +868,15 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
&& primary !== runnerUp
&& readVedastroMinuteSensitiveStatus(decisionReceipt) !== "passed"
) {
const vedastroStarted = Date.now();
const validation = await runV9VedastroValidate({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange,
events,
candidateTimes: [primary, runnerUp],
});
vedastroMs = Date.now() - vedastroStarted;
const persistStarted = Date.now();
decisionReceipt = await refreshV9VedastroValidation(accounting, userId, targetCaseId, {
resultId: latest.resultId,
evidenceFingerprint,
@@ -883,6 +889,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
},
minuteSensitiveStatus: validation.minuteSensitiveStatus,
});
persistMs = Date.now() - persistStarted;
}
return {
persisted: {
@@ -920,18 +927,27 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
parsed,
windowScan,
decisionReceipt,
timings: {
engine_compare_ms: 0,
vedastro_validate_ms: vedastroMs,
persist_ms: persistMs,
},
};
}
const scoreStarted = Date.now();
const score = await runV9CandidateScore({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events,
});
const engineCompareMs = Date.now() - scoreStarted;
const vedastroStarted = Date.now();
const receipt = await persistableReceipt(score, {
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events,
});
const vedastroMs = Date.now() - vedastroStarted;
const refinement = refinementFromDecisionReceipt(receipt);
const windowScan = windowScanFromDecisionReceipt(receipt);
// The packet must be scoped to the candidate set this round produced. Using
@@ -983,6 +999,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
mentionedKeys: mentionedVargaKeysFromLedgerEvidence(parsed.evidence),
}).dropped,
};
const persistStarted = Date.now();
const persisted = await persistV9Candidate(accounting, userId, targetCaseId, {
engineResultId: score.engineResultId,
algorithmVersion: score.algorithmVersion,
@@ -996,7 +1013,18 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
decisionReceipt,
executionLedger: score.executionLedger,
});
return { persisted, score, parsed, windowScan: score.windowScan, decisionReceipt };
return {
persisted,
score,
parsed,
windowScan: score.windowScan,
decisionReceipt,
timings: {
engine_compare_ms: engineCompareMs,
vedastro_validate_ms: vedastroMs,
persist_ms: Date.now() - persistStarted,
},
};
};
const persistPlanFocus = async (
@@ -1230,6 +1258,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
await receipt("rectification-set-focus", "intent.classified", "failed", {
inputFingerprint,
safeErrorCode: "invalid_spoken_prompt",
resultFingerprint: JSON.stringify({ reason: spoken.reason }),
});
return {
ok: false,
@@ -1256,7 +1285,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
targetEvidenceId: null,
targetDomain: persistFollowup.choice_frame
? persistFollowup.domain
: persistableFocusDomain(persistFollowup.domain),
: persistableFocusDomain(persistFollowup.domain) ?? collectQuestionDomain(persistFollowup.domain),
targetKind: null,
expectedAnswerSchema,
askedTurnId: turnId,
@@ -1828,7 +1857,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
};
await receipt("rectification-compare-candidates", "candidates.comparing", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
resultFingerprint: JSON.stringify({
hash: hashResult(projection),
engine_compare_ms: scored.timings.engine_compare_ms,
vedastro_validate_ms: scored.timings.vedastro_validate_ms,
persist_ms: scored.timings.persist_ms,
}),
engineVersion: scored.persisted.algorithmVersion ?? engineVersion,
executedMethods: scored.score.executedMethods,
});
@@ -0,0 +1,170 @@
-- Expose per-tool started_at / elapsed_ms / failure reason on turn receipts.
-- Timing is derived from the started row vs the terminal row; no new columns.
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 max(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 (
(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)
);
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;
@@ -0,0 +1,85 @@
{
"resultId": "11111111-1111-4111-8111-111111111111",
"session_outcome": "adopt_representative",
"can_adopt": true,
"selection_allowed": true,
"propose_allowed": false,
"confirmation_allowed": false,
"selectedTime": "05:06",
"selectionKind": "user_accepted",
"representativeTime": "05:06",
"credible_range": ["04:53", "05:06"],
"overallConfidence": "low",
"accepted_time": "05:06",
"candidates": [
{
"candidateId": "88888888-8888-4888-8888-888888888881",
"rank": 1,
"time": "05:06",
"relativeSupport": 14,
"tiedMinuteCount": 1
},
{
"candidateId": "88888888-8888-4888-8888-888888888882",
"rank": 2,
"time": "04:53",
"relativeSupport": 13,
"tiedMinuteCount": 1
},
{
"candidateId": "88888888-8888-4888-8888-888888888883",
"rank": 3,
"time": "05:03",
"relativeSupport": 13,
"tiedMinuteCount": 1
}
],
"decisionReceipt": {
"display_allowed": true,
"selection_allowed": true,
"acceptance_allowed": true,
"propose_allowed": false,
"confirmation_allowed": false,
"accept_allowed": true,
"confirm_allowed": false
},
"turns": [
{
"id": "22222222-2222-4222-8222-222222222222",
"role": "assistant",
"text": "2021 年这件事记下了。候选之间的差异还在收窄。",
"status": "completed",
"question": {
"focus_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"question_id": "collect:family:collect_method_evidence",
"kind": "collect_spoken",
"prompt": "2021 年前后,家里有没有人住院、搬家或职责明显变化?",
"options": null,
"status": "skipped",
"answer_option": null,
"probe_id": null
}
},
{
"id": "33333333-3333-4333-8333-333333333333",
"role": "assistant",
"text": "当前排盘已采用该时间。现在按该分钟核对。",
"status": "completed",
"question": {
"focus_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"question_id": "reverse_verify:education_style:score",
"kind": "reverse_verify",
"prompt": "2016 年前后,升学结果或学习环境有没有明显变化?",
"options": [
{ "key": "A", "label": "明确发生且时间吻合" },
{ "key": "B", "label": "发生过但程度较弱" },
{ "key": "C", "label": "明确没有发生" },
{ "key": "D", "label": "这段记不清楚" }
],
"status": "active",
"answer_option": null,
"probe_id": "education.2016"
}
}
]
}
@@ -0,0 +1,50 @@
{
"resultId": "11111111-1111-4111-8111-111111111111",
"session_outcome": "collect_evidence",
"can_adopt": true,
"selection_allowed": true,
"propose_allowed": false,
"confirmation_allowed": false,
"selectedTime": null,
"representativeTime": "05:06",
"credible_range": ["04:53", "05:06"],
"overallConfidence": "low",
"candidates": [
{
"candidateId": "88888888-8888-4888-8888-888888888881",
"rank": 1,
"time": "05:06",
"relativeSupport": 14,
"tiedMinuteCount": 1
},
{
"candidateId": "88888888-8888-4888-8888-888888888882",
"rank": 2,
"time": "04:53",
"relativeSupport": 13,
"tiedMinuteCount": 1
},
{
"candidateId": "88888888-8888-4888-8888-888888888883",
"rank": 3,
"time": "05:03",
"relativeSupport": 13,
"tiedMinuteCount": 1
}
],
"decisionReceipt": {
"display_allowed": true,
"selection_allowed": true,
"acceptance_allowed": true,
"propose_allowed": false,
"confirmation_allowed": false,
"accept_allowed": true,
"confirm_allowed": false
},
"current_question": {
"kind": "collect_spoken",
"focus_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"question_id": "collect:family:collect_method_evidence",
"prompt": "2021 年前后,家里有没有人住院、搬家或职责明显变化?"
}
}
@@ -131,6 +131,8 @@ test("candidate acceptance copy never presents adoption as confirmation", () =>
assert.match(chatSource, /采用不等于确认出生时间/);
assert.match(chatSource, /rectification-candidate-badge">已采用/);
assert.match(chatSource, /已确认校正时间/);
assert.match(chatSource, /rectification-adopt-status/);
assert.doesNotMatch(chatSource, /rectification-consult-handoff/);
assert.doesNotMatch(chatSource, /本轮校正已收口|当前排盘时间(代表性时间|本会话以代表性时间收口/);
assert.doesNotMatch(chatSource, /candidateResult\.confirmationAllowed \? "确认校正时间"/);
});
@@ -0,0 +1,529 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
publicCanAdopt,
publicDecisionFields,
decideRectification,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.ts";
import {
attachOfferResultToTurns,
attachQuestionsToTurns,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import {
buildMethodFollowupPlan,
collectQuestionDomain,
OPENING_COLLECT_DOMAIN,
parsePersistedFollowupQuestionId,
projectRectificationChoiceCard,
spokenCollectFallbackFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_STOP_LABEL,
parseRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { hasPersistedVedastroValidation } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts";
import {
parseToolActivityDetail,
loadV9TurnReceipt,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
RECTIFICATION_SLOW_STEP_MS,
RECTIFICATION_TIMEOUT_WARN_BEFORE_MS,
rectificationLiveProgressLabel,
} from "../src/lib/rectification-activity-labels.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, FOCUS_ID, TURN_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts";
const preAdopt = JSON.parse(readFileSync(
new URL("./fixtures/rectification-case-bf7a8de6-pre-adopt.json", import.meta.url),
"utf8",
)) as Record<string, unknown>;
const postAdopt = JSON.parse(readFileSync(
new URL("./fixtures/rectification-case-bf7a8de6-post-adopt.json", import.meta.url),
"utf8",
)) as Record<string, unknown>;
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const EDUCATION_VERIFY = {
year: 2016,
year_label: "2016 年前后",
domain: "education" as const,
event_family: "升学结果或学习环境出现明显变化",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
role: "reverse_verify" as const,
information_gain: 0.4,
semantic_key: "education.2016",
style_options: STYLE_OPTIONS,
};
const VERIFY_COPY = {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
} as const;
function reverseVerifySchema() {
return {
prompt: VERIFY_COPY.prompt,
probe_year: 2016,
choice: VERIFY_COPY,
};
}
function collectWithInternalAdopt() {
return decideRectification({
methodCoverageAll: false,
datedMethodCollectOpen: true,
datedEventCount: 4,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: [
{ time: "05:06", score: 14 },
{ time: "04:53", score: 13 },
{ time: "05:03", score: 13 },
],
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
confirmationAllowed: false,
inferenceCredibleRange: ["04:53", "05:06"],
});
}
test("public can_adopt is closed during collect even when internal capability is open", () => {
const decision = collectWithInternalAdopt();
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.canAdopt, true);
const fields = publicDecisionFields(decision);
assert.equal(fields.can_adopt, false);
assert.equal(fields.selection_allowed, decision.selectionAllowed);
assert.equal(publicCanAdopt(decision), false);
});
test("public can_adopt stays open only for adopt outcomes", () => {
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "awaiting_confirmation" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validated_range" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "discriminate_candidates" }), false);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validate_holdout" }), false);
assert.equal(publicCanAdopt({ canAdopt: false, sessionOutcome: "adopt_representative" }), false);
});
test("accept route 409s before the adopt RPC when public can_adopt is false", () => {
const accept = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
"utf8",
);
const gate = accept.indexOf("overlaid.can_adopt !== true");
const rpc = accept.indexOf("accept_agentic_rectification_candidate_for_case_v2");
assert.ok(gate >= 0, "accept must gate on overlaid.can_adopt");
assert.ok(rpc > gate, "RPC must not run before the session_outcome gate");
assert.match(accept, /adoption_not_allowed/);
assert.match(accept, /session_outcome: fields\.session_outcome/);
});
test("pre-adopt snapshot does not show adopt cards and does show a readonly range", () => {
const result = parseRectificationCandidateResult(preAdopt);
assert.ok(result);
assert.equal(result.sessionOutcome, "collect_evidence");
assert.equal(result.canAdopt, true);
assert.equal(canShowRectificationSelectionCards(result), false);
assert.equal(canShowRectificationReadonlyRange(result), true);
assert.deepEqual(result.credibleRange, ["04:53", "05:06"]);
});
test("post-adopt snapshot keeps the offer on the pre-verify message", () => {
const result = parseRectificationCandidateResult(postAdopt);
assert.ok(result);
assert.equal(result.selectedTime, "05:06");
const turns = Array.isArray(postAdopt.turns) ? postAdopt.turns as Array<{
id: string;
role: string;
question?: unknown;
}> : [];
const withQuestions = turns.map((turn) => ({
...turn,
text: null,
question: parseTurnQuestion(turn.question),
}));
const attached = attachOfferResultToTurns(withQuestions, {
resultId: result.resultId,
canAdopt: true,
acceptedTime: "05:06",
});
assert.equal(attached[0]?.offer_result_id, result.resultId);
assert.equal(attached[1]?.offer_result_id, null);
assert.equal(attached[1]?.question?.kind, "reverse_verify");
});
test("accepted reverse_verify keep reuses the persisted question id", () => {
const questionId = "reverse_verify:education_style:score";
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
const followup = plan.next_followup;
assert.equal(followup?.intent, "reverse_verify");
assert.equal(followup?.method_id, "reverse_verify");
assert.equal(followup?.ask_theme, "education_style");
assert.equal(stableFollowupQuestionId(followup!), questionId);
assert.match(followup?.user_prompt_hint ?? "", /当前排盘已采用该时间/);
const parsed = parsePersistedFollowupQuestionId(questionId);
assert.deepEqual(parsed, { method_id: "reverse_verify", ask_theme: "education_style", scoring: true });
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.ok(card);
assert.equal(card.focus_id, FOCUS_ID);
assert.equal(card.question_id, questionId);
assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL);
assert.ok(parseRectificationChoiceCard(card));
const again = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.equal(again?.question_id, card.question_id);
assert.equal(again?.focus_id, card.focus_id);
});
test("distinguish keep still uses probe: semantic_key ids", () => {
const questionId = "probe:education.2016";
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2020-01-01",
occurredTo: null,
}],
sessionOutcome: "discriminate_candidates",
eventProbes: [{
...EDUCATION_VERIFY,
role: "distinguish",
semantic_key: "education.2016",
candidate_split_hash: "education:2016:05:06|04:53",
candidate_ids: ["05:06", "04:53"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:06"], conflicts: ["04:53"] },
{ answer_class: "no", supports: ["04:53"], conflicts: ["05:06"] },
],
}],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "distinguish_candidates",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: {
semantic_key: "education.2016",
candidate_split_hash: "education:2016:05:06|04:53",
probe_id: "education.2016",
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
},
},
},
});
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
assert.equal(plan.next_followup?.semantic_key, "education.2016");
assert.equal(stableFollowupQuestionId(plan.next_followup!), questionId);
assert.equal(parsePersistedFollowupQuestionId(questionId), null);
});
test("post-adopt live question identity matches the last reverse_verify turn", () => {
const last = Array.isArray(postAdopt.turns)
? postAdopt.turns[postAdopt.turns.length - 1] as { question?: unknown }
: null;
const question = parseTurnQuestion(last?.question);
assert.equal(question?.kind, "reverse_verify");
assert.equal(question?.options?.length, 4);
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: question?.focus_id,
questionId: question?.question_id,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.equal(card?.focus_id, question?.focus_id);
assert.equal(card?.question_id, question?.question_id);
});
test("opening collect domain is education rather than unknown", () => {
assert.equal(OPENING_COLLECT_DOMAIN, "education");
assert.equal(collectQuestionDomain(null), "education");
assert.equal(collectQuestionDomain("unknown"), "education");
const fallback = spokenCollectFallbackFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
});
assert.equal(fallback.domain, "education");
assert.equal(stableFollowupQuestionId(fallback), "collect:education:collect_method_evidence");
assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/);
});
test("skipped collect questions stay attached to their asked turn", () => {
const attached = attachQuestionsToTurns(
[{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }],
[{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "family",
targetKind: null,
expectedAnswerSchema: {
collect: true,
prompt: "2021 年前后,家里有没有人住院、搬家或职责明显变化?",
},
status: "skipped",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: "2026-09-02T00:03:00.000Z",
askedTurnId: TURN_ID,
answerOption: "stop",
}],
);
assert.equal(attached[0]?.question?.status, "skipped");
assert.equal(attached[0]?.question?.kind, "collect_spoken");
});
test("chat owns adopt cards on the offering message and drops the list-end handoff", () => {
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /canShowRectificationReadonlyRange/);
assert.match(chat, /candidateOffer/);
assert.match(chat, /!candidateResult\?\.selectedTime/);
assert.match(chat, /rectification-adopt-status/);
assert.match(chat, /用这个时间看盘/);
assert.match(chat, /CHOICE_STOP_LABEL/);
assert.match(chat, /已跳过(已采用/);
assert.match(chat, /rectificationLiveProgressLabel/);
assert.doesNotMatch(chat, /rectification-consult-handoff/);
assert.match(chat, /kind === "reverse_verify"/);
assert.match(chat, /scoring: question\.kind !== "reverse_verify"/);
});
test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect", () => {
assert.equal(CHOICE_SKIP_QUESTION_LABEL, "这题跳过");
assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围");
const choiceCard = readFileSync(
new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url),
"utf8",
);
assert.match(choiceCard, /CHOICE_SKIP_QUESTION_LABEL/);
const prompt = readFileSync(
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
"utf8",
);
assert.match(prompt, /case\.accepted_time 非空时,正文第一句要说明已按该时间采用/);
assert.match(prompt, /必须先用一句话承接用户本轮给出的事实(年份\+事件)/);
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.match(voice, /不要预告选项/);
});
test("set-focus failures persist the spoken-prompt reason on the receipt", () => {
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
assert.match(tools, /resultFingerprint: JSON\.stringify\(\{ reason: spoken\.reason \}\)/);
assert.match(tools, /readVedastroMinuteSensitiveStatus\(decisionReceipt\) !== "passed"/);
const detail = parseToolActivityDetail({
error: "invalid_spoken_prompt",
result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }),
});
assert.equal(detail?.reason, "probe_mismatch");
assert.equal(detail?.error, "invalid_spoken_prompt");
});
test("elapsed_ms serializes from the turn receipt and 45s copy follows the live tool", () => {
const migration = readFileSync(
new URL("../supabase/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url),
"utf8",
);
assert.match(migration, /'elapsed_ms'/);
assert.match(migration, /'started_at'/);
assert.equal(existsSync(fileURLToPath(new URL("../db/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url))), false);
assert.equal(RECTIFICATION_SLOW_STEP_MS, 45_000);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-compare-candidates",
baseLabel: "正在比较候选时间…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_SLOW_STEP_MS,
}),
"引擎在比较候选,可能需要一两分钟",
);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-read-case",
baseLabel: "正在读取校正记录…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS,
}),
"即将超时,会自动重试或提示",
);
});
test("read_only compare reuses a passed vedastro validation and still names the helper", () => {
assert.equal(hasPersistedVedastroValidation({
vedastro_event_validation: { status: "failed" },
}), true);
assert.equal(hasPersistedVedastroValidation({
gates: { exact_confirmation: { vedastro_event_validation: { status: "passed" } } },
}), true);
assert.equal(hasPersistedVedastroValidation({}), false);
});
test("GET turn receipts expose elapsed_ms and set-focus failure detail", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_receipt: () => ({
turn_id: TURN_ID,
attempt_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
status: "completed",
skill_name: "jyotish-birth-time-rectification",
skill_version: "9.0.0",
engine_version: null,
phases: [],
tool_activities: [
{
tool: "rectification-compare-candidates",
status: "completed",
methods: ["d1-rashi"],
started_at: "2026-09-02T13:27:10.000Z",
elapsed_ms: 120000,
result_fingerprint: JSON.stringify({
hash: "abc",
engine_compare_ms: 80000,
vedastro_validate_ms: 30000,
persist_ms: 10000,
}),
},
{
tool: "rectification-set-focus",
status: "failed",
methods: [],
started_at: "2026-09-02T13:29:50.000Z",
elapsed_ms: 400,
error: "invalid_spoken_prompt",
result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }),
},
],
tools: ["rectification-compare-candidates"],
methods: ["d1-rashi"],
started_at: "2026-09-02T13:27:05.000Z",
completed_at: "2026-09-02T13:30:03.000Z",
}),
});
const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID);
assert.equal(receipt?.toolActivities[0]?.elapsedMs, 120000);
assert.equal(receipt?.toolActivities[0]?.detail?.engine_compare_ms, 80000);
assert.equal(receipt?.toolActivities[1]?.detail?.reason, "probe_mismatch");
});
@@ -534,17 +534,20 @@ test("time-selection cards use server adoption state and stay mutually exclusive
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
assert.match(chat, /candidateResult\?\.selectionAllowed/);
// 旧:showSelectionCards 只看 selectionAllowed && canAdopt && !showLiveChoiceCard && !busy
// 锚在 latestSettledAssistant。采集题没有 AD 卡,所以 collect_evidence 会出采用卡;
// 采用后 busy 把卡藏掉,新消息又把卡带走。
// 新:canShowRectificationSelectionCards(含 session_outcome 门)+ candidateOffer 所有权;
// 已采用后不再把卡锚到最新消息。核对题可与原消息上的结算卡并存。
// 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*candidateResult\?\.canAdopt[\s\S]*!showLiveChoiceCard[\s\S]*!busy[\s\S]*!readonly/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/);
assert.doesNotMatch(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")),
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")),
/offeredSelectionOnce/,
);
assert.match(
chat,
/selectionCardMessageKey = showSelectionCards && latestSettledAssistant\s*\? latestSettledAssistant\.renderKey\s*: undefined/,
);
assert.match(chat, /!candidateResult\?\.selectedTime \? latestSettledAssistant\?\.renderKey/);
assert.match(messageLoop, /showSelectionCards && message\.renderKey === selectionCardMessageKey/);
assert.match(messageLoop, /<RectificationChoiceCard/);
assert.match(messageLoop, /variant="embedded"/);
@@ -568,7 +571,8 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.match(caseRoute, /choice_card: choiceCardFromCaseDossier/);
assert.match(caseRoute, /current_question: projectCurrentQuestion/);
assert.match(caseRoute, /overlayPublicDecision/);
assert.match(caseRoute, /interview: publicDecisionFields/);
assert.match(caseRoute, /const fields = publicDecisionFields\(decision\)/);
assert.match(caseRoute, /interview: fields/);
assert.doesNotMatch(chat, /已完成验证/);
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
});
@@ -3,6 +3,8 @@ import test from "node:test";
import {
canRenderRectificationSelectionCards,
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
isRecommendedRectificationCandidate,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.ts";
@@ -284,3 +286,25 @@ test("adopted minute recasts the house table and keeps technique audit collapsed
assert.equal(result?.confirmationGate.blockers.some((item) => item.id === "public_aa_holdout" && item.status === "not_ready"), true);
});
test("session_outcome gates adopt cards independently of leaked can_adopt", () => {
const collecting = parseRectificationCandidateResult({
...camelCaseSnapshot,
session_outcome: "collect_evidence",
canAdopt: true,
can_adopt: true,
credible_range: ["04:53", "05:06"],
});
assert.equal(collecting?.canAdopt, true);
assert.equal(canRenderRectificationSelectionCards(collecting), true);
assert.equal(canShowRectificationSelectionCards(collecting), false);
assert.equal(canShowRectificationReadonlyRange(collecting), true);
const adopted = parseRectificationCandidateResult({
...camelCaseSnapshot,
session_outcome: "adopt_representative",
selectedTime: "05:07",
});
assert.equal(canShowRectificationSelectionCards(adopted), true);
assert.equal(canShowRectificationReadonlyRange(adopted), false);
});
@@ -11,7 +11,7 @@ import {
} from "../src/lib/rectification-agentic/v9/confirmation-gate.ts";
import { RECTIFICATION_POLICY } from "../src/lib/rectification-policy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideRectification, publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
createRectificationV9Tools,
latestResultToolProjection,
@@ -412,3 +412,29 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
);
assert.equal(safeToolErrorCode(new RectificationToolServiceError("confirmation_blocked")), "confirmation_blocked");
});
test("collect_evidence keeps public can_adopt closed while selection_allowed follows the decision", () => {
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
datedMethodCollectOpen: true,
datedEventCount: 4,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: [
{ time: "05:06", score: 14 },
{ time: "04:53", score: 13 },
],
confirmationAllowed: false,
});
// 旧:collect() 把 capability.canAdopt 原样投影成 can_adopt。
// 新:publicDecisionFields 再按 session_outcome 收紧;内部 canAdopt 仍可 true。
// 保留语义:selection_allowed 不因此关闭;确认门仍 fail-closed。
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.canAdopt, true);
const fields = publicDecisionFields(decision);
assert.equal(fields.can_adopt, false);
assert.equal(fields.selection_allowed, decision.selectionAllowed);
});
@@ -12,7 +12,7 @@ import {
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { evaluateCandidateSeparation } from "../src/lib/rectification-agentic/core/candidate-separation.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideRectification, publicCanAdopt, publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
classifySnapshotStaleReason,
@@ -586,3 +586,26 @@ test("MethodFollowup unions include holdout validation kinds used by next_follow
assert.match(source, /ask_theme: "holdout"/);
assert.match(source, /method_id: "holdout_validation"/);
});
test("collect_evidence with open capability still publishes can_adopt=false", () => {
const collecting = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
datedMethodCollectOpen: true,
datedEventCount: 4,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: TIED,
confirmationAllowed: false,
});
assert.equal(collecting.sessionOutcome, "collect_evidence");
assert.equal(collecting.canAdopt, true);
// 旧:collect() 把 capability.canAdopt 原样投影成 can_adopt。
// 新:publicDecisionFields 按 ADOPT_OUTCOMES 收紧;collect 期间公开 can_adopt=false。
// 保留语义:内部 canAdopt / selection_allowed 不变;holdout 已过的 validated_range 仍可公开采用。
assert.equal(publicDecisionFields(collecting).can_adopt, false);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
});
@@ -641,3 +641,60 @@ test("set-focus description no longer tells the Agent to write option_a", () =>
assert.match(hints, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/);
assert.doesNotMatch(hints, /不要写 expectedAnswerSchema\.choice/);
});
test("adopt continuation set-focus keeps the persisted reverse_verify question id", async () => {
const questionId = "reverse_verify:education_style:score";
const schema = {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
probe_year: 2016,
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
};
const raw = distinguishDossier(activeFocusFixture({
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: schema,
}));
(raw.case as { accepted_time: string | null }).accepted_time = "05:06:00";
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = {
focus: activeFocusFixture({
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: schema,
}),
};
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
set_agentic_rectification_conversation_focus: setFocusHandler(store),
});
const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
caseId: CASE_ID,
questionId,
intent: "reverse_verify",
spokenPrompt: "2016 年前后升学或学习环境有没有明显变化?",
}) as Record<string, unknown>;
assert.equal(result.error, undefined);
assert.equal(result.question_id, questionId);
assert.equal(result.focus_id, FOCUS_ID);
const copy = parseAgentChoiceCopy(result.expected_answer_schema);
assert.equal(copy?.options.length, 4);
const schemaRow = result.expected_answer_schema && typeof result.expected_answer_schema === "object"
? result.expected_answer_schema as Record<string, unknown>
: {};
assert.notEqual(schemaRow.semantic_key, RELOCATION_2015.semantic_key);
});
@@ -17,7 +17,6 @@ import {
spokenFollowupForUser,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts";
@@ -509,7 +508,12 @@ test("spoken collect followup persists a collect focus without a choice card", a
test("zero-evidence opening persists the server-owned first question", async () => {
const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup;
if (!followup) throw new Error("zero-evidence opening must produce a follow-up");
const expected = GENERIC_COLLECT_QUESTION;
// 旧:开场 domain 落成 unknown,题干是 GENERIC_COLLECT_QUESTION。
// 新:开场 domain 取轮转第一个领域 education,题干走该领域采集句。
// 保留语义:仍是 collect_spoken,仍由服务端 persistAgent 不自拟开场题。
assert.equal(followup.domain, "education");
assert.doesNotMatch(stableFollowupQuestionId(followup), /unknown/);
const expected = spokenFollowupForUser(followup);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
@@ -656,6 +660,8 @@ test("collect persist maps occupation to other and health_pressure to health", a
assert.equal(persistableFocusDomain("horary"), "horary");
assert.equal(persistableFocusDomain(null), null);
assert.equal(persistableFocusDomain("unknown"), null);
assert.equal(stableFollowupQuestionId(collectFollowup({ domain: "unknown" })), "collect:education:collect_method_evidence");
assert.equal(stableFollowupQuestionId(collectFollowup({ domain: null })), "collect:education:collect_method_evidence");
const occupation = collectFollowup({
method_id: "occupation",
@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-prompt.ts";
import { parseAgentChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { validateSpokenPrompt, withSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-prompt.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
@@ -115,3 +116,29 @@ test("spokenPrompt requires each year in a period when probe_year is empty", ()
targetDomain: "relationship",
}).ok, true);
});
test("withSpokenPrompt keeps a parseable choice prompt when spoken copy includes a clock time", () => {
const schema = {
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: [
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
{ key: "C", label: "明确没有发生", answer_class: "no" },
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
],
},
};
const next = withSpokenPrompt(
schema,
"已按 05:06 采用。现在核对:2016 年前后升学或学习环境有没有明显变化?",
);
const copy = parseAgentChoiceCopy(next);
assert.equal(copy?.options.length, 4);
assert.equal(copy?.prompt, "2016 年前后,升学结果或学习环境有没有明显变化?");
assert.equal(next.prompt, "已按 05:06 采用。现在核对:2016 年前后升学或学习环境有没有明显变化?");
});