fix(rectification): put each question inside the assistant message
Independent Staging Quality Gate / validate (push) Successful in 25m7s
Independent Staging Quality Gate / publish (push) Successful in 2m8s

Focuses now carry asked_turn_id so GET rebuilds stem and options on the
same turn. Agent writes spokenPrompt; the live question slot is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 19:53:27 +08:00
parent a594990a16
commit d94049769e
42 changed files with 1760 additions and 475 deletions
+33
View File
@@ -7536,3 +7536,36 @@
- 相关记录:BUG-486、BUG-467、BUG-352
- 复发自:BUG-486(键名别名与 demote 治标,值形状与缺失层未修)
- 修复版本:`7faf8555`staging;未提升 main
## BUG-490 | 校正题目必须在同一条助手消息里,删除问题槽
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`agentic_rectification_conversation_focuses.asked_turn_id` / `answer_option`、GET `/api/rectification/cases/[caseId]` `turns[].question``rectification-set-focus.spokenPrompt``rectification-agentic-chat``.rectification-question-slot`
- 用户现象:采集题、区分题、前事核对出现在助手气泡下面的独立问题槽,或刷新后只剩正文。产品要求每一问都是同一条助手消息:正文 → 题干 → 选项;已答原地变灰;刷新从 turn 重建。
- 触发条件:任意校正 opening / message / answer_choice / accept 后仍有 active focus。
- 根因:focus 没有挂到 asked turn,也没有记录所选选项。前端只能从 GET 直播槽或正文拼接恢复题目,所以必须另画 `.rectification-question-slot`。Agent 被禁止调用 set-focus,题干只能用服务端模板。
- 修复:focus 增加 `asked_turn_id``answer_option`。GET 按 `asked_turn_id``question` 接到 assistant turn。Agent 用 `rectification-set-focus.spokenPrompt` 写题干(校验失败两次落模板)。题干和选项画在同一条 `<article>` 里。删除问题槽与把题干拼进 `assistant_message` 的路径。旧 turn 仅在精确后缀 `\n\n${prompt}``asked_turn_id` 命中时 detach。不 bump Skill 10.0.14,确认门仍 fail-closed。
- 验证:`rectification-question-in-message``rectification-spoken-prompt``rectification-spoken-collect` / `rectification-collect-prompt` / `rectification-walkthrough-polish` / `rectification-answer-choice` / `rectification-decision-authority` / `rectification-varga-style-copy` 迁到新合同;`rectification-v10-tool-contract` 锁 Agent 可调用 set-focus,两次 `invalid_spoken_prompt``retryable=false` 落模板。
- 防复发:DOM 中不得再出现 `.rectification-question-slot`。运行时不得用正文字符串判断「有没有问过」。任意 active focus 必须能在某条 assistant turn 的 `question.focus_id` 上找到。缺题状态只出现在输入框上方。本题废止 BUG-441/461/471/485/487/488 中与「槽」或「正文不得含题干」绑定的条款;其余条款保留(有选项时题干可见、共用 `--assistant-content-inset`、不得用正文判断状态、开场不得并行提问、不得追加第二条 opening 题干消息)。
- 相关记录:BUG-441、BUG-461、BUG-471、BUG-480、BUG-485、BUG-487、BUG-488、BUG-491
- 复发自:BUG-488(拼接进正文后选择卡仍走槽,刷新合同仍不完整)
- 修复版本:待发布
## BUG-491 | message 路径预建 choice focus 不挂 asked_turn_id,刷新丢题干
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`persistNextInterviewIfIdle``finalizeSuccessfulTurnExit`、agent-run message 路径预建 choice focus
- 用户现象:用户回答采集题后进入区分题时,在线会话也许能看到下一问;刷新后对应助手消息没有题干和选项。
- 触发条件:message 路径上 agent-run 已为下一问建好 choice focus,随后 `finalizeSuccessfulTurnExit` 因已有 focus 早退,不写题干 turn。引入提交约为 `39c30b55`
- 根因:题干曾依赖第二条 assistant turn 或正文拼接。已有 active focus 时 idle persist 直接返回,不把 focus 链到当前 turn。
- 修复:题干改为 turn 上的 `question`。idle persist 若已有 active focus,仍用当前 `turnId` 回写 `asked_turn_id`。GET 按该列重建。不再需要第二条题干 turn,早退不再丢题。
- 验证:`rectification-question-in-message` 锁「预建 choice focus + askedTurnId → set_focus.p_asked_turn_id」;GET `turns[].question``asked_turn_id` 连接,禁止按时间戳推断。
- 防复发:opening / message / answer_choice / accept 建立的每个 focus 都必须有 `asked_turn_id`,且 GET `turns[]` 恰有一条 assistant turn 带该 `question`。不得按 `asked_at``created_at` 对题。
- 相关记录:BUG-480、BUG-490
- 复发自:无
- 修复版本:待发布
+17 -4
View File
@@ -1,6 +1,6 @@
# Voice
Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、问题槽归属、代表分钟边界)不靠冷冰冰的句子来守。
Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、题干归属、代表分钟边界)不靠冷冰冰的句子来守。
## 人设
@@ -23,6 +23,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 坏 | 好 | 为什么 |
|---|---|---|
| 请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。 | 从你最容易想起来的开始就好——比如哪年上的大学、哪年换的工作,记得大概年份就行。 | 公文枚举改成一件容易开口的事。 |
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
| (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 |
| 有没有记得住时间的收入变化、大笔支出或欠债? | 钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗? | 去掉审讯口吻,先给领域引子。 |
| 请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。 | 直接点下面的选项就行,打字回答也一样算数。 | 内部计分规则不进聊天。 |
| 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 |
@@ -30,13 +33,23 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在问题槽,过渡用中性句。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
## 红线(语气改不了这些)
## 服务端探针 → Agent 题干
题干由 Agent 通过 `rectification-set-focus.spokenPrompt` 写;年份、事件家族、选项语义必须与服务端探针一致。
| 服务端探针 | 坏 | 好 | 为什么 |
|---|---|---|---|
| 2023 感情区分 | 2023 年前后,你有没有一段认真开始或结束的关系?(逐字复读模板) | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读整句模板。 |
| 2016 升学 | 2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
| 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 |
- 不得虚构星盘事实或唯一出生分钟。
-题槽仍由服务端 focus 唯一所有;正文不得出题、复述或改写题干
-哪道题、年份、选项语义仍由服务端 focus 唯一所有;Agent 用 `spokenPrompt` 写题干,不得改年份、不得改选项含义。正文不得出题、复述或改写题干——题干作为同一条消息里正文之后的独立段落出现
- 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。
- 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。
- 技法审计表仍在本命回答文末。
@@ -737,6 +737,7 @@ export async function POST(request: Request) {
caseId,
action,
assistantBodyPresent: Boolean(result.answerText?.trim()),
askedTurnId: result.turnId,
});
} catch (error) {
const code = error instanceof RectificationToolServiceError ? error.code : "run_failed";
@@ -9,10 +9,13 @@ import {
loadV9TurnReceipt,
receiptStatusFromTurn,
RectificationToolServiceError,
listV10ConversationFocuses,
type ConversationFocus,
type V9CaseDossier,
} 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 { 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";
@@ -69,7 +72,8 @@ export async function GET(request: Request, context: RouteContext) {
}
}),
);
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail }));
const focuses = await listV10ConversationFocuses(accounting, user.id, caseId);
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, focuses }));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const message = error.message;
@@ -88,11 +92,12 @@ function dossierResponse(
dossier: V9CaseDossier,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
options: { fullDetail?: boolean } = {},
options: { fullDetail?: boolean; focuses?: readonly ConversationFocus[] } = {},
) {
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const turns = attachQuestionsToTurns(dossier.turns, options.focuses ?? []);
return {
case: {
case_id: dossier.case.caseId,
@@ -110,13 +115,14 @@ function dossierResponse(
last_activity_at: dossier.case.lastActivityAt,
case_revision: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.revision ?? 0,
},
turns: dossier.turns.map((turn) => ({
turns: turns.map((turn) => ({
id: turn.id,
role: turn.role,
text: turn.text,
status: turn.status,
created_at: turn.createdAt,
receipt: turnReceipt(turn.id, receipts),
question: turn.question,
})),
evidence: dossier.evidence,
latest_result: dossier.latestResult
+19 -15
View File
@@ -2898,30 +2898,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
}
.rectification-question-slot {
.rectification-message-question {
display: grid;
gap: var(--space-2);
width: calc(100% - var(--assistant-content-inset));
margin-block: var(--space-3);
margin-inline-start: var(--assistant-content-inset);
}
.rectification-message-wrap .rectification-question-slot {
width: calc(100% - var(--assistant-content-inset));
margin-inline-start: var(--assistant-content-inset);
}
.rectification-message-wrap .rectification-question-slot > .rectification-choice-card {
gap: var(--space-3);
margin-block-start: var(--space-3);
width: 100%;
margin-inline-start: 0;
}
.rectification-question-slot__prompt {
.rectification-message-question__prompt {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-sm);
font-weight: 600;
line-height: 1.55;
}
.rectification-question-slot__status {
margin: 0;
.rectification-composer-status {
margin: 0 0 var(--space-2);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
@@ -2976,6 +2967,19 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
margin: var(--space-3) 0 var(--space-4);
margin-inline-start: var(--assistant-content-inset);
}
.rectification-message-wrap .rectification-choice-card.is-embedded,
.message-bubble .rectification-choice-card.is-embedded {
width: 100%;
margin: 0;
margin-inline-start: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.rectification-choice-card.is-embedded .birth-time-choice-question legend {
display: unset;
}
.rectification-choice-why {
margin: 0 0 var(--space-3);
color: var(--color-ink-secondary);
+8 -3
View File
@@ -5,7 +5,7 @@ import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
type GsapCore = typeof import("gsap")["gsap"];
@@ -39,10 +39,12 @@ export function ChatMessageRow({
message,
showActivity = message.state !== "settled",
vargaSentence,
afterAnswer,
}: Readonly<{
message: ChatMessageView;
showActivity?: boolean;
vargaSentence?: string | null;
afterAnswer?: ReactNode;
}>) {
const messageRow = useRef<HTMLElement>(null);
const assistantLabel = message.state === "thinking"
@@ -137,8 +139,9 @@ export function ChatMessageRow({
{hasAnswer ? (
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
{afterAnswer}
</section>
) : null}
) : afterAnswer}
</div>
) : (
<>
@@ -147,17 +150,19 @@ export function ChatMessageRow({
{thinkingPanel}
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
{afterAnswer}
</section>
</div>
) : (
<>
{thinkingPanel}
{spokenAnswer}
{afterAnswer}
</>
)}
</>
)
) : <p>{message.text}</p>}
) : <><p>{message.text}</p>{afterAnswer}</>}
</div>
</div>
</article>
@@ -9,6 +9,7 @@ export type PersistedRectificationTurn = Readonly<{
role: "user" | "assistant";
text: string | null;
status: string;
question?: unknown;
receipt?: Readonly<{
status: string;
phases: readonly string[];
@@ -57,13 +57,7 @@ import {
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { composeCollectSpokenAssistantText } from "@/lib/rectification-agentic/v9/collect-prompt";
import {
isPersistedFocusId,
parseRectificationChoiceCard,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import { CHOICE_MODE, CHOICE_STOP_LABEL, 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";
@@ -77,13 +71,15 @@ import {
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import { Button } from "./ui/button";
import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
type PersistedTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
question?: unknown;
receipt?: Readonly<{
status: string;
phases: readonly string[];
@@ -106,40 +102,24 @@ type CandidateResult = RectificationCandidateResult | null;
type CurrentQuestionModel = Readonly<{
kind: "choice" | "collect_spoken";
prompt: string | null;
focus_id: string | null;
question_id: string | null;
}>;
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const question = value as { kind?: unknown; prompt?: unknown };
const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown };
if (question.kind !== "choice" && question.kind !== "collect_spoken") return null;
return {
kind: question.kind,
prompt: typeof question.prompt === "string" && question.prompt.trim()
? question.prompt.trim()
: null,
focus_id: typeof question.focus_id === "string" ? question.focus_id : null,
question_id: typeof question.question_id === "string" ? question.question_id : null,
};
}
function collectSpokenPromptFromQuestion(question: CurrentQuestionModel | null): string | null {
return question?.kind === "collect_spoken" ? question.prompt : null;
}
function attachCollectSpokenStem(current: RenderMessage[], prompt: string): RenderMessage[] {
const last = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text)
));
if (!last) return current;
const combined = composeCollectSpokenAssistantText(last.text, prompt);
if (combined === last.text) return current;
return current.map((item) => (
item.renderKey === last.renderKey ? { ...item, text: combined } : item
));
}
function RectificationCandidateCards({
result,
acceptingCandidateId,
@@ -207,20 +187,93 @@ type RectificationAgenticChatProps = Readonly<{
headerSlot: HTMLElement | null;
}>;
type SettledChoiceAttachment = Readonly<{
card: ChoiceCardModel;
selectedKey: ChoiceKey | "stop";
}>;
type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
choiceAttachment?: SettledChoiceAttachment;
question?: TurnQuestion;
};
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const byId = new Map<string, TurnQuestion | null>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, parseTurnQuestion(turn.question));
}
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 };
});
}
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "typed",
): RenderMessage[] {
return current.map((message) => {
const question = message.question;
if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message;
return {
...message,
question: {
...question,
status: "resolved",
answer_option: selected === "typed" ? null : selected,
},
};
});
}
function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] {
return Array.isArray(payload?.turns) ? payload.turns : [];
}
function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => (
message.role === "assistant"
&& message.turnId
&& !known.has(message.turnId)
));
return extras.length ? [...current, ...extras] : current;
}
function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
): ChoiceCardModel | null {
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
return { ...live, prompt: question.prompt };
}
if (!question.options || question.options.length !== 4) return null;
return {
question_id: question.question_id,
method_id: "",
prompt: question.prompt,
why: "",
varga: null,
choice_mode: CHOICE_MODE,
options: question.options.map((option) => ({
key: option.key,
label: option.label,
answer_class: "unsure",
role: "primary",
})),
stop_label: CHOICE_STOP_LABEL,
stop_message: "先这样",
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
};
}
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
@@ -282,6 +335,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
activityTrace: activityTraceFromReceipt(completedReceipt),
failed,
turnId: turn.id,
question: parseTurnQuestion(turn.question) ?? undefined,
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
@@ -392,6 +446,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
turns?: unknown;
case?: {
status?: unknown;
accepted_time?: unknown;
@@ -421,7 +476,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, []);
const loadCaseSnapshot = useCallback(async (): Promise<CurrentQuestionModel | null | undefined> => {
const loadCaseSnapshot = useCallback(async (): Promise<{
question: CurrentQuestionModel | null;
turns: readonly unknown[];
} | null | undefined> => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
@@ -430,7 +488,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
applyCaseSnapshot(payload);
return currentQuestionFromSnapshot(payload?.current_question);
return {
question: currentQuestionFromSnapshot(payload?.current_question),
turns: snapshotTurns(payload),
};
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
return undefined;
@@ -447,8 +508,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
.then((payload) => {
if (controller.signal.aborted) return;
applyCaseSnapshot(payload);
const prompt = collectSpokenPromptFromQuestion(currentQuestionFromSnapshot(payload?.current_question));
if (prompt) setMessages((current) => attachCollectSpokenStem(current, prompt));
})
.catch(() => {
// Snapshot refresh is best-effort; the durable Case remains on the server.
@@ -468,10 +527,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = `v9-assistant-${turnKey}`;
const pendingCollectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current);
const pendingFocusId = currentQuestionRef.current?.focus_id;
setMessages((current) => [
...(action === "message" && pendingCollectPrompt
? attachCollectSpokenStem(current, pendingCollectPrompt)
...(action === "message" && pendingFocusId
? markQuestionAnswered(current, pendingFocusId, "typed")
: current),
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
@@ -679,16 +738,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);
const collectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current);
const settledText = succeeded && collectPrompt
? composeCollectSpokenAssistantText(parsed.text, collectPrompt)
: parsed.text;
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (succeeded) {
return [{
...message,
text: settledText,
text: parsed.text,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
@@ -718,18 +773,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError((current) => current || userFacingRunFailure("run_failed"));
}
if (succeeded) {
const snapshotPrompt = collectSpokenPromptFromQuestion(await loadCaseSnapshot() ?? null);
const attachedText = snapshotPrompt
? composeCollectSpokenAssistantText(parsed.text, snapshotPrompt)
: settledText;
if (attachedText !== settledText) {
setMessages((current) => current.map((message) => (
message.renderKey === assistantRenderKey ? { ...message, text: attachedText } : message
)));
const snapshot = await loadCaseSnapshot();
if (snapshot?.turns.length) {
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
}
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: attachedText },
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
}
@@ -797,26 +847,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
const questionId = choiceCard.question_id;
const actionId = actionIdForChoice(focusId, optionId);
const answeredCard = choiceCard;
const offeringRenderKey = [...messages].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.choiceAttachment
))?.renderKey ?? null;
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
setMessages((current) => [
...current.map((message) => (
offeringRenderKey && message.renderKey === offeringRenderKey
? {
...message,
choiceAttachment: { card: answeredCard, selectedKey: optionId },
}
: message
)),
...markQuestionAnswered(current, focusId, optionId),
{
role: "assistant",
text: "",
@@ -852,13 +888,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
setMessages((current) => current.flatMap((message) => {
if (message.renderKey === assistantRenderKey) return [];
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
return [{ ...message, choiceAttachment: undefined }];
}
return [message];
}));
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
@@ -869,34 +899,42 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
onCompleted?.();
await loadCaseSnapshot();
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
if (willContinue) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setMessages((current) => mergeTurnQuestions(
current.filter((message) => message.renderKey !== assistantRenderKey),
turns,
));
choiceContinuationPending.current = true;
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: narration,
state: "settled" as const,
activity: undefined,
}
: message));
setMessages((current) => {
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
const withHistory = appendUnseenAssistantTurns(
mergeTurnQuestions(withoutPlaceholder, turns),
turns,
);
if (withHistory.length > withoutPlaceholder.length) return withHistory;
return [
...withHistory,
{
role: "assistant" as const,
text: narration,
renderKey: assistantRenderKey,
state: "settled" as const,
activity: undefined,
},
];
});
onMessagesChange?.([
{ role: "assistant", text: narration },
]);
}
} catch {
setMessages((current) => current.flatMap((message) => {
if (message.renderKey === assistantRenderKey) return [];
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
return [{ ...message, choiceAttachment: undefined }];
}
return [message];
}));
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
setError("选择题处理失败,请稍后重试。");
} finally {
@@ -908,7 +946,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
caseId,
choiceCard,
loadCaseSnapshot,
messages,
onCompleted,
onMessagesChange,
onProfileIncomplete,
@@ -997,7 +1034,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
async function copyMessage(message: RenderMessage) {
try {
await navigator.clipboard.writeText(message.text);
await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question));
setCopiedMessageKey(message.renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === message.renderKey ? null : current
@@ -1028,10 +1065,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") {
throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答");
}
const collectPrompt = collectSpokenPromptFromQuestion(currentQuestion);
const nextText = collectPrompt
? composeCollectSpokenAssistantText(payload.assistantMessage, collectPrompt)
: payload.assistantMessage;
const nextText = payload.assistantMessage;
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: nextText, state: "settled" }
: item));
@@ -1067,15 +1101,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !message.failed
&& Boolean(message.text)
));
const answeredQuestionIds = new Set(
messages.flatMap((message) => message.choiceAttachment
? [message.choiceAttachment.card.question_id]
: []),
);
const latestLiveQuestion = [...messages].reverse().find((message) => (
message.role === "assistant"
&& message.question
&& !questionIsAnswered(message.question)
))?.question ?? null;
const showLiveChoiceCard = Boolean(
currentQuestion?.kind === "choice"
&& choiceCard
&& !answeredQuestionIds.has(choiceCard.question_id)
latestLiveQuestion
&& latestLiveQuestion.options?.length === 4
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
@@ -1097,6 +1131,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const liveQuestionOnMessages = messages.some((message) => (
message.role === "assistant"
&& message.state === "settled"
&& message.question
&& currentQuestion
&& message.question.focus_id === currentQuestion.focus_id
&& !questionIsAnswered(message.question)
));
const showMissingQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
@@ -1110,13 +1152,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !readonly
&& !busy
&& currentQuestion !== null
&& ((currentQuestion.kind === "choice" && !choiceCard)
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
);
const showQuestionSlot = Boolean(
showLiveChoiceCard
|| showMissingQuestion
|| showUnavailableQuestion,
&& !liveQuestionOnMessages,
);
const canSend = !busy && !readonly && !regeneratingMessageKey;
@@ -1191,28 +1227,48 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
settled: message.state === "settled",
}),
};
const settledChoice = message.choiceAttachment;
const visibleChoiceCard = settledChoice?.card;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey;
const bubbleText = !regenerating
&& isLatestMessage
&& message.role === "assistant"
&& collectSpokenPrompt
? composeCollectSpokenAssistantText(displayedMessage.text, collectSpokenPrompt)
: displayedMessage.text;
const bubbleMessage = bubbleText === displayedMessage.text
? displayedMessage
: { ...displayedMessage, text: bubbleText };
const question = regenerating ? undefined : message.question;
const liveQuestion = Boolean(
question
&& currentQuestion?.focus_id === question.focus_id
&& !questionIsAnswered(question)
&& choiceCard
&& choiceCard.focus_id === question.focus_id
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null;
const afterAnswer = question && displayedMessage.state === "settled"
? (
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{question.prompt}</p>
{embeddedCard && (
<RectificationChoiceCard
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
variant="embedded"
card={embeddedCard}
pending={busy && liveQuestion}
disabled={!liveQuestion}
selectedKey={question.answer_option ?? ""}
onSelect={submitChoice}
onStop={submitStop}
/>
)}
</div>
)
: undefined;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{(!message.failed || Boolean(bubbleMessage.text) || regenerating) && (
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={bubbleMessage}
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
afterAnswer={afterAnswer}
/>
)}
{showActions && !regenerating && (
@@ -1224,7 +1280,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
}))}
onCopy={() => void copyMessage({ ...message, text: bubbleText })}
onCopy={() => void copyMessage(message)}
onRegenerate={() => void regenerateMessage(message)}
/>
)}
@@ -1236,72 +1292,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onAccept={(candidateId) => void acceptCandidate(candidateId)}
/>
)}
{visibleChoiceCard && (
<RectificationChoiceCard
key={settledChoice
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
card={visibleChoiceCard}
pending={false}
disabled={readonly || Boolean(settledChoice)}
selectedKey={settledChoice?.selectedKey ?? ""}
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{isLatestMessage && showQuestionSlot && (
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
</section>
)}
</div>
);
})}
{messages.length === 0 && showQuestionSlot && (
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
</section>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -1328,6 +1321,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{(showMissingQuestion || showUnavailableQuestion) && (
<p className="rectification-composer-status" role="status">
{showMissingQuestion
? "当前没有可回答的问题,正在等待服务端更新。"
: "当前问题暂时无法显示,请等待服务端更新。"}
</p>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -13,6 +13,8 @@ type RectificationChoiceCardProps = Readonly<{
selectedKey?: ChoiceKey | "stop" | "";
onSelect: (key: ChoiceKey) => void;
onStop: () => void;
variant?: "card" | "embedded";
hidePrompt?: boolean;
}>;
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
@@ -38,9 +40,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
props.onStop();
}
const embedded = props.variant === "embedded";
const hidePrompt = embedded || props.hidePrompt === true;
return (
<section
className={`rectification-choice-card${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
className={`rectification-choice-card${embedded ? " is-embedded" : ""}${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
>
<fieldset
@@ -48,8 +53,10 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
disabled={props.pending || props.disabled || answered}
data-answered={answered ? "true" : "false"}
>
<legend>{props.card.prompt}</legend>
{props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
{hidePrompt
? <legend className="sr-only">{props.card.prompt}</legend>
: <legend>{props.card.prompt}</legend>}
{!hidePrompt && props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
<div className="birth-time-primary-choices">
{props.card.options.map((option, index) => (
<button
@@ -128,11 +128,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
if (!response.ok) return;
const payload = await response.json().catch(() => null);
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown; question?: unknown }) => ({
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
question: turn?.question ?? null,
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
@@ -29,8 +29,6 @@ import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { persistNextInterviewIfIdle } from "./answer-choice";
import { projectCurrentQuestion } from "./turn-decision";
import { composeCollectSpokenAssistantText } from "./collect-prompt";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import {
resolveExactSkillPackage,
@@ -228,7 +226,7 @@ function openingBrief(dossier: V9CaseDossier): string {
`Case 状态:${dossier.case.status}`,
`出生时间不确定类型:${uncertaintyType}`,
`已有证据摘要:已确认 ${confirmed.length} 条,待澄清或待确认 ${pending.length}${domains.length ? `;已覆盖 ${domains.join("、")}` : ""}`,
"当前 active focus 的题干由服务器接在正文末尾并写入聊天历史。正文只打招呼,说明可以慢慢说、记得大概年份即可,不要要求一次说完。不要提问,不要举大学、工作、搬家的例子。",
"正文只打招呼,说明可以慢慢说、记得大概年份即可,不要要求一次说完。不要提问,不要举大学、工作、搬家的例子。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题。",
].join("\n");
}
@@ -502,32 +500,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
null,
outcome.usage,
);
let answerText = outcome.answerText;
let collectSpokenAttached = collectSpokenEmitted;
const answerText = outcome.answerText;
if (action === "opening" || action === "evidence") {
try {
await persistNextInterviewIfIdle({ accounting, userId, caseId });
await persistNextInterviewIfIdle({ accounting, userId, caseId, askedTurnId: turnId });
} catch (error) {
console.warn(
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
);
}
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const question = projectCurrentQuestion(latest.conversationSummary.activeFocus);
if (question?.kind === "collect_spoken" && question.prompt) {
const combined = composeCollectSpokenAssistantText(answerText, question.prompt);
if (combined !== answerText.trim()) {
await emit({ type: "answer.delta", text: combined, replace: true });
collectSpokenAttached = true;
}
answerText = combined;
}
} catch (error) {
console.warn(
`[rectification-v9] attach collect prompt failed case=${caseId} reason=${safeErrorCode(error)}`,
);
}
}
await finalizeTurn("completed", answerText, outcome.attemptId, outcome.attemptId, true);
@@ -544,7 +525,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: outcome.toolsUsed,
errorCode: null,
previousFocusId,
collectSpokenEmitted: collectSpokenAttached,
collectSpokenEmitted,
};
async function streamAttempt(attemptNumber: number, attemptId: string): Promise<AttemptOutcome> {
@@ -0,0 +1,17 @@
/**
* User-visible machine-voice phrases that must not appear in Agent spoken
* prompts or in the shared copy module. Keep in source so validation and
* tests share one list; do not scan assistant body text at runtime.
*/
export const MACHINE_VOICE_LEXICON = [
"按同一规则计入",
"请刷新后重新作答",
"当前状态为",
"请点选下面的选项作答",
"当前区分题",
"接下来请点选下面这一问",
"可以从下面的时间里选一个采用",
"我没能确定这句话是否在回答当前问题",
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
] as const;
@@ -42,11 +42,12 @@ import {
RectificationToolServiceError,
safeToolErrorCode,
type AccountingClient,
type ConversationFocus,
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import { isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion } from "./server-focus";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn } from "./server-focus";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
@@ -364,7 +365,14 @@ export async function persistNextInterviewAfterChoice(input: {
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
askedTurnId?: string | null;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
persisted?: boolean;
focusId?: string | null;
focus?: ConversationFocus | null;
}> {
const latest = input.dossier.latestResult
? {
...input.dossier.latestResult,
@@ -417,18 +425,30 @@ export async function persistNextInterviewAfterChoice(input: {
caseId: input.caseId,
decisionReceipt: latest.decisionReceipt,
followup,
askedTurnId: input.askedTurnId ?? null,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return { hostNarration: open.prompt, choiceReady: true };
return {
hostNarration: open.prompt,
choiceReady: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
return { hostNarration: open.prompt, choiceReady: false };
return {
hostNarration: open.prompt,
choiceReady: false,
persisted: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
};
}
if (followup?.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup) ?? GENERIC_COLLECT_QUESTION;
await persistFocusAfterChoice({
const fallback = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -437,8 +457,14 @@ export async function persistNextInterviewAfterChoice(input: {
...spokenFollowup,
user_prompt_hint: spoken,
},
askedTurnId: input.askedTurnId ?? null,
});
return { hostNarration: spoken, choiceReady: false };
return {
hostNarration: spoken,
choiceReady: false,
focusId: fallback.focus?.id ?? null,
focus: fallback.focus,
};
}
if (followup?.intent === "collect_method_evidence") {
const spoken = spokenFollowupForUser(followup);
@@ -446,7 +472,7 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
return { hostNarration: spoken, choiceReady: false };
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus };
}
return persistExhaustionCollect({
accounting: input.accounting,
@@ -458,6 +484,7 @@ export async function persistNextInterviewAfterChoice(input: {
representativeTime: input.nextAction.representative_time,
},
decisionReceipt: latest.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
if (!followup) {
@@ -477,6 +504,7 @@ export async function persistNextInterviewAfterChoice(input: {
representativeTime: input.nextAction.representative_time,
},
decisionReceipt: latest.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
return {
@@ -501,6 +529,7 @@ async function persistFocusAfterChoice(input: {
caseId: string;
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
askedTurnId?: string | null;
}) {
let persisted;
try {
@@ -511,6 +540,7 @@ async function persistFocusAfterChoice(input: {
activeFocus: null,
decisionReceipt: input.decisionReceipt,
followup: input.followup,
askedTurnId: input.askedTurnId ?? null,
});
} catch (error) {
console.warn(
@@ -535,6 +565,7 @@ async function persistFocusAfterChoice(input: {
activeFocus: dossier.conversationSummary.activeFocus,
decisionReceipt: input.decisionReceipt,
followup: input.followup,
askedTurnId: input.askedTurnId ?? null,
});
} catch (error) {
console.warn(
@@ -618,6 +649,7 @@ export async function persistNextInterviewIfIdle(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
askedTurnId?: string | null;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const staleFocus = dossier.conversationSummary.activeFocus;
@@ -639,6 +671,21 @@ export async function persistNextInterviewIfIdle(input: {
}
}
if (dossier.conversationSummary.activeFocus) {
if (input.askedTurnId) {
try {
await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus: dossier.conversationSummary.activeFocus,
askedTurnId: input.askedTurnId,
});
} catch (error) {
console.warn(
`[rectification-v9] link idle focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
}
return { persisted: false, choiceReady: false, hostNarration: null };
}
let birthDate: string | null = null;
@@ -686,6 +733,7 @@ export async function persistNextInterviewIfIdle(input: {
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
if (!followup) {
@@ -700,6 +748,7 @@ export async function persistNextInterviewIfIdle(input: {
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
birthDate,
askedTurnId: input.askedTurnId ?? null,
});
return {
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
@@ -724,7 +773,13 @@ async function persistExhaustionCollect(input: {
};
decision: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null };
decisionReceipt?: Readonly<Record<string, unknown>> | null;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string }> {
askedTurnId?: string | null;
}): Promise<{
persisted: boolean;
choiceReady: boolean;
hostNarration: string;
focus?: ConversationFocus | null;
}> {
const followup = exhaustionSpokenCollectFollowup({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
@@ -742,6 +797,7 @@ async function persistExhaustionCollect(input: {
caseId: input.caseId,
decisionReceipt: input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt,
followup,
askedTurnId: input.askedTurnId ?? null,
})
: { status: "skipped" as const, focus: null, questionId: null, prompt: null };
const persisted = Boolean(spoken) && (
@@ -751,6 +807,7 @@ async function persistExhaustionCollect(input: {
persisted,
choiceReady: false,
hostNarration: withProspectiveWindows(range, input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt),
focus: persistedFocus.focus,
};
}
@@ -813,6 +870,7 @@ async function persistApplied(
let nextChoiceReady = false;
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
@@ -829,12 +887,13 @@ async function persistApplied(
});
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
nextFocus = nextInterview.focus ?? null;
if (nextInterview.hostNarration) {
hostNarration = skippedNextInterview
? `${input.narration}
if (skippedNextInterview) {
hostNarration = `${input.narration}
${nextInterview.hostNarration}`
: nextInterview.hostNarration;
${nextInterview.hostNarration}`;
}
nextInterviewPersisted = true;
}
}
@@ -866,12 +925,31 @@ ${nonConvergingRangeNarration({
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
) {
try {
await persistV9DeterministicTurn(accounting, command.userId, command.caseId, {
const turn = await persistV9DeterministicTurn(accounting, command.userId, command.caseId, {
requestId: command.actionId,
userMessage: input.userDisplay,
assistantMessage: hostNarration,
});
narrationPersisted = true;
if (turn.turnId) {
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
if (focus) {
await linkFocusAskedTurn({
accounting,
userId: command.userId,
caseId: command.caseId,
focus,
askedTurnId: turn.turnId,
});
}
} catch (error) {
console.warn(
`[rectification-v9] link choice focus to turn failed case=${command.caseId} reason=${safeToolErrorCode(error)}`,
);
}
}
} catch {
narrationPersisted = false;
}
@@ -942,7 +942,7 @@ function collectHint(
extra = "",
evidence: readonly MethodFollowupEvidence[] = [],
): string {
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)} rectification-set-focus 的 spokenPrompt 用自然语言写出采集题。正文不提问,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
}
function agentHint(
@@ -951,7 +951,7 @@ function agentHint(
extra = "",
evidence: readonly MethodFollowupEvidence[] = [],
): string {
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用简体中文自己写一句追问;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim();
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用 rectification-set-focus 的 spokenPrompt 写出题干;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。正文不要提问、不要复述选项。`.replace(/\s+/g, " ").trim();
}
export function shouldAttachChoiceFrame(
@@ -1544,8 +1544,8 @@ export function buildMethodFollowupPlan(input: {
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。自己写一句追问;年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。不要调用 set-focus。正文不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则继续用自然语言问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
@@ -55,9 +55,7 @@ export const PUBLIC_RECTIFICATION_TOOLS = [
export type PublicRectificationTool =
(typeof PUBLIC_RECTIFICATION_TOOLS)[number];
export const RECTIFICATION_AGENT_TOOLS = PUBLIC_RECTIFICATION_TOOLS.filter(
(tool) => tool !== "rectification-set-focus",
);
export const RECTIFICATION_AGENT_TOOLS = [...PUBLIC_RECTIFICATION_TOOLS];
export const PUBLIC_RECTIFICATION_METHODS = [
"d1-rashi",
@@ -2,12 +2,12 @@ import type { MessageListInput } from "@mastra/core/agent/message-list";
import { isTerminalStatus, type RectificationCaseStatus } from "./case-status";
import {
insertV9SkillRunReceipt,
listV10ConversationFocuses,
loadV9CaseDossier,
RectificationToolServiceError,
type RectificationRpcClient,
} from "./tool-service";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "./collect-prompt";
import { projectCurrentQuestion } from "./turn-decision";
import { attachQuestionsToTurns } from "./turn-question";
import type { ResolvedSkillPackageIdentity } from "../../skill-package-registry.ts";
export type RectificationRegenerationAgent = Readonly<{
@@ -49,7 +49,7 @@ function regenerationPrompt(caseId: string, oldAssistantMessage: string): string
return [
`当前校正 Case 引用:${caseId}`,
"请先加载绑定 Skill,再调用 rectification-read-case。",
"随后只重写下面这条最近的 Agent 正文,使其更自然、准确,并符合当前服务端事实。不要描述后台过程,不要执行或声称执行任何写操作。",
"随后只重写下面这条最近的 Agent 正文,使其更自然、准确,并符合当前服务端事实。不要描述后台过程,不要执行或声称执行任何写操作。不要提问,不要复述题干。",
"",
"待替换的旧正文:",
oldAssistantMessage,
@@ -112,7 +112,9 @@ export async function regenerateV9AssistantTurn(
throw new RectificationToolServiceError("agentic_rectification_case_terminal");
}
const target = dossier.turns.find((turn) => (
const focuses = await listV10ConversationFocuses(accounting, userId, caseId);
const turns = attachQuestionsToTurns(dossier.turns, focuses);
const target = turns.find((turn) => (
turn.id === turnId
&& turn.role === "assistant"
&& turn.status === "completed"
@@ -145,24 +147,14 @@ export async function regenerateV9AssistantTurn(
skillPackage,
);
const question = projectCurrentQuestion(dossier.conversationSummary.activeFocus);
const collectPrompt = question?.kind === "collect_spoken" && question.prompt
? question.prompt
: null;
const sourceText = collectPrompt
? detachCollectSpokenAssistantText(target.text, collectPrompt)
: target.text;
const generated = await agent.generate(
[{ role: "user", content: regenerationPrompt(caseId, sourceText) }],
[{ role: "user", content: regenerationPrompt(caseId, target.text) }],
{ abortSignal: signal, maxSteps: 6 },
);
const rewritten = generated.text.trim();
if (!rewritten) {
throw new RectificationToolServiceError("agentic_rectification_regeneration_empty");
}
const assistantMessage = collectPrompt
? composeCollectSpokenAssistantText(rewritten, collectPrompt)
: rewritten;
const { data, error } = await accounting.rpc(
"regenerate_agentic_rectification_turn",
@@ -172,7 +164,7 @@ export async function regenerateV9AssistantTurn(
p_session_id: sessionId,
p_turn_id: turnId,
p_request_id: requestId,
p_assistant_message: assistantMessage,
p_assistant_message: rewritten,
},
);
if (error) throw new RectificationToolServiceError(error.message);
@@ -200,6 +200,30 @@ function isFocusIdempotencyConflict(error: unknown): boolean {
return code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict");
}
export async function linkFocusAskedTurn(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
focus: ConversationFocus;
askedTurnId?: string | null;
}): Promise<ConversationFocus> {
if (!input.askedTurnId || input.focus.askedTurnId) return input.focus;
try {
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
questionId: input.focus.questionId,
intent: input.focus.intent,
targetEvidenceId: input.focus.targetEvidenceId,
targetDomain: input.focus.targetDomain,
targetKind: input.focus.targetKind,
expectedAnswerSchema: input.focus.expectedAnswerSchema,
askedTurnId: input.askedTurnId,
});
return result.focus;
} catch {
return input.focus;
}
}
function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
const prompt = spokenFollowupForUser({ ...followup, choice_frame: null });
if (!prompt) return null;
@@ -221,6 +245,7 @@ async function persistCollectFocus(input: {
caseId: string;
activeFocus: ConversationFocus | null;
followup: MethodFollowup;
askedTurnId?: string | null;
}): Promise<PersistServerFocusResult> {
const schema = collectFocusSchema(input.followup);
if (!schema) {
@@ -240,7 +265,14 @@ async function persistCollectFocus(input: {
&& active.questionId !== "active_focus:active_focus"
&& isCollectFocusSchema(active.expectedAnswerSchema)
) {
return { status: "already_open", focus: active, questionId: active.questionId, prompt };
const focus = await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus: active,
askedTurnId: input.askedTurnId,
});
return { status: "already_open", focus, questionId: focus.questionId, prompt };
}
const insertFocus = async (id: string): Promise<PersistServerFocusResult> => {
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
@@ -250,6 +282,7 @@ async function persistCollectFocus(input: {
targetDomain: persistableFocusDomain(input.followup.domain),
targetKind: null,
expectedAnswerSchema: schema,
askedTurnId: input.askedTurnId ?? null,
});
return {
status: result.idempotent ? "already_open" : "created",
@@ -305,6 +338,7 @@ async function persistSpokenChoiceFallback(input: {
caseId: string;
activeFocus: ConversationFocus | null;
followup: MethodFollowup;
askedTurnId?: string | null;
}): Promise<PersistServerFocusResult> {
return persistCollectFocus({
accounting: input.accounting,
@@ -312,6 +346,7 @@ async function persistSpokenChoiceFallback(input: {
caseId: input.caseId,
activeFocus: input.activeFocus,
followup: spokenCollectFallbackFollowup(input.followup),
askedTurnId: input.askedTurnId,
});
}
@@ -322,6 +357,7 @@ export async function persistServerOwnedFocus(input: {
activeFocus: ConversationFocus | null;
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: MethodFollowup | null;
askedTurnId?: string | null;
}): Promise<PersistServerFocusResult> {
const followup = input.followup;
const frame = followup?.choice_frame ?? null;
@@ -341,6 +377,7 @@ export async function persistServerOwnedFocus(input: {
caseId: input.caseId,
activeFocus: input.activeFocus,
followup,
askedTurnId: input.askedTurnId,
});
}
if (followup.intent === "collect_method_evidence") {
@@ -350,6 +387,7 @@ export async function persistServerOwnedFocus(input: {
caseId: input.caseId,
activeFocus: input.activeFocus,
followup,
askedTurnId: input.askedTurnId,
});
}
return {
@@ -382,6 +420,7 @@ export async function persistServerOwnedFocus(input: {
caseId: input.caseId,
activeFocus: input.activeFocus,
followup,
askedTurnId: input.askedTurnId,
});
}
return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null };
@@ -399,7 +438,14 @@ export async function persistServerOwnedFocus(input: {
|| (schemaProbeId(schema) && schemaProbeId(active.expectedAnswerSchema) === schemaProbeId(schema))
)
) {
return { status: "already_open", focus: active, questionId: active.questionId, prompt };
const focus = await linkFocusAskedTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
focus: active,
askedTurnId: input.askedTurnId,
});
return { status: "already_open", focus, questionId: focus.questionId, prompt };
}
try {
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
@@ -409,6 +455,7 @@ export async function persistServerOwnedFocus(input: {
targetDomain: followup.domain,
targetKind: null,
expectedAnswerSchema: schema,
askedTurnId: input.askedTurnId ?? null,
});
return {
status: result.idempotent ? "already_open" : "created",
@@ -0,0 +1,84 @@
import { persistableFocusDomain } 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;
export const SPOKEN_PROMPT_MAX = 120;
const OPTION_LITERAL = /(?:^|[\s,。;:])(?:A\.|B\.|C\.|D\.|选项)/;
const UNIQUE_MINUTE = /唯一.{0,8}出生分钟|确切.{0,8}出生分钟/;
const INTERNAL_TOKEN = /probe[_-]?id|focus[_-]?id|candidate[_-]?id|question_id|asked_turn/i;
export type SpokenPromptError = "invalid_spoken_prompt";
export type SpokenPromptValidation =
| { ok: true; prompt: string }
| { ok: false; error: SpokenPromptError; reason: string };
export function normalizeSpokenPrompt(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export function validateSpokenPrompt(input: {
spokenPrompt: string;
followup?: MethodFollowup | null;
targetDomain?: string | null;
questionId?: string | null;
}): SpokenPromptValidation {
const prompt = normalizeSpokenPrompt(input.spokenPrompt);
if (prompt.length < SPOKEN_PROMPT_MIN || prompt.length > SPOKEN_PROMPT_MAX) {
return { ok: false, error: "invalid_spoken_prompt", reason: "length" };
}
if (prompt.includes("\n")) {
return { ok: false, error: "invalid_spoken_prompt", reason: "multiline" };
}
const questionMarks = (prompt.match(/[?]/g) ?? []).length;
if (questionMarks > 1) {
return { ok: false, error: "invalid_spoken_prompt", reason: "too_many_questions" };
}
if (OPTION_LITERAL.test(prompt) || prompt.includes("选项")) {
return { ok: false, error: "invalid_spoken_prompt", reason: "option_literal" };
}
if (UNIQUE_MINUTE.test(prompt)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "unique_minute" };
}
if (INTERNAL_TOKEN.test(prompt)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "internal_token" };
}
for (const phrase of MACHINE_VOICE_LEXICON) {
if (prompt.includes(phrase)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "machine_voice" };
}
}
const followup = input.followup ?? null;
if (followup) {
const followupDomain = persistableFocusDomain(followup.domain);
const targetDomain = persistableFocusDomain(input.targetDomain);
if (followupDomain && targetDomain && followupDomain !== targetDomain) {
return { ok: false, error: "invalid_spoken_prompt", reason: "domain_mismatch" };
}
if (followup.choice_frame && input.questionId && input.questionId !== stableFollowupQuestionId(followup)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "probe_mismatch" };
}
if (followup.probe_year) {
const years = prompt.match(/19\d{2}|20\d{2}/g) ?? [];
if (years.some((year) => Number(year) !== followup.probe_year)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" };
}
}
}
return { ok: true, prompt };
}
export function withSpokenPrompt(
schema: Readonly<Record<string, unknown>>,
spokenPrompt: string,
): Record<string, unknown> {
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 };
}
return next;
}
@@ -93,6 +93,8 @@ export type ConversationFocus = Readonly<{
status: ConversationFocusStatus;
askedAt: string;
resolvedAt: string | null;
askedTurnId?: string | null;
answerOption?: "A" | "B" | "C" | "D" | "stop" | null;
}>;
export type CaseConversationSummary = Readonly<{
@@ -316,6 +318,12 @@ function rowObject(value: unknown): Readonly<Record<string, unknown>> | null {
: null;
}
function parseAnswerOption(value: unknown): ConversationFocus["answerOption"] {
return value === "A" || value === "B" || value === "C" || value === "D" || value === "stop"
? value
: null;
}
function parseConversationFocus(value: unknown): ConversationFocus | null {
const row = rowObject(value);
if (!row || typeof row.id !== "string" || typeof row.case_id !== "string"
@@ -335,6 +343,8 @@ function parseConversationFocus(value: unknown): ConversationFocus | null {
status,
askedAt: String(row.asked_at ?? ""),
resolvedAt: rowText(row.resolved_at),
askedTurnId: rowText(row.asked_turn_id),
answerOption: parseAnswerOption(row.answer_option),
};
}
@@ -1023,6 +1033,7 @@ export async function setV10ConversationFocus(
targetDomain?: string | null;
targetKind?: string | null;
expectedAnswerSchema?: Readonly<Record<string, unknown>>;
askedTurnId?: string | null;
},
): Promise<Readonly<{ focus: ConversationFocus; idempotent: boolean }>> {
const row = await rpc<Record<string, unknown>>(
@@ -1037,6 +1048,7 @@ export async function setV10ConversationFocus(
p_target_domain: input.targetDomain ?? null,
p_target_kind: input.targetKind ?? null,
p_expected_answer_schema: input.expectedAnswerSchema ?? {},
p_asked_turn_id: input.askedTurnId ?? null,
},
);
const focus = parseConversationFocus(row.focus ?? row);
@@ -1052,6 +1064,7 @@ export async function resolveV10ConversationFocus(
focusId: string;
status: "resolved" | "declined" | "skipped";
evidenceId?: string | null;
answerOption?: "A" | "B" | "C" | "D" | "stop" | null;
},
): Promise<Readonly<{ focusId: string; status: string; evidenceId: string | null; idempotent: boolean }>> {
const row = await rpc<Record<string, unknown>>(
@@ -1063,6 +1076,7 @@ export async function resolveV10ConversationFocus(
p_focus_id: input.focusId,
p_status: input.status,
p_evidence_id: input.evidenceId ?? null,
p_answer_option: input.answerOption ?? null,
},
);
const focusId = rowText(row.focus_id);
@@ -1075,6 +1089,31 @@ export async function resolveV10ConversationFocus(
};
}
export async function listV10ConversationFocuses(
accounting: AccountingClient,
userId: string,
caseId: string,
): Promise<readonly ConversationFocus[]> {
try {
const { data, error } = await accounting.rpc(
"list_agentic_rectification_conversation_focuses",
{ p_user_id: userId, p_case_id: caseId },
);
if (error) return [];
const items = Array.isArray(data)
? data
: Array.isArray((data as { value?: unknown } | null)?.value)
? (data as { value: unknown[] }).value
: [];
return items.flatMap((item) => {
const focus = parseConversationFocus(item);
return focus ? [focus] : [];
});
} catch {
return [];
}
}
export async function confirmV10Evidence(
accounting: AccountingClient,
userId: string,
@@ -78,6 +78,7 @@ export function projectCurrentQuestion(
): CurrentQuestionProjection | null {
if (!focus) return null;
const schema = focus.expectedAnswerSchema;
const spoken = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
const copy = parseAgentChoiceCopy(schema);
const probeId = typeof schema?.probe_id === "string" ? schema.probe_id : null;
if (copy) {
@@ -85,13 +86,13 @@ export function projectCurrentQuestion(
question_id: focus.questionId ?? null,
focus_id: focus.id ?? null,
probe_id: probeId,
prompt: copy.prompt,
prompt: spoken.length >= 8 ? spoken : copy.prompt,
kind: "choice",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
const collectPrompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
const collectPrompt = spoken || "";
if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) {
return {
question_id: focus.questionId ?? null,
@@ -2,12 +2,7 @@ import {
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "./answer-choice.ts";
import { projectCurrentQuestion } from "./turn-decision.ts";
import {
loadV9CaseDossier,
persistV9DeterministicTurn,
type RectificationRpcClient,
} from "./tool-service.ts";
import type { RectificationRpcClient } from "./tool-service.ts";
export type RectificationRouteAction =
| "opening"
@@ -30,39 +25,25 @@ export function dossierHasNonemptyAssistantBody(
return (turns ?? []).some((turn) => turn.role === "assistant" && Boolean(turn.text?.trim()));
}
/**
* Whether to append the server-owned focus prompt as its own assistant turn.
*
* Structured only: focus kind/intent, and whether this turn already has a
* nonempty assistant body. Never inspect the body text to decide
* whether it already asked. Collect prompts are joined onto the same
* assistant turn before finalize, so a second turn is only written when
* this turn has no body.
*/
export function shouldPersistFocusPromptTurn(input: {
action: RectificationRouteAction;
questionKind: string | null | undefined;
questionIntent: string | null | undefined;
assistantBodyPresent: boolean;
}): boolean {
const collect = input.questionKind === "collect_spoken"
|| input.questionIntent === "collect_method_evidence";
return !(collect && input.assistantBodyPresent);
}
export async function finalizeSuccessfulTurnExit(input: {
accounting: RectificationRpcClient;
userId: string;
caseId: string;
action: RectificationRouteAction;
assistantBodyPresent?: boolean;
askedTurnId?: string | null;
}): Promise<void> {
if (input.action === "read_only") {
// Read-only requests must never mutate the interview or create a focus.
return;
}
try {
await persistNextInterviewIfIdle(input);
await persistNextInterviewIfIdle({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
askedTurnId: input.askedTurnId ?? null,
});
} catch (error) {
console.warn(
`[rectification-v9] persist next interview after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
@@ -76,32 +57,6 @@ export async function finalizeSuccessfulTurnExit(input: {
);
throw error;
}
try {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const question = projectCurrentQuestion(dossier.conversationSummary.activeFocus);
const assistantBodyPresent = input.assistantBodyPresent
?? dossierHasNonemptyAssistantBody(dossier.turns);
if (
question?.focus_id
&& question.prompt
&& shouldPersistFocusPromptTurn({
action: input.action,
questionKind: question.kind,
questionIntent: question.intent,
assistantBodyPresent,
})
) {
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: question.focus_id,
userMessage: null,
assistantMessage: question.prompt,
});
}
} catch (error) {
console.warn(
`[rectification-v9] persist server question turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
export async function awaitTurnExitBeforeResponse<T>(
@@ -0,0 +1,138 @@
import { detachCollectSpokenAssistantText } from "./collect-prompt";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
export type TurnQuestionKind = "choice" | "collect_spoken" | "reverse_verify";
export type TurnQuestionOption = Readonly<{
key: ChoiceKey;
label: string;
}>;
export type TurnQuestion = Readonly<{
focus_id: string;
question_id: string;
kind: TurnQuestionKind;
prompt: string;
options: readonly TurnQuestionOption[] | null;
status: string;
answer_option: ChoiceKey | "stop" | null;
probe_id: string | null;
}>;
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop"]);
export function focusSpokenPrompt(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
if (!schema) return null;
const top = typeof schema.prompt === "string" ? schema.prompt.trim() : "";
if (top.length >= 8 && top.length <= 120) return top;
const copy = parseAgentChoiceCopy(schema);
const fromChoice = copy?.prompt?.trim() ?? "";
return fromChoice || null;
}
export function turnQuestionKind(focus: {
intent?: string | null;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
}): TurnQuestionKind {
const intent = focus.intent ?? "";
if (intent === "reverse_verify" || intent === "out_of_sample_check") return "reverse_verify";
if (parseAgentChoiceCopy(focus.expectedAnswerSchema)) return "choice";
return "collect_spoken";
}
export function turnQuestionFromFocus(focus: ConversationFocus): TurnQuestion | null {
const prompt = focusSpokenPrompt(focus.expectedAnswerSchema);
if (!prompt || !focus.id || !focus.questionId) return null;
const copy = parseAgentChoiceCopy(focus.expectedAnswerSchema);
const probeId = typeof focus.expectedAnswerSchema.probe_id === "string"
? focus.expectedAnswerSchema.probe_id
: null;
const answer = focus.answerOption;
return {
focus_id: focus.id,
question_id: focus.questionId,
kind: turnQuestionKind(focus),
prompt,
options: copy
? copy.options.map((option) => ({ key: option.key, label: option.label }))
: null,
status: focus.status,
answer_option: answer && ANSWER_OPTIONS.has(answer) ? answer : null,
probe_id: probeId,
};
}
export function parseTurnQuestion(value: unknown): TurnQuestion | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (typeof row.focus_id !== "string" || typeof row.question_id !== "string") return null;
if (row.kind !== "choice" && row.kind !== "collect_spoken" && row.kind !== "reverse_verify") {
return null;
}
const prompt = typeof row.prompt === "string" ? row.prompt.trim() : "";
if (!prompt) return null;
const answer = row.answer_option;
const options = Array.isArray(row.options)
? row.options.flatMap((item): TurnQuestionOption[] => {
if (!item || typeof item !== "object") return [];
const option = item as Record<string, unknown>;
if (option.key !== "A" && option.key !== "B" && option.key !== "C" && option.key !== "D") {
return [];
}
if (typeof option.label !== "string" || !option.label.trim()) return [];
return [{ key: option.key, label: option.label.trim() }];
})
: [];
return {
focus_id: row.focus_id,
question_id: row.question_id,
kind: row.kind,
prompt,
options: options.length === 4 ? options : (row.kind === "choice" || row.kind === "reverse_verify" ? options : null),
status: typeof row.status === "string" ? row.status : "active",
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D" || answer === "stop"
? answer
: null,
probe_id: typeof row.probe_id === "string" ? row.probe_id : null,
};
}
export function attachQuestionsToTurns<T extends { id: string; role: string; text: string | null }>(
turns: readonly T[],
focuses: readonly ConversationFocus[],
): Array<T & { question: TurnQuestion | null; text: string | null }> {
const byTurn = new Map<string, ConversationFocus>();
for (const focus of focuses) {
if (!focus.askedTurnId) continue;
byTurn.set(focus.askedTurnId, focus);
}
return turns.map((turn) => {
if (turn.role !== "assistant") return { ...turn, question: null };
const focus = byTurn.get(turn.id);
if (!focus) return { ...turn, question: null };
const question = turnQuestionFromFocus(focus);
const text = question && turn.text
? detachCollectSpokenAssistantText(turn.text, question.prompt)
: turn.text;
return { ...turn, text, question };
});
}
export function questionIsAnswered(question: TurnQuestion): boolean {
return question.status !== "active";
}
export function copyTextForMessage(body: string, question: TurnQuestion | null | undefined): string {
const spoken = body.trim();
if (!question?.prompt) return spoken;
const lines = [spoken, "", question.prompt];
if (question.options?.length) {
lines.push("");
for (const option of question.options) {
const mark = question.answer_option === option.key ? " ✓" : "";
lines.push(`${option.key}. ${option.label}${mark}`);
}
}
return lines.join("\n").trim();
}
+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. current_question UI collect_spoken 2-4 choice collect_spoken current_question
4. rectification-set-focus spokenPrompt /2-4 set-focus spokenPrompt next_followup=null 2-4 choice collect_spoken
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill
2016 9
+42 -5
View File
@@ -82,6 +82,7 @@ import {
openQuestionFromPersistedFocus,
persistServerOwnedFocus,
} from "@/lib/rectification-agentic/v9/server-focus";
import { validateSpokenPrompt, withSpokenPrompt } from "@/lib/rectification-agentic/v9/spoken-prompt";
import {
publicEvidenceItemStatus,
resolveEvidenceQuote,
@@ -752,6 +753,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const { accounting, userId, caseId, turnId, attemptId, userMessage } = ctx;
const engineVersion = v9EngineVersion();
let hasReadCase = false;
let spokenPromptFailures = 0;
const receipt = async (
toolName: string,
@@ -1014,6 +1016,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
activeFocus: parsed.conversationSummary.activeFocus,
decisionReceipt: latest.decisionReceipt,
followup,
askedTurnId: turnId,
});
if (
followup?.intent === "distinguish_candidates"
@@ -1028,6 +1031,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
activeFocus: parsed.conversationSummary.activeFocus,
decisionReceipt: latest.decisionReceipt,
followup,
askedTurnId: turnId,
});
} else if (
followup?.intent === "distinguish_candidates"
@@ -1173,11 +1177,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const setFocusTool = createTool({
id: "rectification-set-focus",
description:
"在提出至多一个主问题时持久化服务器 ConversationFocus。questionId 必须稳定标识该问题;targetEvidenceId/domain/kind 只填写实际目标。新的 focus 会由服务器 supersede 旧 active focus。仅当 method_followup_plan.next_followup 带 choice_frame 时才在 expectedAnswerSchema.choice 写入 prompt、option_a、option_b、option_c、option_d。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。",
"在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 idtargetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。新的 focus 会由服务器 supersede 旧 active focus。仅当 method_followup_plan.next_followup 带 choice_frame 时才在 expectedAnswerSchema.choice 写入 option_a、option_b、option_c、option_d。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。",
inputSchema: z.object({
caseId: z.string().uuid(),
questionId: z.string().trim().min(1).max(160),
intent: z.string().trim().min(1).max(160),
spokenPrompt: z.string().trim().min(8).max(120),
targetEvidenceId: z.string().uuid().nullable().optional(),
targetDomain: evidenceDomainSchema.nullable().optional(),
targetKind: evidenceKindSchema.nullable().optional(),
@@ -1224,6 +1229,37 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
// Probe identity is only a hint for the C/D write path.
}
}
let nextFollowup: ReturnType<typeof sessionAwareFollowupForParsed>["plan"]["next_followup"] = null;
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const parsed = parseDossierForTools(dossier);
if (parsed.latestResult) {
nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup;
}
} catch {
nextFollowup = null;
}
const spoken = validateSpokenPrompt({
spokenPrompt: input.spokenPrompt,
followup: nextFollowup,
targetDomain: input.targetDomain ?? nextFollowup?.domain ?? null,
questionId: input.questionId,
});
if (!spoken.ok) {
spokenPromptFailures += 1;
const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input);
await receipt("rectification-set-focus", "intent.classified", "failed", {
inputFingerprint,
safeErrorCode: "invalid_spoken_prompt",
});
return {
ok: false,
error: "invalid_spoken_prompt",
reason: spoken.reason,
retryable: spokenPromptFailures < 2,
};
}
Object.assign(expectedAnswerSchema, withSpokenPrompt(expectedAnswerSchema, spoken.prompt));
const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input);
await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint });
try {
@@ -1234,6 +1270,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
targetDomain: input.targetDomain ?? null,
targetKind: input.targetKind ?? null,
expectedAnswerSchema,
askedTurnId: turnId,
});
const projection = {
focus_id: result.focus.id,
@@ -2104,6 +2141,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
export function agentToolKeysForDecision(decision: RectificationDecision): ReadonlySet<string> {
const keys = new Set<string>([
"rectification-read-case",
"rectification-set-focus",
"rectification-resolve-focus",
"rectification-record-evidence-batch",
"rectification-propose-evidence",
@@ -2127,12 +2165,11 @@ export function createRectificationV9AgentTools(
decision: RectificationDecision | undefined = ctx.decision,
) {
const tools = createRectificationV9Tools(ctx);
const { "rectification-set-focus": _omitted, ...agentTools } = tools;
if (!decision) return agentTools;
if (!decision) return tools;
const allowed = agentToolKeysForDecision(decision);
return Object.fromEntries(
Object.entries(agentTools).filter(([name]) => allowed.has(name)),
) as typeof agentTools;
Object.entries(tools).filter(([name]) => allowed.has(name)),
) as typeof tools;
}
export type RectificationV9Tools = ReturnType<typeof createRectificationV9Tools>;
@@ -0,0 +1,438 @@
-- Question-in-message: persist the asked turn and selected option on each
-- conversation focus so GET can rebuild the same assistant bubble after refresh.
-- New columns are nullable so existing cases remain readable as plain text.
begin;
alter table public.agentic_rectification_conversation_focuses
add column if not exists asked_turn_id uuid
references public.agentic_rectification_turns(id) on delete set null,
add column if not exists answer_option text
check (answer_option is null or answer_option in ('A', 'B', 'C', 'D', 'stop'));
create index if not exists agentic_rectification_focus_asked_turn_idx
on public.agentic_rectification_conversation_focuses (asked_turn_id)
where asked_turn_id is not null;
drop function if exists public.set_agentic_rectification_conversation_focus(
uuid, uuid, text, text, uuid, text, text, jsonb
);
create function public.set_agentic_rectification_conversation_focus(
p_user_id uuid,
p_case_id uuid,
p_question_id text,
p_intent text,
p_target_evidence_id uuid,
p_target_domain text,
p_target_kind text,
p_expected_answer_schema jsonb,
p_asked_turn_id uuid default null
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_focus public.agentic_rectification_conversation_focuses%rowtype;
v_existing_identity jsonb;
v_incoming_identity jsonb;
begin
if p_user_id is null or p_case_id is null
or length(btrim(coalesce(p_question_id, ''))) = 0
or length(btrim(coalesce(p_intent, ''))) = 0
or p_expected_answer_schema is null
or jsonb_typeof(p_expected_answer_schema) <> 'object' then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
if p_asked_turn_id is not null and not exists (
select 1 from public.agentic_rectification_turns
where id = p_asked_turn_id and case_id = p_case_id
) then
raise exception 'agentic_rectification_turn_not_found' 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
for update;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
if p_target_evidence_id is not null and not exists (
select 1 from public.agentic_rectification_evidence
where id = p_target_evidence_id and case_id = p_case_id
) then
raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001';
end if;
select * into v_focus
from public.agentic_rectification_conversation_focuses
where case_id = p_case_id and question_id = p_question_id;
if found then
if v_focus.status = 'active'
and v_focus.intent = p_intent
and v_focus.target_evidence_id is not distinct from p_target_evidence_id
and v_focus.target_domain is not distinct from p_target_domain
and v_focus.target_kind is not distinct from p_target_kind then
v_existing_identity := (v_focus.expected_answer_schema - 'prompt');
v_incoming_identity := (p_expected_answer_schema - 'prompt');
if jsonb_typeof(v_existing_identity -> 'choice') = 'object' then
v_existing_identity := jsonb_set(
v_existing_identity, '{choice}', (v_existing_identity -> 'choice') - 'prompt'
);
end if;
if jsonb_typeof(v_incoming_identity -> 'choice') = 'object' then
v_incoming_identity := jsonb_set(
v_incoming_identity, '{choice}', (v_incoming_identity -> 'choice') - 'prompt'
);
end if;
if v_existing_identity = v_incoming_identity then
update public.agentic_rectification_conversation_focuses
set expected_answer_schema = p_expected_answer_schema,
asked_turn_id = coalesce(asked_turn_id, p_asked_turn_id),
updated_at = pg_catalog.now()
where id = v_focus.id
returning * into v_focus;
return jsonb_build_object(
'focus', to_jsonb(v_focus),
'idempotent', true
);
end if;
end if;
raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001';
end if;
update public.agentic_rectification_conversation_focuses
set status = 'superseded',
resolved_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where case_id = p_case_id and status = 'active';
insert into public.agentic_rectification_conversation_focuses (
case_id, question_id, intent, target_evidence_id, target_domain, target_kind,
expected_answer_schema, status, asked_at, asked_turn_id
) values (
p_case_id, p_question_id, p_intent, p_target_evidence_id, p_target_domain, p_target_kind,
p_expected_answer_schema, 'active', pg_catalog.now(), p_asked_turn_id
) returning * into v_focus;
return jsonb_build_object(
'focus', to_jsonb(v_focus),
'idempotent', false
);
end;
$$;
revoke all on function public.set_agentic_rectification_conversation_focus(
uuid, uuid, text, text, uuid, text, text, jsonb, uuid
) from public, anon, authenticated;
grant execute on function public.set_agentic_rectification_conversation_focus(
uuid, uuid, text, text, uuid, text, text, jsonb, uuid
) to service_role;
drop function if exists public.resolve_agentic_rectification_conversation_focus(
uuid, uuid, uuid, text, uuid
);
create function public.resolve_agentic_rectification_conversation_focus(
p_user_id uuid,
p_case_id uuid,
p_focus_id uuid,
p_status text,
p_evidence_id uuid,
p_answer_option text default null
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_focus public.agentic_rectification_conversation_focuses%rowtype;
begin
if p_user_id is null or p_case_id is null or p_focus_id is null
or p_status not in ('resolved', 'declined', 'skipped', 'superseded') then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
if p_answer_option is not null and p_answer_option not in ('A', 'B', 'C', 'D', 'stop') 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;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
select * into v_focus
from public.agentic_rectification_conversation_focuses
where id = p_focus_id and case_id = p_case_id
for update;
if not found then
raise exception 'agentic_rectification_focus_not_found' using errcode = 'P0001';
end if;
if p_evidence_id is not null and not exists (
select 1 from public.agentic_rectification_evidence
where id = p_evidence_id and case_id = p_case_id
) then
raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001';
end if;
if v_focus.target_evidence_id is not null
and p_evidence_id is distinct from v_focus.target_evidence_id then
raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001';
end if;
if v_focus.status <> 'active' then
if v_focus.status = p_status
and (v_focus.target_evidence_id is null or v_focus.target_evidence_id is not distinct from p_evidence_id)
and v_focus.answer_option is not distinct from p_answer_option then
return jsonb_build_object(
'focus_id', v_focus.id, 'status', v_focus.status,
'evidence_id', v_focus.target_evidence_id, 'idempotent', true,
'answer_option', to_jsonb(v_focus.answer_option)
);
end if;
raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001';
end if;
update public.agentic_rectification_conversation_focuses
set status = p_status,
target_evidence_id = coalesce(target_evidence_id, p_evidence_id),
answer_option = coalesce(p_answer_option, answer_option),
resolved_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where id = v_focus.id
returning * into v_focus;
return jsonb_build_object(
'focus_id', v_focus.id,
'status', p_status,
'evidence_id', coalesce(v_focus.target_evidence_id, p_evidence_id),
'idempotent', false,
'answer_option', to_jsonb(v_focus.answer_option)
);
end;
$$;
revoke all on function public.resolve_agentic_rectification_conversation_focus(
uuid, uuid, uuid, text, uuid, text
) from public, anon, authenticated;
grant execute on function public.resolve_agentic_rectification_conversation_focus(
uuid, uuid, uuid, text, uuid, text
) to service_role;
create or replace function public.list_agentic_rectification_conversation_focuses(
p_user_id uuid,
p_case_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_focuses jsonb;
begin
if p_user_id is null or p_case_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 coalesce(jsonb_agg(to_jsonb(f) order by f.asked_at, f.created_at, f.id), '[]'::jsonb)
into v_focuses
from public.agentic_rectification_conversation_focuses f
where f.case_id = p_case_id;
return v_focuses;
end;
$$;
revoke all on function public.list_agentic_rectification_conversation_focuses(uuid, uuid)
from public, anon, authenticated;
grant execute on function public.list_agentic_rectification_conversation_focuses(uuid, uuid)
to service_role;
create or replace function public.apply_agentic_rectification_choice_action(
p_user_id uuid,
p_case_id uuid,
p_action_id uuid,
p_question_id text,
p_option_id text,
p_expected_revision integer,
p_focus_id uuid,
p_focus_status text,
p_source_quote text,
p_derived_context jsonb,
p_narration text,
p_inference jsonb
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_focus public.agentic_rectification_conversation_focuses%rowtype;
v_existing public.agentic_rectification_choice_actions%rowtype;
v_transition jsonb;
v_focus_result jsonb;
v_receipt jsonb;
v_id uuid;
v_probe_id text;
v_question_id text;
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_option_id is null or p_option_id not in ('A', 'B', 'C', 'D', 'stop')
or p_expected_revision is null or p_expected_revision < 0
or p_focus_id is null
or p_focus_status is null or p_focus_status not in ('resolved', 'declined', 'skipped')
or p_derived_context is null or jsonb_typeof(p_derived_context) <> 'object'
or (p_inference is not null and jsonb_typeof(p_inference) <> 'object') 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
for update;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
select * into v_existing
from public.agentic_rectification_choice_actions
where case_id = p_case_id
and action_id = p_action_id;
if found then
return jsonb_set(v_existing.receipt, '{idempotent}', 'true'::jsonb, true);
end if;
select * into v_focus
from public.agentic_rectification_conversation_focuses
where id = p_focus_id and case_id = p_case_id
for update;
if not found then
raise exception 'agentic_rectification_focus_not_found' using errcode = 'P0001';
end if;
if v_focus.status is distinct from 'active' then
raise exception 'agentic_rectification_stale_question' using errcode = 'P0001';
end if;
v_question_id := coalesce(nullif(btrim(coalesce(p_question_id, '')), ''), v_focus.question_id);
insert into public.agentic_rectification_choice_actions (
case_id, action_id, question_id, probe_id, option_id, expected_revision,
status, answer_class, source_quote, derived_context, focus_id, narration, receipt
) values (
p_case_id, p_action_id, v_question_id,
nullif(btrim(coalesce(p_inference ->> 'probe_id', p_derived_context ->> 'derivedFromProbeId', '')), ''),
p_option_id, p_expected_revision, 'received',
nullif(btrim(coalesce(p_inference ->> 'answer_class', p_derived_context ->> 'answerClass', '')), ''),
nullif(btrim(coalesce(p_source_quote, '')), ''),
p_derived_context, p_focus_id, nullif(btrim(coalesce(p_narration, '')), ''),
jsonb_build_object('status', 'received')
)
returning * into v_existing;
v_transition := null;
if p_inference is not null then
v_transition := public.append_agentic_rectification_inference_transition(
p_user_id,
p_case_id,
coalesce((p_inference ->> 'expected_revision')::integer, p_expected_revision),
p_inference ->> 'probe_id',
p_inference ->> 'open_probe_id',
p_inference ->> 'semantic_key',
p_inference ->> 'candidate_split_hash',
p_inference ->> 'answer_class',
p_inference ->> 'raw_answer',
p_inference -> 'inference_state',
coalesce(p_inference -> 'posterior_before', '{}'::jsonb),
coalesce(p_inference -> 'posterior_after', '{}'::jsonb),
coalesce(p_inference -> 'score_deltas', '{}'::jsonb),
p_inference ->> 'decision_state_fingerprint',
p_inference ->> 'reason',
p_inference ->> 'idempotency_key',
p_inference ->> 'candidate_set_id'
);
end if;
v_focus_result := public.resolve_agentic_rectification_conversation_focus(
p_user_id, p_case_id, p_focus_id, p_focus_status, null, p_option_id
);
v_probe_id := coalesce(
v_transition ->> 'probe_id',
p_inference ->> 'probe_id',
p_derived_context ->> 'derivedFromProbeId'
);
v_receipt := jsonb_build_object(
'action_id', p_action_id,
'status', 'applied',
'idempotent', false,
'question_id', v_question_id,
'option_id', p_option_id,
'probe_id', to_jsonb(v_probe_id),
'revision', coalesce((v_transition ->> 'revision')::integer, p_expected_revision),
'focus_id', v_focus_result -> 'focus_id',
'focus_status', v_focus_result -> 'status',
'transition_id', v_transition -> 'transition_id',
'source_quote', to_jsonb(nullif(btrim(coalesce(p_source_quote, '')), '')),
'derived_context', p_derived_context,
'narration', to_jsonb(nullif(btrim(coalesce(p_narration, '')), ''))
);
update public.agentic_rectification_choice_actions
set status = 'applied',
probe_id = v_probe_id,
transition_id = nullif(v_transition ->> 'transition_id', '')::uuid,
receipt = v_receipt,
updated_at = pg_catalog.now()
where id = v_existing.id
returning id into v_id;
update public.agentic_rectification_cases
set last_activity_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where id = p_case_id;
return v_receipt;
exception
when unique_violation then
select * into v_existing
from public.agentic_rectification_choice_actions
where case_id = p_case_id
and action_id = p_action_id;
if found then
return jsonb_set(v_existing.receipt, '{idempotent}', 'true'::jsonb, true);
end if;
raise;
end;
$$;
revoke all on function public.apply_agentic_rectification_choice_action(
uuid, uuid, uuid, text, text, integer, uuid, text, text, jsonb, text, jsonb
) from public, anon, authenticated;
grant execute on function public.apply_agentic_rectification_choice_action(
uuid, uuid, uuid, text, text, integer, uuid, text, text, jsonb, text, jsonb
) to service_role;
commit;
@@ -12,20 +12,7 @@ import {
listUserVisibleCopy,
nonConvergingRangeNarration,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { shouldPersistFocusPromptTurn } from "../src/lib/rectification-agentic/v9/turn-exit.ts";
const MACHINE_VOICE_LEXICON = [
"按同一规则计入",
"请刷新后重新作答",
"当前状态为",
"请点选下面的选项作答",
"当前区分题",
"接下来请点选下面这一问",
"可以从下面的时间里选一个采用",
"我没能确定这句话是否在回答当前问题",
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
] as const;
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
/** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */
export const ACCIDENT_CASE_BEFORE_COPY = {
@@ -92,67 +79,40 @@ test("default range narration stays semantic even without an opening window", ()
assert.equal(containsBoundarySemantics(text), true);
});
test("question-slot ownership stays in the rectification agent prompt", () => {
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
assert.match(agent, /正文不得提问、复述、改写或拼接题干/);
assert.match(agent, /开场轮同样适用/);
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /题干会作为同一条消息的下一段自动出现/);
assert.match(agent, /开场轮:先 set-focus 写采集题的 spokenPrompt/);
assert.match(agent, /不要举大学、工作、搬家的例子/);
assert.match(agent, /collect_spoken 题干由服务器接在同一条正文末尾并写入聊天历史/);
assert.match(agent, /每轮正文 2-4 句/);
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
assert.doesNotMatch(agent, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(agent, /collect_spoken 题干由服务器接在同一条正文末尾/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.doesNotMatch(agent, /每轮正文只输出一句确认或承接/);
assert.doesNotMatch(route, /接下来请点选下面这一问/);
assert.doesNotMatch(exit, /接下来请点选下面这一问/);
assert.match(exit, /shouldPersistFocusPromptTurn/);
assert.doesNotMatch(exit, /shouldPersistFocusPromptTurn/);
assert.doesNotMatch(exit, /action === ["']opening["']/);
assert.match(route, /assistantBodyPresent: Boolean\(result\.answerText\?\.trim\(\)\)/);
assert.match(route, /askedTurnId: result\.turnId/);
assert.doesNotMatch(exit, /assistantMessage\.(?:includes|match|search)/);
assert.doesNotMatch(exit, /question\.prompt\.(?:includes|match|search)/);
const skipFn = exit.slice(
exit.indexOf("export function shouldPersistFocusPromptTurn"),
exit.indexOf("export async function finalizeSuccessfulTurnExit"),
);
assert.doesNotMatch(skipFn, /\.test\(/);
assert.doesNotMatch(skipFn, /\.match\(/);
assert.doesNotMatch(skipFn, /\.search\(/);
assert.doesNotMatch(skipFn, /includes\(/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.match(chat, /afterAnswer/);
assert.match(chat, /rectification-message-question/);
});
test("collect prompt skip is structural, not a body-text regex", () => {
assert.equal(shouldPersistFocusPromptTurn({
action: "opening",
questionKind: "collect_spoken",
questionIntent: "collect_method_evidence",
assistantBodyPresent: true,
}), false);
assert.equal(shouldPersistFocusPromptTurn({
action: "opening",
questionKind: "collect_spoken",
questionIntent: "collect_method_evidence",
assistantBodyPresent: false,
}), true);
assert.equal(shouldPersistFocusPromptTurn({
action: "opening",
questionKind: "choice",
questionIntent: "distinguish_candidates",
assistantBodyPresent: true,
}), true);
assert.equal(shouldPersistFocusPromptTurn({
action: "message",
questionKind: "collect_spoken",
questionIntent: "collect_method_evidence",
assistantBodyPresent: true,
}), false);
assert.equal(shouldPersistFocusPromptTurn({
action: "answer_choice",
questionKind: "choice",
questionIntent: "distinguish_candidates",
assistantBodyPresent: true,
}), true);
test("collect prompt skip no longer writes a second assistant turn", () => {
const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
assert.match(exit, /persistNextInterviewIfIdle/);
assert.match(exit, /askedTurnId: input\.askedTurnId/);
assert.doesNotMatch(exit, /persistV9DeterministicTurn/);
assert.doesNotMatch(exit, /shouldPersistFocusPromptTurn/);
});
test("E-2 display copy keeps engine meaning out of instruction tone", () => {
@@ -365,7 +365,7 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
}
assert.match(chat, /<ChatMessageActions/);
assert.match(chat, /toggleRectificationFeedback/);
assert.match(chat, /navigator\.clipboard\.writeText\(message\.text\)/);
assert.match(chat, /navigator\.clipboard\.writeText\(copyTextForMessage\(message\.text, message\.question\)\)/);
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
assert.match(chat, /requestId: globalThis\.crypto\.randomUUID\(\)/);
@@ -535,7 +535,7 @@ test("time-selection cards use server adoption state and stay mutually exclusive
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
assert.match(chat, /candidateResult\?\.selectionAllowed/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*currentQuestion\?\.kind === "choice"[\s\S]*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
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.doesNotMatch(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")),
@@ -547,9 +547,10 @@ test("time-selection cards use server adoption state and stay mutually exclusive
);
assert.match(messageLoop, /showSelectionCards && message\.renderKey === selectionCardMessageKey/);
assert.match(messageLoop, /<RectificationChoiceCard/);
assert.match(messageLoop, /choiceAttachment/);
assert.match(messageLoop, /variant="embedded"/);
assert.match(messageLoop, /choiceNonce/);
assert.match(messageLoop, /answered:\$\{settledChoice\.selectedKey\}/);
assert.match(messageLoop, /question\.answer_option/);
assert.doesNotMatch(messageLoop, /rectification-question-slot/);
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
assert.match(choiceCardComponent, /selectedKey/);
assert.match(choiceCardComponent, /is-answered/);
@@ -635,8 +636,8 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(strategy, /没有更多事件/);
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
assert.match(strategy, /不要一进场就出 A\/B\/C\/D/);
assert.match(agent, /有持久化 current_question 时,题干与选项完全由结构化槽位和 UI 承担/);
assert.match(agent, /正文不提问、复述、改写或拼接题干/);
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /正文只做承接(2-4 句),不提问、复述题干、不预告选项/);
});
test("clear current-turn events go through the batch evidence service", () => {
@@ -664,9 +665,10 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
assert.doesNotMatch(tools, /offer_selection/);
});
test("choice cards expose the persisted prompt as a visible legend", () => {
test("embedded choice cards hide the prompt legend; standalone cards keep it visible", () => {
assert.match(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
assert.doesNotMatch(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.match(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.match(choiceCardComponent, /variant === "embedded"/);
});
test("choice card answers and stop share one stacked primary list", () => {
@@ -682,7 +684,7 @@ test("adopted time offers a consultation handoff without unique-minute copy", ()
assert.match(board, /换升时刻/);
assert.match(board, /经历与大运对照/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /choiceAttachment/);
assert.match(chat, /markQuestionAnswered/);
assert.match(page, /startConsultationAfterRectification/);
assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/);
assert.doesNotMatch(agent, /start_consultation/);
@@ -714,9 +714,19 @@ test("answering a discriminator persists the next dated card so GET still has a
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
const persistedPrompt = schema.choice?.prompt;
assert.ok(persistedPrompt);
assert.equal(applied.narration, persistedPrompt);
assert.notEqual(applied.narration, persistedPrompt);
assert.match(applied.narration, /已记录你的选择/);
assert.doesNotMatch(applied.narration, /2014/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, persistedPrompt);
assert.equal(turn?.args.p_assistant_message, applied.narration);
assert.equal(
accounting.calls.some((call) => (
call.fn === "set_agentic_rectification_conversation_focus"
&& call.args.p_asked_turn_id === TURN_ID
&& call.args.p_question_id === setFocus.args.p_question_id
)),
true,
);
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
@@ -837,12 +847,20 @@ test("answering the last discriminator persists a year-locked family collect foc
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
assert.match(applied.narration, /2021/);
assert.match(applied.narration, /家人|结婚|添丁|住院/);
assert.match(applied.narration, /已记录你的选择/);
assert.doesNotMatch(applied.narration, /2021/);
assert.doesNotMatch(applied.narration, /点选/);
assert.doesNotMatch(applied.narration, /D24/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.match(String(turn?.args.p_assistant_message ?? ""), /2021/);
assert.match(String(turn?.args.p_assistant_message ?? ""), /已记录你的选择/);
assert.doesNotMatch(String(turn?.args.p_assistant_message ?? ""), /2021/);
assert.equal(
accounting.calls.some((call) => (
call.fn === "set_agentic_rectification_conversation_focus"
&& call.args.p_asked_turn_id === TURN_ID
)),
true,
);
});
test("replaying the same actionId does not duplicate the applied receipt", async () => {
@@ -694,7 +694,8 @@ test("opening follow-up asks for a dated event in natural language, not a choice
assert.equal(plan.next_followup?.choice_frame, null);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /自然语言/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /不要调用 set-focus/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /spokenPrompt/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /不要调用 set-focus/);
});
test("distinguish follow-up copy forbids competing-chart ranking", () => {
@@ -743,7 +744,7 @@ test("distinguish follow-up copy forbids competing-chart ranking", () => {
assert.equal(plan.next_followup?.choice_frame?.period, "2016 年前后");
assert.doesNotMatch(plan.next_followup?.choice_frame?.option_a_hint ?? "", /更像哪一件|两套盘|可能性/);
assert.doesNotMatch(plan.next_followup?.choice_frame?.option_b_hint ?? "", /更像哪一件|两套盘|可能性/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /自己写一句追问/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /spokenPrompt 写出题干/);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /不得发明年份/);
});
@@ -1,8 +1,11 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
const greeting = "你好,我是生时校正助手。";
@@ -26,3 +29,56 @@ test("detachCollectSpokenAssistantText removes only an exact trailing stem", ()
assert.equal(detachCollectSpokenAssistantText(GENERIC_COLLECT_QUESTION, GENERIC_COLLECT_QUESTION), GENERIC_COLLECT_QUESTION);
assert.equal(detachCollectSpokenAssistantText("", GENERIC_COLLECT_QUESTION), "");
});
test("GET rebuild detaches a legacy composed suffix only when asked_turn_id matches", () => {
const greeting = "你好,我是生时校正助手。";
const combined = composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION);
const turns = [{
id: TURN_ID,
role: "assistant" as const,
text: combined,
status: "completed",
}];
const linked = attachQuestionsToTurns(turns, [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:unknown:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: null,
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
}]);
assert.equal(linked[0]?.text, greeting);
assert.equal(linked[0]?.question?.prompt, GENERIC_COLLECT_QUESTION);
const unlinked = attachQuestionsToTurns(turns, [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:unknown:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: null,
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: null,
}]);
assert.equal(unlinked[0]?.text, combined);
assert.equal(unlinked[0]?.question, null);
});
test("runtime no longer composes the stem into assistant_message", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /detachCollectSpokenAssistantText/);
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
});
@@ -621,10 +621,11 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb
assert.equal(decision.canAdopt, true);
assert.ok(currentQuestion?.prompt);
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。");
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.equal(setFocus.args.p_intent, "collect_method_evidence");
const historyTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(historyTurn?.args.p_request_id, FOCUS_ID);
assert.equal(historyTurn?.args.p_user_message, null);
assert.equal(historyTurn?.args.p_assistant_message, currentQuestion.prompt);
assert.equal(historyTurn, undefined);
});
test("public decision fields are derived from decideRectification", () => {
@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import {
attachQuestionsToTurns,
copyTextForMessage,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const OTHER_TURN = "44444444-4444-4444-8444-444444444444";
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8");
const migration = readFileSync(
new URL("../supabase/migrations/20260902010000_rectification_question_on_turn.sql", import.meta.url),
"utf8",
);
test("question-on-turn migration adds asked_turn_id and answer_option", () => {
assert.match(migration, /add column if not exists asked_turn_id uuid/);
assert.match(migration, /add column if not exists answer_option text/);
assert.match(migration, /p_asked_turn_id uuid default null/);
assert.match(migration, /p_answer_option text default null/);
assert.match(migration, /list_agentic_rectification_conversation_focuses/);
assert.equal(
existsSync(new URL("../db/migrations/20260902010000_rectification_question_on_turn.sql", import.meta.url)),
false,
);
});
test("GET turns attach question by asked_turn_id and never by timestamps", () => {
assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, options\.focuses/);
assert.match(casesRoute, /question: turn\.question/);
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
assert.doesNotMatch(attach, /askedAt|createdAt/);
});
test("attachQuestionsToTurns joins only the matching asked turn", () => {
const prompt = GENERIC_COLLECT_QUESTION;
const turns = [
{ id: TURN_ID, role: "assistant" as const, text: "你好。", status: "completed" },
{ id: OTHER_TURN, role: "assistant" as const, text: "我们继续。", status: "completed" },
];
const attached = attachQuestionsToTurns(turns, [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: { prompt, collect: true },
status: "active",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
answerOption: null,
}]);
assert.equal(attached[0]?.question?.focus_id, FOCUS_ID);
assert.equal(attached[0]?.question?.prompt, prompt);
assert.equal(attached[0]?.question?.kind, "collect_spoken");
assert.equal(attached[1]?.question, null);
});
test("copy text is body, blank line, stem, then option lines", () => {
const question = parseTurnQuestion({
focus_id: FOCUS_ID,
question_id: "d9:relationship:2023",
kind: "choice",
prompt: "2023 年前后,你有没有一段认真开始或结束的关系?",
options: [
{ key: "A", label: "明确发生且时间吻合" },
{ key: "B", label: "发生过但时间偏了" },
{ key: "C", label: "没有这回事" },
{ key: "D", label: "记不清" },
],
status: "resolved",
answer_option: "A",
probe_id: null,
});
const copied = copyTextForMessage("2016 年 9 月上大学,记下了。", question);
assert.match(copied, /记下了。\n\n2023 年前后/);
assert.match(copied, /A\. 明确发生且时间吻合 ✓/);
assert.match(copied, /B\. 发生过但时间偏了/);
});
test("one assistant article owns the stem and options; the slot class is gone", () => {
assert.match(row, /afterAnswer/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(chat, /variant="embedded"/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /"typed"/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.match(chat, /role="status"/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
});
test("agent toolbag includes set-focus so spokenPrompt can run", () => {
assert.equal(RECTIFICATION_AGENT_TOOLS.includes("rectification-set-focus"), true);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /"rectification-set-focus",/);
assert.doesNotMatch(tools, /"rectification-set-focus": _omitted/);
});
test("message-path idle persist links a pre-built choice focus to the current turn", async () => {
const prompt = "2023 年前后,你有没有一段认真开始或结束的关系?";
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
questionId: "d9:relationship:2023",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt,
choice: {
prompt,
option_a: "明确发生且时间吻合",
option_b: "发生过但时间偏了",
option_c: "没有这回事",
option_d: "记不清",
},
},
}),
}),
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
...activeFocusFixture({
intent: "distinguish_candidates",
questionId: String(args.p_question_id),
askedTurnId: String(args.p_asked_turn_id ?? ""),
}),
asked_turn_id: args.p_asked_turn_id,
},
idempotent: true,
}),
});
const result = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(result.persisted, false);
const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.equal(linked?.args.p_asked_turn_id, TURN_ID);
});
test("typed answers keep the previous question on the same message", () => {
assert.match(chat, /markQuestionAnswered\(current, pendingFocusId, "typed"\)/);
assert.match(chat, /answer_option: selected === "typed" \? null : selected/);
assert.match(chat, /status: "resolved"/);
});
test("refresh rebuilds answered and live questions from asked_turn_id only", () => {
const liveTurn = "55555555-5555-4555-8555-555555555555";
const collectPrompt = GENERIC_COLLECT_QUESTION;
const answeredPrompt = "2015 年前后,有没有搬家或换过主要居住地?";
const livePrompt = "2014 年前后,有没有升学、转学或换学习环境?";
const choiceSchema = (prompt: string) => ({
prompt,
choice: {
prompt,
option_a: "明确发生且时间吻合",
option_b: "发生过但时间偏了",
option_c: "没有这回事",
option_d: "记不清",
},
});
const attached = attachQuestionsToTurns(
[
{ id: TURN_ID, role: "assistant" as const, text: "你好,可以慢慢说。", status: "completed" },
{ id: OTHER_TURN, role: "assistant" as const, text: "记下了,并更新了候选比较。", status: "completed" },
{ id: liveTurn, role: "assistant" as const, text: "2016 年入学记下了。", status: "completed" },
],
[
{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relationship:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: { prompt: collectPrompt, collect: true },
status: "resolved",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: "2026-09-02T00:01:00.000Z",
askedTurnId: TURN_ID,
answerOption: null,
},
{
id: "66666666-6666-4666-8666-666666666666",
caseId: CASE_ID,
questionId: "relocation:2015",
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "relocation",
targetKind: null,
expectedAnswerSchema: choiceSchema(answeredPrompt),
status: "resolved",
askedAt: "2026-09-02T00:02:00.000Z",
resolvedAt: "2026-09-02T00:03:00.000Z",
askedTurnId: OTHER_TURN,
answerOption: "A",
},
{
id: "77777777-7777-4777-8777-777777777777",
caseId: CASE_ID,
questionId: "education:2014",
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: choiceSchema(livePrompt),
status: "active",
askedAt: "2026-09-02T00:04:00.000Z",
resolvedAt: null,
askedTurnId: liveTurn,
answerOption: null,
},
],
);
assert.equal(attached[0]?.question?.kind, "collect_spoken");
assert.equal(attached[0]?.question?.status, "resolved");
assert.equal(attached[1]?.question?.answer_option, "A");
assert.equal(attached[1]?.question?.prompt, answeredPrompt);
assert.equal(attached[2]?.question?.prompt, livePrompt);
assert.equal(attached[2]?.question?.answer_option, null);
assert.equal(attached[2]?.text, "2016 年入学记下了。");
});
@@ -20,11 +20,10 @@ test("live and persisted assistant text stay verbatim after spoken-answer remova
assert.doesNotMatch(agentRun, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
});
test("structured current_question, not model prose, owns the visible question slot", () => {
test("structured current_question, not model prose, owns the visible question on the turn", () => {
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.match(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "choice"/);
assert.doesNotMatch(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
@@ -89,13 +89,12 @@ test("skill version stays 10.0.14 for the spoken-collect visibility fix", () =>
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
});
test("cases current_question drives the unified question slot", () => {
test("cases current_question remains the submit contract, not a visual slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.match(chat, /className="rectification-question-slot"/);
assert.match(chat, /isLatestMessage/);
assert.match(chat, /currentQuestion\?\.kind === "choice"/);
assert.doesNotMatch(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /onSelect=\{submitChoice\}/);
assert.match(chat, /onStop=\{submitStop\}/);
@@ -105,40 +104,42 @@ test("cases current_question drives the unified question slot", () => {
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("collect_spoken stem is joined into the latest assistant bubble, never a slot sibling", () => {
test("collect_spoken stem lives on turn.question inside the same assistant article", () => {
// old → new → kept: slot sibling → in-bubble question → no body-text scan; refresh rebuilds from asked_turn_id
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /composeCollectSpokenAssistantText\(displayedMessage\.text, collectSpokenPrompt\)/);
assert.match(chat, /composeCollectSpokenAssistantText\(last\.text, prompt\)/);
assert.match(chat, /composeCollectSpokenAssistantText\(parsed\.text, collectPrompt\)/);
assert.match(chat, /function attachCollectSpokenStem/);
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(row, /afterAnswer/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.doesNotMatch(chat, /function attachCollectSpokenStem/);
assert.match(chat, /useLayoutEffect\(\(\) => \{\s*currentQuestionRef\.current = currentQuestion;/);
assert.doesNotMatch(chat, /\[busy, currentQuestion, regeneratingMessageKey\]/);
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
assert.doesNotMatch(chat, /rectification-question-slot__prompt/);
assert.doesNotMatch(chat, /collectSpokenPromptId/);
assert.match(chat, /showQuestionSlot/);
assert.match(chat, /isLatestMessage && showQuestionSlot/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /showQuestionSlot/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
);
assert.match(chat, /<RectificationChoiceCard/);
assert.doesNotMatch(chat, /rectification-question-slot__spoken/);
assert.doesNotMatch(chat, /请在下方输入框回答/);
assert.doesNotMatch(chat, /questionHintId/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
assert.match(agentRun, /composeCollectSpokenAssistantText/);
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /replace: true/);
assert.match(agentRun, /finalizeTurn\("completed", answerText/);
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
assert.match(styles, /\.rectification-message-question \{/);
assert.match(styles, /\.rectification-choice-card\.is-embedded/);
assert.doesNotMatch(styles, /\.rectification-question-slot \{/);
});
test("missing current_question is explicit only for resumable cases", () => {
@@ -150,16 +151,18 @@ test("missing current_question is explicit only for resumable cases", () => {
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
});
test("choice_card still renders through the existing choice-card branch", () => {
test("choice options render inside the same assistant message, not a sibling slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const render = chat.slice(
chat.indexOf("{messages.map((message) => {") ,
chat.indexOf("{savedTime &&"),
);
assert.match(render, /showLiveChoiceCard/);
assert.match(chat, /showLiveChoiceCard/);
assert.match(render, /<RectificationChoiceCard/);
assert.match(render, /onSelect=\{submitChoice\}/);
assert.match(render, /onStop=\{submitStop\}/);
assert.match(render, /afterAnswer/);
assert.doesNotMatch(render, /rectification-question-slot/);
assert.doesNotMatch(render, /liveSpoken/);
assert.doesNotMatch(render, /showLiveSpokenCollect/);
});
@@ -322,18 +325,18 @@ test("active_focus collect followups get a domain-stable questionId, not active_
assert.equal(open?.kind, "collect_spoken");
});
test("agent prompt does not let the model write a persisted question stem", () => {
test("agent prompt lets the model write the stem only through set-focus spokenPrompt", () => {
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const prompt = agent.slice(
agent.indexOf("const agenticRectificationInstructions"),
agent.indexOf("export function getRectificationV9Agent"),
);
assert.match(prompt, /有持久化 current_question 时/);
assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/);
assert.match(prompt, /没有 current_question 时也不要自拟区分题/);
assert.match(prompt, /选择卡/);
assert.match(prompt, /collect_spoken 题干由服务器接在同一条正文末尾并写入聊天历史/);
assert.match(prompt, /没有下一问/);
assert.match(prompt, /选择卡|choice 选项由服务端/);
assert.doesNotMatch(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(prompt, /collect_spoken 题干由服务器接在同一条正文末尾并写入聊天历史/);
assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/);
assert.doesNotMatch(prompt, /界面提示条/);
});
@@ -371,7 +374,7 @@ test("agent route keeps question ownership in the server Case projection", () =>
assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus/);
assert.match(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /接在正文末尾并写入聊天历史/);
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /askedTurnId: turnId/);
assert.doesNotMatch(agentRun, /当前可询问范围/);
});
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateSpokenPrompt } 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";
function followup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "probe",
must_not_label: false,
choice_frame: {
question_id: "d9:relationship:2023",
method_id: "d9_relationship",
period: "2023 年前后",
prompt: "2023 年前后,你有没有一段认真开始或结束的关系?",
varga: "D9",
why: "区分",
option_a_hint: "明确发生且时间吻合",
option_b_hint: "发生过但时间偏了",
neither_label: "没有这回事",
unsure_label: "记不清",
option_a_answer_class: "yes",
option_b_answer_class: "weak_yes",
option_c_answer_class: "no",
option_d_answer_class: "unsure",
choice_mode: "A/B/C/D",
stop_label: "先这样,先看当前范围",
stop_message: "先这样",
scoring: true,
},
source: "event_probe",
probe_year: 2023,
...overrides,
};
}
test("spokenPrompt rejects length, option literals, unique-minute claims, and machine voice", () => {
assert.equal(validateSpokenPrompt({ spokenPrompt: "太短了" }).ok, false);
const optionLiteral = validateSpokenPrompt({
spokenPrompt: "2023 年这段感情,选项怎么选你还记得吗?",
});
assert.equal(optionLiteral.ok, false);
assert.equal(optionLiteral.ok ? null : optionLiteral.reason, "option_literal");
const uniqueMinute = validateSpokenPrompt({
spokenPrompt: "这能定下唯一的出生分钟吗,你还记得医院的钟点?",
});
assert.equal(uniqueMinute.ok, false);
assert.equal(uniqueMinute.ok ? null : uniqueMinute.reason, "unique_minute");
assert.equal(validateSpokenPrompt({
spokenPrompt: MACHINE_VOICE_LEXICON[3],
}).ok, false);
});
test("spokenPrompt rejects a different year or domain than the server probe", () => {
const probe = followup();
const yearMismatch = validateSpokenPrompt({
spokenPrompt: "2018 年前后,你有没有一段认真开始或结束的关系?",
followup: probe,
targetDomain: "relationship",
questionId: "d9_relationship:relationship:2023",
});
assert.equal(yearMismatch.ok ? null : yearMismatch.reason, "year_mismatch");
const domainMismatch = validateSpokenPrompt({
spokenPrompt: "2023 年前后,你有没有换过工作?",
followup: probe,
targetDomain: "career",
questionId: "d9_relationship:relationship:2023",
});
assert.equal(domainMismatch.ok ? null : domainMismatch.reason, "domain_mismatch");
assert.equal(validateSpokenPrompt({
spokenPrompt: "工作记下了。2023 年前后,有没有一段认真开始或结束的关系?",
followup: probe,
targetDomain: "relationship",
questionId: "d9_relationship:relationship:2023",
}).ok, true);
});
@@ -81,6 +81,7 @@ test("set/resolve focus services preserve the exact durable RPC contract", async
p_target_domain: "career",
p_target_kind: "career_entry",
p_expected_answer_schema: { required: ["month"] },
p_asked_turn_id: null,
},
});
@@ -103,6 +104,7 @@ test("set/resolve focus services preserve the exact durable RPC contract", async
p_focus_id: FOCUS_ID,
p_status: "declined",
p_evidence_id: EVIDENCE_ID,
p_answer_option: null,
},
});
});
@@ -127,6 +129,7 @@ test("set/resolve focus tools return safe projections and never infer the active
caseId: CASE_ID,
questionId: "career-month-question",
intent: "clarify_event_date",
spokenPrompt: "2016年前后,你是哪年上的大学?",
targetEvidenceId: EVIDENCE_ID,
targetDomain: "career",
targetKind: "career_entry",
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
@@ -54,6 +55,7 @@ const validInputs: Record<(typeof EXACT_TOOL_KEYS)[number], Record<string, unkno
caseId: CASE_ID,
questionId: "career-month-question",
intent: "clarify_event_date",
spokenPrompt: "2016年前后,你是哪年上的大学?",
targetEvidenceId: EVIDENCE_ID,
targetDomain: "career",
targetKind: "career_entry",
@@ -218,25 +220,29 @@ test("agent tools omit offer-candidates when proposeAllowed is false", () => {
trainingGateOpen: false,
candidateScores: [],
}));
assert.equal("rectification-set-focus" in tools, false);
assert.equal("rectification-set-focus" in tools, true);
assert.equal("rectification-read-case" in tools, true);
assert.equal("rectification-offer-candidates" in tools, false);
assert.equal("rectification-accept-candidate" in tools, false);
assert.equal("rectification-confirm-birth-time" in tools, false);
});
test("agent tools omit model-driven set-focus", () => {
test("agent tools include set-focus so spokenPrompt can be written", () => {
const tools = createRectificationV9AgentTools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: fakeAccounting({}).client as never,
});
assert.equal("rectification-set-focus" in tools, false);
assert.equal("rectification-set-focus" in tools, true);
assert.deepEqual(
Object.keys(tools).sort(),
EXACT_TOOL_KEYS.filter((name) => name !== "rectification-set-focus").slice().sort(),
[...EXACT_TOOL_KEYS].slice().sort(),
);
const source = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(source, /error: "invalid_spoken_prompt"/);
assert.match(source, /retryable: spokenPromptFailures < 2/);
assert.match(source, /askedTurnId: turnId/);
});
test("evidence batch items may use source offsets instead of a rewritten quote", () => {
+16 -10
View File
@@ -67,7 +67,7 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /工具执行保持静默/);
assert.match(prompt, /思考用简体中文写在思维链/);
assert.match(prompt, /对用户说的话必须自己写在正文里/);
assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /每轮正文 2-4 句/);
assert.match(prompt, /「先这样」由服务器/);
assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/);
@@ -238,9 +238,9 @@ test("agent receives the exact server-owned case id for tool calls", async () =>
assert.match(openingPrompt, new RegExp(CASE_ID));
assert.match(openingPrompt, /服务端 opening brief/);
assert.match(openingPrompt, /不要要求一次说完/);
assert.match(openingPrompt, /当前 active focus/);
assert.match(openingPrompt, /spokenPrompt 写出当前采集题/);
assert.match(openingPrompt, /不要提问/);
assert.match(openingPrompt, /接在正文末尾并写入聊天历史/);
assert.doesNotMatch(openingPrompt, /接在正文末尾并写入聊天历史/);
assert.doesNotMatch(openingPrompt, /当前可询问范围/);
assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/);
});
@@ -299,7 +299,7 @@ test("server-loaded Skill is bound before the provider and the first model step
assert.equal(typeof firstStep?.toolChoice, "string");
assert.deepEqual(
(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }) as { activeTools?: string[] }).activeTools?.includes("rectification-set-focus"),
false,
true,
);
assert.ok(
((await observedStreamOptions.prepareStep?.({ stepNumber: 1 })) as { activeTools?: string[] }).activeTools?.includes("rectification-read-case"),
@@ -354,7 +354,7 @@ test("first turn with a bound immutable Skill completes and persists receipts",
assert.ok(accountingCalls.includes("finalize_agentic_rectification_turn"));
});
test("collect_spoken stem is written onto the same assistant turn before finalize", async () => {
test("collect_spoken stem is persisted on the same turn via asked_turn_id, not assistant_message", async () => {
const greeting = "你好,我是生时校正助手。";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
@@ -389,16 +389,22 @@ test("collect_spoken stem is written onto the same assistant turn before finaliz
]) as never,
});
const result = await runV9AgentTurn(options);
const combined = composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION);
assert.equal(result.ok, true);
assert.equal(result.answerText, combined);
assert.equal(result.collectSpokenEmitted, true);
assert.equal(result.answerText, greeting);
assert.equal(result.collectSpokenEmitted, false);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.equal(replaced?.text, combined);
assert.notEqual(replaced?.text, composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION));
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalize?.args.p_assistant_message, combined);
assert.equal(finalize?.args.p_assistant_message, greeting);
assert.equal(
accounting.calls.some((call) => (
call.fn === "set_agentic_rectification_conversation_focus"
&& call.args.p_asked_turn_id === TURN_ID
)),
true,
);
});
test("framework getSkill failure is a controlled retry and then a failed turn", async () => {
@@ -128,6 +128,7 @@ test("regeneration replaces the latest completed Assistant text in place", async
assert.deepEqual(accounting.calls.map((call) => call.fn), [
"get_agentic_rectification_turn_regeneration",
"get_agentic_rectification_case_dossier",
"list_agentic_rectification_conversation_focuses",
"insert_agentic_rectification_skill_run_receipt",
"regenerate_agentic_rectification_turn",
]);
@@ -261,8 +262,7 @@ test("an empty regenerated body never reaches the update RPC", async () => {
assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false);
});
test("regeneration reattaches the current collect_spoken stem before writing history", async () => {
const combined = composeCollectSpokenAssistantText(NEW_REPLY, GENERIC_COLLECT_QUESTION);
test("regeneration rewrites the body only and leaves the asked-turn question in place", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({
@@ -281,9 +281,21 @@ test("regeneration reattaches the current collect_spoken stem before writing his
prompt: GENERIC_COLLECT_QUESTION,
collect: true,
},
askedTurnId: TURN_ID,
}),
}),
}),
list_agentic_rectification_conversation_focuses: () => [{
...activeFocusFixture({
intent: "collect_method_evidence",
questionId: "collect:unknown:collect_method_evidence",
expectedAnswerSchema: {
prompt: GENERIC_COLLECT_QUESTION,
collect: true,
},
askedTurnId: TURN_ID,
}),
}],
insert_agentic_rectification_skill_run_receipt: () => ({
receipt_id: "99999999-9999-4999-8999-999999999999",
}),
@@ -296,7 +308,7 @@ test("regeneration reattaches the current collect_spoken stem before writing his
});
const { agent } = fakeAgent();
const result = await regenerateV9AssistantTurn(options(accounting.client, agent));
assert.equal(result.assistantMessage, combined);
assert.equal(result.assistantMessage, NEW_REPLY);
const update = accounting.calls.find((call) => call.fn === "regenerate_agentic_rectification_turn");
assert.equal(update?.args.p_assistant_message, combined);
assert.equal(update?.args.p_assistant_message, NEW_REPLY);
});
@@ -156,6 +156,8 @@ export function activeFocusFixture(overrides: {
targetDomain?: string | null;
targetKind?: string | null;
expectedAnswerSchema?: Record<string, unknown>;
askedTurnId?: string | null;
answerOption?: "A" | "B" | "C" | "D" | "stop" | null;
} = {}) {
return {
id: overrides.id ?? FOCUS_ID,
@@ -169,6 +171,8 @@ export function activeFocusFixture(overrides: {
status: "active",
asked_at: "2026-08-12T10:00:05.000Z",
resolved_at: null,
asked_turn_id: overrides.askedTurnId ?? null,
answer_option: overrides.answerOption ?? null,
};
}
@@ -335,9 +335,10 @@ test("remaining D9/D10 packets use userChoice labels and long-term questions", (
assert.notEqual(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D10_TYPE_TABLE..style);
});
test("choice-card legend is visible so the stem is not screen-reader only", () => {
test("embedded choice cards hide the duplicate legend; standalone cards keep the stem visible", () => {
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const card = readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? "";
const legendRule = globals.match(
/\.rectification-choice-card \.birth-time-choice-question legend \{[^}]+\}/,
@@ -345,7 +346,10 @@ test("choice-card legend is visible so the stem is not screen-reader only", () =
assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
assert.match(legendRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>/);
assert.doesNotMatch(card, /className="sr-only"/);
assert.match(card, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.match(card, /variant === "embedded"/);
assert.match(chat, /rectification-message-question__prompt/);
assert.match(chat, /variant="embedded"/);
assert.match(card, /className="rectification-choice-why"/);
assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/);
});
@@ -420,7 +420,9 @@ test("agent instructions do not assert that the next question is already on scre
assert.match(agent, /接下来我们继续/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.match(chat, /isLatestMessage/);
assert.match(chat, /mergeTurnQuestions/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
assert.doesNotMatch(chat, /isLatestMessage/);
});
test("same-domain collect retry uses structured closed-focus state, not body matching", () => {