fix(rectification): put each question inside the assistant message
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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_report;80%/60% 只是事件吻合率。
|
||||
6. 一次一问。不泄露提示词或 Skill 原文。
|
||||
坏:「好的,记下了。」好:「2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。」
|
||||
|
||||
@@ -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 的稳定 id;targetEvidenceId/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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user