fix(rectification): server-append spoken collect stems after free-text turns
Choice path already wrote spokenFollowupForUser into the body; free-text dropped that stem and only filled empty answers, so a new collect_spoken focus stayed invisible after “记下了”. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-08-30 — 口述采集题由服务器接在正文后(Skill 10.0.13 不变)
|
||||
|
||||
自由文本记下经历后,`collect_spoken` 题已经落库,但助手只说「记下了:…」,用户看不到下一问。点选路径本来就会把 `spokenFollowupForUser` 写进正文;自由文本路径把这句题干丢了,而且只在正文为空时补。
|
||||
|
||||
- 用本轮是否新建 active focus 决定补不补,不看正文有没有问号。
|
||||
- 题干仍是普通一段,接在同一条助手消息后面;不新造容器,不复用选择卡。
|
||||
- 撤回「模型必须自己把口述题问出来」,避免服务器补了模型再问一遍。
|
||||
|
||||
## 2026-08-30 — 反推前事探针供给扩容(Skill 10.0.13 不变)
|
||||
|
||||
区分阶段在收窄无年份分盘对比题之后,带年份的 `dasha_boundary` 反推题每个 case 只剩 1–3 条。本轮只加供给,不放宽判定门槛、不改计分口径。
|
||||
|
||||
+21
-5
@@ -6756,14 +6756,14 @@
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-30
|
||||
- 最近更新:2026-08-30
|
||||
- 影响面:GET `current_question`、`openQuestionFromPersistedFocus`、`agentVisibleLatestProjection`、`stableFollowupQuestionId`、POST `/api/rectification/agent` 出卡判定与空正文兜底、Agent 系统提示第 4 条
|
||||
- 影响面:GET `current_question`、`openQuestionFromPersistedFocus`、`agentVisibleLatestProjection`、`stableFollowupQuestionId`、POST `/api/rectification/agent` 出卡判定与空正文兜底、Agent 系统提示第 4 条。可见性策略已被 BUG-449 取代。
|
||||
- 用户现象:助手正文只写「接下来看下面这一问。」,界面没有任何问题。GET 里 `choice_card` 为 null,`current_question` 已是合法口述采集题(`kind=collect_spoken`、prompt 非空),问题只存在于数据库和 API。
|
||||
- 触发条件:方法覆盖回落到不带 choice_frame 的口述采集焦点;Agent 按选择卡承接措辞说话;前端只解析 `choice_card`。
|
||||
- 根因:三段同时成立。(1) 前端从不渲染 `current_question`:`applyCaseSnapshot` 只解析 `latest_result` / `choice_card` / `case`,`kind=collect_spoken` 在界面上没有承载位置。(2) 面向 Agent 的两套投影自相矛盾:`projectTurnDecision` 给出 `current_question.kind=collect_spoken` 与 prompt;记完证据后的 `agentVisibleLatestProjection` 取自 `openQuestionFromPersistedFocus`,后者对 collect focus 一律返回 null。Agent 收到打架信号,退回「卡片会显示」的措辞。(3) `stableFollowupQuestionId` 在 followup 既无 semantic_key 也无 probe_year 时退到 `${method_id}:${ask_theme}`;`persistPlanFocus` 传入的 active_focus 计划正好两者都没有,questionId 退化成 `active_focus:active_focus`。
|
||||
- 修复:P1/P2 仍在:`openQuestionFromPersistedFocus` 对 collect focus 返回带 `kind=collect_spoken` 的同形结果;`choiceReady` / 出卡判定只对 `kind=choice` 为真;`current_probe` 与 `inference.next_probe` 仍只对可渲染选择题放行;collect 在无 semantic_key / probe_year 时用 `collect:<domain>:<intent>`,`active_focus:active_focus` 不得与新 id 互相 already_open。P0 独立提示条已撤回:前端不再解析或渲染 `current_question`;口述采集题改由 Agent 用自己的话在正文里问出来,一次一问,不得照抄服务端 prompt 原文,但意思和时间范围不得改。「请点选」「看下面这一问」仍只允许在确实有选择卡时使用。服务端 collect focus 继续持久化,GET 仍返回 `current_question`。`runV9AgentTurn` 返回后若 `result.ok` 且 `answerText` 为空、且存在 `kind=collect_spoken` 的 active focus,则补一条普通助手消息(内容等于 focus prompt)并在关流前发出;`answerText` 非空一律不补。Skill 保持 10.0.13,未放宽 confirmation gate。
|
||||
- 验证:`frontend/tests/rectification-spoken-collect.test.ts` 锁:GET `collect_spoken` 不再渲染独立块、`.rectification-spoken-collect-prompt` 不存在、选择卡分支仍在、提示词要求正文提问、空正文落 focus prompt、非空正文不补。collect openQuestion 不为卡片、probe 在 collect 下为空、active_focus 派生 questionId 带 domain。BUG-440 collect-stall 全绿,D12 同领域计分卡仍优先于家人口述题。
|
||||
- 防复发:服务端问题的可见性由正文与确定性兜底保证,不得为无选项的问题新造视觉容器,更不得借用选择卡的样式类。同一焦点在所有面向 Agent 的投影里形态必须一致。「请点选」类措辞只允许在有选择卡时使用。不得用正文文本判断助手有没有问出来。collect questionId 不得退化为 `active_focus:active_focus`。
|
||||
- 相关记录:BUG-404、BUG-411、BUG-432、BUG-437、BUG-440
|
||||
- 修复:P1/P2 仍在:`openQuestionFromPersistedFocus` 对 collect focus 返回带 `kind=collect_spoken` 的同形结果;`choiceReady` / 出卡判定只对 `kind=choice` 为真;`current_probe` 与 `inference.next_probe` 仍只对可渲染选择题放行;collect 在无 semantic_key / probe_year 时用 `collect:<domain>:<intent>`,`active_focus:active_focus` 不得与新 id 互相 already_open。P0 独立提示条已撤回:前端不再解析或渲染 `current_question`。~~口述采集题改由 Agent 用自己的话在正文里问出来~~(该可见性策略已被 BUG-449 取代:题干改由服务器按焦点生命周期确定性接在正文之后,模型不得自行写题干)。「请点选」「看下面这一问」仍只允许在确实有选择卡时使用。服务端 collect focus 继续持久化,GET 仍返回 `current_question`。Skill 保持 10.0.13,未放宽 confirmation gate。
|
||||
- 验证:`frontend/tests/rectification-spoken-collect.test.ts` 锁:GET `collect_spoken` 不再渲染独立块、`.rectification-spoken-collect-prompt` 不存在、选择卡分支仍在。collect openQuestion 不为卡片、probe 在 collect 下为空、active_focus 派生 questionId 带 domain。可见性回归见 BUG-449。BUG-440 collect-stall 全绿,D12 同领域计分卡仍优先于家人口述题。
|
||||
- 防复发:服务端问题的可见性由正文与确定性兜底保证,不得为无选项的问题新造视觉容器,更不得借用选择卡的样式类。同一焦点在所有面向 Agent 的投影里形态必须一致。「请点选」类措辞只允许在有选择卡时使用。不得用正文文本判断助手有没有问出来。collect questionId 不得退化为 `active_focus:active_focus`。口述采集题的可见性策略见 BUG-449,不得再改回「指望模型问」。
|
||||
- 相关记录:BUG-404、BUG-411、BUG-432、BUG-437、BUG-440、BUG-449
|
||||
- 复发自:BUG-432(无选择卡的问题曾要求由 Agent 正文问出,前端仍不渲染 `current_question`)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -6878,3 +6878,19 @@
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-449 | 口述采集题已落库但自由文本后助手只说记下了,用户看不到下一问
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-30
|
||||
- 最近更新:2026-08-30
|
||||
- 影响面:POST `/api/rectification/agent` 自由文本路径、`persistNextInterviewIfIdle`、`persistCollectSpokenAssistantIfNew`、`runV9AgentTurn` 口述题可见性、Agent 系统提示第 4 条
|
||||
- 用户现象:答完一件事后助手只说「记下了:…」,之后没有任何问题也没有卡片,但 GET 里 `current_question` 已是合法 `collect_spoken` 题。
|
||||
- 触发条件:自由文本记下带年份经历(本例唯一证据为 2022 搬家、`precision=year`);轮内 `rectification-record-evidence-batch` → `autoRescoreAfterEvidenceChange` → `persistPlanFocus` 新建 `collect:relationship:collect_method_evidence`;助手正文非空。
|
||||
- 根因:五段同时成立。(1) `persistNextInterviewAfterChoice` 对 collect 焦点返回 `hostNarration = spokenFollowupForUser(followup)`;点选路径把它写进正文,所以点选没坏。(2) `persistNextInterviewIfIdle` 调用同一个函数,却丢弃 `hostNarration`,只返回 `{persisted, choiceReady}`。自由文本路径因此没有题干输出。(3) 它还在开头 `if (dossier.conversationSummary.activeFocus) return` 提前返回;本 case 的焦点是轮内 `persistPlanFocus` 建的,所以这条兜底连跑都没跑。(4) route 唯一的兜底 `persistEmptyCollectSpokenAssistant` 只在 `!result.answerText.trim()` 时触发;正文非空一律不补。(5) 选择卡不受影响(卡片由 `current_question` 渲染);区分题有 `discriminatorInvariant` fail-closed。collect 两样都没有。BUG-441 把可见性改成「指望模型问」后,非空正文不再由服务器保证。
|
||||
- 修复:P0:在 `runV9AgentTurn` 已有的首份 dossier 上快照 `activeFocus.id`;轮后若 `projectCurrentQuestion` 为 `collect_spoken` 且 focus id 与轮前不同(含轮前为 null),服务器补题干。同一 id 不补。choice 永不补。P1:题干复用 `spokenFollowupForUser` / schema `prompt`;非空正文作为独立一段接在同一条助手消息后并 `answer.delta`;空正文整条消息就是题干。补出文本过 `INTERNAL_TOKEN_RE`,不得含「请点选」。agent-run 与 route 共用 helper + 本轮 `collectSpokenEmitted`,同一轮最多一次。P2:`persistNextInterviewIfIdle` 返回 `hostNarration`,交给 P1;activeFocus 提前返回保留,但不吞掉 P0。P3:撤回「口述采集题必须由你用自己的话问出来」;有持久化当前问题时题干一律不由模型写。Skill 保持 10.0.13。未放宽 confirmation gate / coverageComplete,未回退 b43808b0 无年份收窄。未用正文文本判断是否已问,未新造视觉容器。
|
||||
- 验证:`frontend/tests/rectification-spoken-collect.test.ts` 锁:2022 relocation 自由文本后正文含感情采集题干;同一 focus id 下一轮不重复补;choice 正文无题干;空正文整条即题干;in-turn / idle 共用同一句题干且每轮一次;无内部 token、无「请点选」。改写「非空不补」与 route `if (!answerText.trim())` 旧锁。`assert.doesNotMatch(/answerText\.(?:includes|match|search)\(/)` 仍绿。BUG-440 / BUG-441 / BUG-442 / b43808b0 无年份收窄回归保持绿。修复前:`persistEmptyCollectSpokenAssistant({ answerText: "记下了:2022年搬家。" })` 返回 `null`。
|
||||
- 防复发:口述采集题的可见性由服务器确定性输出保证,不得依赖模型是否照做,也不得用正文文本反推;点选路径与自由文本路径必须共用同一句题干来源(`spokenFollowupForUser` / schema prompt)。不得为口述题新造视觉容器或复用选择卡样式。
|
||||
- 相关记录:BUG-404、BUG-432、BUG-440、BUG-441
|
||||
- 复发自:BUG-441
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
applyRectificationChoice,
|
||||
applyCollectFocusDenial,
|
||||
persistNextInterviewIfIdle,
|
||||
persistEmptyCollectSpokenAssistant,
|
||||
persistCollectSpokenAssistantIfNew,
|
||||
} from "@/lib/rectification-agentic/v9/answer-choice";
|
||||
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
|
||||
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
|
||||
@@ -668,32 +668,35 @@ export async function POST(request: Request) {
|
||||
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
|
||||
} else {
|
||||
if (action === "message") {
|
||||
let idleHostNarration: string | null = null;
|
||||
try {
|
||||
await persistNextInterviewIfIdle({
|
||||
const idle = await persistNextInterviewIfIdle({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
});
|
||||
idleHostNarration = idle.hostNarration;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
if (!result.answerText.trim()) {
|
||||
try {
|
||||
const fallback = await persistEmptyCollectSpokenAssistant({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
requestId: crypto.randomUUID(),
|
||||
answerText: result.answerText,
|
||||
});
|
||||
if (fallback) send({ type: "answer.delta", text: fallback });
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] empty collect spoken fallback failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const fallback = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
requestId: crypto.randomUUID(),
|
||||
answerText: result.answerText,
|
||||
previousFocusId: result.previousFocusId,
|
||||
alreadyEmitted: result.collectSpokenEmitted,
|
||||
hostNarration: idleHostNarration,
|
||||
});
|
||||
if (fallback) send({ type: "answer.delta", text: fallback });
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] collect spoken visibility fallback failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
send({ type: "done", emitted: true });
|
||||
|
||||
@@ -54,7 +54,11 @@ import {
|
||||
openQuestionPromptFromToolResult,
|
||||
publicNarrationDtoFromDossier,
|
||||
} from "./turn-narration";
|
||||
import { emptyAnswerCollectSpokenFallback, projectCurrentQuestion } from "./turn-decision";
|
||||
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
|
||||
import {
|
||||
collectSpokenPromptForNewFocus,
|
||||
composeCollectSpokenAssistantText,
|
||||
} from "./turn-decision";
|
||||
import {
|
||||
defaultMessageOrigin,
|
||||
isRectificationMessageOrigin,
|
||||
@@ -106,6 +110,8 @@ export type V9AgentRunResult = Readonly<{
|
||||
phases: readonly string[];
|
||||
toolsUsed: readonly string[];
|
||||
errorCode: string | null;
|
||||
previousFocusId: string | null;
|
||||
collectSpokenEmitted: boolean;
|
||||
}>;
|
||||
|
||||
type AttemptStatus = "completed" | "failed" | "retryable";
|
||||
@@ -238,6 +244,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const skillName = options.skillName ?? RECTIFICATION_SKILL_NAME;
|
||||
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
|
||||
let collectSpokenEmitted = false;
|
||||
if (dossier.case.sessionId !== sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
}
|
||||
@@ -323,6 +331,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
phases: ["run.completed"],
|
||||
toolsUsed: [],
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
if (existingStatus !== "pending") await billing.release();
|
||||
@@ -432,6 +442,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
phases: outcome.phases,
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: outcome.errorCode,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -465,6 +477,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
phases: [],
|
||||
toolsUsed: [],
|
||||
errorCode: "usage_settlement_failed",
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,6 +520,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
phases: [...outcome.phases, "billing.settled", "run.completed"],
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
|
||||
async function streamAttempt(attemptNumber: number, attemptId: string): Promise<AttemptOutcome> {
|
||||
@@ -669,6 +685,31 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
await emit({ type: "answer.delta", text: visible, replace: true });
|
||||
};
|
||||
|
||||
const emitNewCollectSpokenIfNeeded = async (): Promise<void> => {
|
||||
if (collectSpokenEmitted) return;
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const fromFocus = collectSpokenPromptForNewFocus({
|
||||
previousFocusId,
|
||||
focus: latest.conversationSummary.activeFocus,
|
||||
});
|
||||
const prompt = fromFocus && isSafeCollectSpokenPrompt(fromFocus) ? fromFocus : null;
|
||||
if (!prompt) return;
|
||||
const { composed, delta } = composeCollectSpokenAssistantText(answerText, prompt);
|
||||
if (!delta) return;
|
||||
if (!answerText.trim()) {
|
||||
await emitVisibleSpoken(composed);
|
||||
} else {
|
||||
answerText = composed;
|
||||
answerDeltas.push(delta);
|
||||
await emit({ type: "answer.delta", text: delta });
|
||||
}
|
||||
collectSpokenEmitted = true;
|
||||
} catch {
|
||||
// Visibility fallback must not fail the turn.
|
||||
}
|
||||
};
|
||||
|
||||
const publishSpokenStep = async (pieces: readonly string[], live = false) => {
|
||||
const joined = pieces.join("");
|
||||
if (!joined) return;
|
||||
@@ -884,13 +925,19 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
maxSteps,
|
||||
});
|
||||
if (mapped === "run_timeout") {
|
||||
if (await flushPersistedPrompt()) return completeAttempt();
|
||||
if (await flushPersistedPrompt()) {
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
return completeAttempt();
|
||||
}
|
||||
return failedAttempt(attemptId, "run_timeout");
|
||||
}
|
||||
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
|
||||
if (!finished) return failedAttempt(attemptId, mapped ?? "stream_unfinished");
|
||||
if (mapped === "answer_truncated") {
|
||||
if (await flushPersistedPrompt()) return completeAttempt();
|
||||
if (await flushPersistedPrompt()) {
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
return completeAttempt();
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
@@ -910,19 +957,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
return failedAttempt(attemptId, mapped);
|
||||
}
|
||||
if (await flushPersistedPrompt()) {
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
const invariant = await discriminatorInvariant();
|
||||
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
|
||||
return completeAttempt();
|
||||
}
|
||||
await emitNewCollectSpokenIfNeeded();
|
||||
if (!answerText.trim()) {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const collect = emptyAnswerCollectSpokenFallback(
|
||||
"",
|
||||
projectCurrentQuestion(latest.conversationSummary.activeFocus),
|
||||
);
|
||||
const narration = collect
|
||||
?? composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
|
||||
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
|
||||
if (narration.trim()) {
|
||||
answerText = narration;
|
||||
answerDeltas.push(narration);
|
||||
|
||||
@@ -47,7 +47,11 @@ import {
|
||||
spokenFollowupForUser,
|
||||
} from "./method-followup";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate";
|
||||
import { emptyAnswerCollectSpokenFallback, projectCurrentQuestion } from "./turn-decision";
|
||||
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
|
||||
import {
|
||||
collectSpokenPromptForNewFocus,
|
||||
composeCollectSpokenAssistantText,
|
||||
} from "./turn-decision";
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
@@ -454,10 +458,10 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
}): Promise<{ persisted: boolean; choiceReady: boolean }> {
|
||||
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
if (dossier.conversationSummary.activeFocus) {
|
||||
return { persisted: false, choiceReady: false };
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
let birthDate: string | null = null;
|
||||
try {
|
||||
@@ -468,7 +472,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
}
|
||||
const decision = decideFromDossier(dossier, { birthDate });
|
||||
if (isNonConvergingRangeOffer(decision)) {
|
||||
return { persisted: false, choiceReady: false };
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const plan = buildMethodFollowupPlan({
|
||||
@@ -482,7 +486,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
});
|
||||
if (!plan.next_followup) {
|
||||
return { persisted: false, choiceReady: false };
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
const nextAction = publicNextAction(decision);
|
||||
const nextInterview = await persistNextInterviewAfterChoice({
|
||||
@@ -497,29 +501,51 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
return {
|
||||
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
|
||||
choiceReady: nextInterview.choiceReady,
|
||||
hostNarration: nextInterview.hostNarration,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistCollectSpokenAssistantIfNew(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
requestId: string;
|
||||
answerText: string;
|
||||
previousFocusId?: string | null;
|
||||
alreadyEmitted?: boolean;
|
||||
hostNarration?: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (input.alreadyEmitted) return null;
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
const fromFocus = collectSpokenPromptForNewFocus({
|
||||
previousFocusId: input.previousFocusId ?? null,
|
||||
focus: dossier.conversationSummary.activeFocus,
|
||||
});
|
||||
if (!fromFocus) return null;
|
||||
const host = input.hostNarration?.trim() ?? "";
|
||||
const prompt = host && isSafeCollectSpokenPrompt(host) ? host : fromFocus;
|
||||
if (!isSafeCollectSpokenPrompt(prompt)) return null;
|
||||
const { delta } = composeCollectSpokenAssistantText(input.answerText, prompt);
|
||||
if (!delta) return null;
|
||||
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: null,
|
||||
assistantMessage: prompt,
|
||||
});
|
||||
return delta;
|
||||
}
|
||||
|
||||
export async function persistEmptyCollectSpokenAssistant(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
requestId: string;
|
||||
answerText: string;
|
||||
previousFocusId?: string | null;
|
||||
alreadyEmitted?: boolean;
|
||||
hostNarration?: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (input.answerText.trim()) return null;
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
const prompt = emptyAnswerCollectSpokenFallback(
|
||||
input.answerText,
|
||||
projectCurrentQuestion(dossier.conversationSummary.activeFocus),
|
||||
);
|
||||
if (!prompt) return null;
|
||||
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: null,
|
||||
assistantMessage: prompt,
|
||||
});
|
||||
return prompt;
|
||||
return persistCollectSpokenAssistantIfNew(input);
|
||||
}
|
||||
|
||||
async function persistApplied(
|
||||
|
||||
@@ -35,6 +35,14 @@ function isActivityEcho(text: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function isSafeCollectSpokenPrompt(prompt: string): boolean {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) return false;
|
||||
if (INTERNAL_TOKEN_RE.test(trimmed)) return false;
|
||||
if (trimmed.includes("请点选") || trimmed.includes("看下面这一问")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isRectificationProcessNarration(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
@@ -77,6 +77,36 @@ export function emptyAnswerCollectSpokenFallback(
|
||||
return prompt || null;
|
||||
}
|
||||
|
||||
export function collectSpokenPromptForNewFocus(input: {
|
||||
previousFocusId: string | null | undefined;
|
||||
focus: Parameters<typeof projectCurrentQuestion>[0];
|
||||
}): string | null {
|
||||
const question = projectCurrentQuestion(input.focus);
|
||||
if (question?.kind !== "collect_spoken") return null;
|
||||
const focusId = typeof input.focus?.id === "string" && input.focus.id.trim()
|
||||
? input.focus.id
|
||||
: question.focus_id;
|
||||
if (!focusId || focusId === (input.previousFocusId ?? null)) return null;
|
||||
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
|
||||
return prompt || null;
|
||||
}
|
||||
|
||||
export function composeCollectSpokenAssistantText(answerText: string, prompt: string): {
|
||||
composed: string;
|
||||
delta: string;
|
||||
} {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt) return { composed: answerText, delta: "" };
|
||||
if (!answerText.trim()) {
|
||||
return { composed: trimmedPrompt, delta: trimmedPrompt };
|
||||
}
|
||||
const delta = `\n\n${trimmedPrompt}`;
|
||||
return {
|
||||
composed: `${answerText.replace(/\s+$/u, "")}${delta}`,
|
||||
delta,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectCurrentQuestion(
|
||||
focus: {
|
||||
id?: string;
|
||||
|
||||
@@ -33,6 +33,7 @@ export function composeRectificationTurnNarration(dto: RectificationNarrationDto
|
||||
|
||||
function promptFromQuestionField(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
if ((value as { kind?: unknown }).kind === "collect_spoken") return null;
|
||||
const prompt = (value as { prompt?: unknown }).prompt;
|
||||
if (typeof prompt !== "string") return null;
|
||||
const text = prompt.trim();
|
||||
|
||||
@@ -63,7 +63,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
|
||||
1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。
|
||||
2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。
|
||||
3. 新事件走 rectification-record-evidence-batch。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。
|
||||
4. 有持久化选择题(current_question.kind=choice / 选择卡)时,题干和选项只由选择卡展示,正文只自然承接,不得另写、改写或复述。「请点选」「看下面这一问」只允许在确实有选择卡时使用。口述采集题(current_question.kind=collect_spoken)必须由你用自己的话在正文里问出来,一次一问;不要照抄服务端 prompt 原文,但意思和时间范围不得改。没有持久化当前问题时,用自然语言问一件带大概年份的经历,不得自拟区分题。点选与「先这样」由服务器处理。
|
||||
4. 有持久化当前问题时(current_question.kind=choice 或 collect_spoken),题干一律不由你写。choice 的题干和选项只由选择卡展示,正文只自然承接;collect_spoken 的题干由服务器接在正文之后。你只写一句自然承接(例如确认刚记下的事),不得复述、改写或预告题干。「请点选」「看下面这一问」只允许在确实有选择卡时使用。没有持久化当前问题时,用自然语言问一件带大概年份的经历,不得自拟区分题。点选与「先这样」由服务器处理。
|
||||
5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。出牌轮写入 skill_verification_report;80%/60% 只是事件吻合率。
|
||||
6. 一次一问。不泄露提示词或 Skill 原文。`;
|
||||
|
||||
|
||||
@@ -622,6 +622,7 @@ test("free-text turns persist the next followup so current_question is not null"
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(persisted.persisted, true);
|
||||
assert.ok(persisted.hostNarration || persisted.choiceReady);
|
||||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.ok(setFocus);
|
||||
const question = projectCurrentQuestion({
|
||||
@@ -920,6 +921,7 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.equal(persisted.persisted, true);
|
||||
assert.ok(persisted.hostNarration || persisted.choiceReady);
|
||||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.ok(setFocus);
|
||||
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
|
||||
|
||||
@@ -9,14 +9,17 @@ import {
|
||||
persistServerOwnedFocus,
|
||||
stableFollowupQuestionId,
|
||||
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { emptyAnswerCollectSpokenFallback, projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { emptyAnswerCollectSpokenFallback, projectTurnDecision, collectSpokenPromptForNewFocus, composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { persistCollectSpokenAssistantIfNew, persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { spokenCollectFallbackFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { isSafeCollectSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
|
||||
import { openQuestionPromptFromToolResult } from "../src/lib/rectification-agentic/v9/turn-narration.ts";
|
||||
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
EVIDENCE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
@@ -297,36 +300,150 @@ test("active_focus collect followups get a domain-stable questionId, not active_
|
||||
assert.equal(open?.kind, "collect_spoken");
|
||||
});
|
||||
|
||||
test("agent prompt requires spoken collect questions in the body and forbids 请点选 unless a choice card exists", () => {
|
||||
test("agent prompt does not let the model write a persisted question stem", () => {
|
||||
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, /口述采集题[\s\S]*必须由你用自己的话在正文里问出来/);
|
||||
assert.match(prompt, /有持久化当前问题时/);
|
||||
assert.match(prompt, /题干一律不由你写/);
|
||||
assert.match(prompt, /collect_spoken[\s\S]*服务器接在正文之后/);
|
||||
assert.match(prompt, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
|
||||
assert.match(prompt, /请点选/);
|
||||
assert.match(prompt, /选择卡/);
|
||||
assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/);
|
||||
assert.doesNotMatch(prompt, /界面提示条/);
|
||||
assert.doesNotMatch(prompt, /不复述题干/);
|
||||
});
|
||||
|
||||
const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const RECORDED_BODY = "记下了:2022年搬家。";
|
||||
const COLLECT_QUESTION_ID = "collect:relationship:collect_method_evidence";
|
||||
|
||||
function collectFocus() {
|
||||
return activeFocusFixture({
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "relationship",
|
||||
questionId: COLLECT_QUESTION_ID,
|
||||
expectedAnswerSchema: {
|
||||
prompt: RELATIONSHIP_PROMPT,
|
||||
collect: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function collectFocusDossier() {
|
||||
return dossierFixture({
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: collectFocus(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function relocation2022Dossier(activeFocus: ReturnType<typeof collectFocus> | null) {
|
||||
return dossierFixture({
|
||||
evidence: [
|
||||
{
|
||||
id: EVIDENCE_ID,
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "relocation",
|
||||
domain: "relocation",
|
||||
occurred_from: "2022-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "2022年搬家",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-30T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function choiceFocusDossier() {
|
||||
return dossierFixture({
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: activeFocusFixture({
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "relationship",
|
||||
intent: "distinguish_candidates",
|
||||
targetDomain: "education",
|
||||
expectedAnswerSchema: {
|
||||
prompt: RELATIONSHIP_PROMPT,
|
||||
collect: true,
|
||||
choice: {
|
||||
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
|
||||
option_a: "明确发生且时间吻合",
|
||||
option_b: "发生过但程度较弱",
|
||||
option_c: "明确没有发生",
|
||||
option_d: "这段记不清楚",
|
||||
options: [
|
||||
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
|
||||
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
|
||||
{ key: "C", label: "明确没有发生", answer_class: "no" },
|
||||
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
test("new collect_spoken focus is visible from the focus lifecycle, not from body text", () => {
|
||||
const focus = {
|
||||
id: FOCUS_ID,
|
||||
questionId: COLLECT_QUESTION_ID,
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "relationship",
|
||||
expectedAnswerSchema: {
|
||||
prompt: RELATIONSHIP_PROMPT,
|
||||
collect: true,
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
collectSpokenPromptForNewFocus({ previousFocusId: null, focus }),
|
||||
RELATIONSHIP_PROMPT,
|
||||
);
|
||||
assert.equal(
|
||||
collectSpokenPromptForNewFocus({ previousFocusId: FOCUS_ID, focus }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
collectSpokenPromptForNewFocus({
|
||||
previousFocusId: null,
|
||||
focus: {
|
||||
id: FOCUS_ID,
|
||||
intent: "distinguish_candidates",
|
||||
expectedAnswerSchema: {
|
||||
choice: { prompt: RELATIONSHIP_PROMPT, option_a: "A", option_b: "B", option_c: "C", option_d: "D" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(composeCollectSpokenAssistantText(" ", RELATIONSHIP_PROMPT).composed, RELATIONSHIP_PROMPT);
|
||||
assert.equal(
|
||||
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).composed,
|
||||
`${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`,
|
||||
);
|
||||
assert.equal(
|
||||
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).delta,
|
||||
`\n\n${RELATIONSHIP_PROMPT}`,
|
||||
);
|
||||
assert.equal(spokenFollowupForUser(collectFollowup()), RELATIONSHIP_PROMPT);
|
||||
assert.equal(isSafeCollectSpokenPrompt(RELATIONSHIP_PROMPT), true);
|
||||
assert.equal(isSafeCollectSpokenPrompt("接下来请点选下面这一问。"), false);
|
||||
assert.equal(isSafeCollectSpokenPrompt("看下面这一问"), false);
|
||||
assert.equal(isSafeCollectSpokenPrompt("请继续 focusId 提问"), false);
|
||||
assert.equal(openQuestionPromptFromToolResult({
|
||||
type: "tool-result",
|
||||
payload: {
|
||||
result: { open_question: { kind: "collect_spoken", prompt: RELATIONSHIP_PROMPT } },
|
||||
},
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("empty agent body persists the collect_spoken prompt as a plain assistant message", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
||||
@@ -342,6 +459,7 @@ test("empty agent body persists the collect_spoken prompt as a plain assistant m
|
||||
caseId: CASE_ID,
|
||||
requestId: FALLBACK_REQUEST_ID,
|
||||
answerText: " ",
|
||||
previousFocusId: null,
|
||||
});
|
||||
assert.equal(filled, RELATIONSHIP_PROMPT);
|
||||
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
@@ -349,37 +467,139 @@ test("empty agent body persists the collect_spoken prompt as a plain assistant m
|
||||
assert.equal(turn?.args.p_user_message, null);
|
||||
assert.equal(turn?.args.p_status, "completed");
|
||||
|
||||
const skipped = await persistEmptyCollectSpokenAssistant({
|
||||
const appended = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
answerText: RECORDED_BODY,
|
||||
previousFocusId: null,
|
||||
});
|
||||
assert.equal(appended, `\n\n${RELATIONSHIP_PROMPT}`);
|
||||
const turns = accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(turns.length, 2);
|
||||
assert.equal(turns[1]?.args.p_assistant_message, RELATIONSHIP_PROMPT);
|
||||
assert.match(String(turns[1]?.args.p_assistant_message), /还记得别的带年份的感情变化吗/);
|
||||
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /请点选/);
|
||||
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /focusId|current_question|choice_frame/);
|
||||
});
|
||||
|
||||
test("the same open collect focus is not appended again on the next turn", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
});
|
||||
const skipped = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: FALLBACK_REQUEST_ID,
|
||||
answerText: "记下了。你长期做什么工作?",
|
||||
answerText: RECORDED_BODY,
|
||||
previousFocusId: FOCUS_ID,
|
||||
});
|
||||
assert.equal(skipped, null);
|
||||
assert.equal(
|
||||
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("choice focus never copies the stem into the assistant body", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => choiceFocusDossier(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
});
|
||||
const skipped = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: FALLBACK_REQUEST_ID,
|
||||
answerText: "记下了。",
|
||||
previousFocusId: null,
|
||||
});
|
||||
assert.equal(skipped, null);
|
||||
assert.equal(
|
||||
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("in-turn persistPlanFocus and idle persist share one collect stem and emit once", async () => {
|
||||
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const idle = answerChoice.slice(
|
||||
answerChoice.indexOf("export async function persistNextInterviewIfIdle"),
|
||||
answerChoice.indexOf("export async function persistCollectSpokenAssistantIfNew"),
|
||||
);
|
||||
assert.match(idle, /if \(dossier\.conversationSummary\.activeFocus\)/);
|
||||
assert.match(idle, /hostNarration:\s*nextInterview\.hostNarration/);
|
||||
assert.match(answerChoice, /spokenFollowupForUser\(followup\)/);
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
||||
append_agentic_rectification_turn: (_fn, args) => ({
|
||||
turn_id: TURN_ID,
|
||||
assistant_message: args.p_assistant_message,
|
||||
}),
|
||||
});
|
||||
const first = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: FALLBACK_REQUEST_ID,
|
||||
answerText: RECORDED_BODY,
|
||||
previousFocusId: null,
|
||||
hostNarration: spokenFollowupForUser(collectFollowup()),
|
||||
});
|
||||
assert.equal(first, `\n\n${RELATIONSHIP_PROMPT}`);
|
||||
const second = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
answerText: RECORDED_BODY,
|
||||
previousFocusId: null,
|
||||
alreadyEmitted: true,
|
||||
hostNarration: spokenFollowupForUser(collectFollowup()),
|
||||
});
|
||||
assert.equal(second, null);
|
||||
assert.equal(
|
||||
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("agent route emits the collect prompt only when the body is empty", () => {
|
||||
test("agent route appends a new collect_spoken stem from focus lifecycle, not body text", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
|
||||
assert.match(afterRun, /persistNextInterviewIfIdle/);
|
||||
assert.match(afterRun, /persistEmptyCollectSpokenAssistant/);
|
||||
assert.match(afterRun, /persistCollectSpokenAssistantIfNew/);
|
||||
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
|
||||
assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistEmptyCollectSpokenAssistant"));
|
||||
assert.match(afterRun, /if \(!result\.answerText\.trim\(\)\)/);
|
||||
assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistCollectSpokenAssistantIfNew"));
|
||||
assert.match(afterRun, /previousFocusId:\s*result\.previousFocusId/);
|
||||
assert.match(afterRun, /alreadyEmitted:\s*result\.collectSpokenEmitted/);
|
||||
assert.match(afterRun, /hostNarration:\s*idleHostNarration/);
|
||||
assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/);
|
||||
assert.doesNotMatch(afterRun, /if \(!result\.answerText\.trim\(\)\)/);
|
||||
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
|
||||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
assert.match(agentRun, /collectSpokenPromptForNewFocus/);
|
||||
assert.match(agentRun, /composeCollectSpokenAssistantText/);
|
||||
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
|
||||
});
|
||||
|
||||
test("empty stream with an open collect_spoken focus uses the focus prompt as the assistant message", async () => {
|
||||
async function runCollectTurn(input: {
|
||||
firstDossier: unknown;
|
||||
laterDossier: unknown;
|
||||
spoken?: string;
|
||||
}) {
|
||||
const emitted: Array<{ type: string; text?: string }> = [];
|
||||
let loads = 0;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
||||
get_agentic_rectification_case_dossier: () => {
|
||||
loads += 1;
|
||||
return loads === 1 ? input.firstDossier : input.laterDossier;
|
||||
},
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||||
});
|
||||
@@ -389,7 +609,7 @@ test("empty stream with an open collect_spoken focus uses the focus prompt as th
|
||||
sessionId: "22222222-2222-4222-8222-222222222222",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
action: "evidence",
|
||||
message: "2016年9月离开家去北京工作",
|
||||
message: "2022年搬家",
|
||||
modelName: "gpt-4o-mini",
|
||||
accounting: accounting.client,
|
||||
billing: {
|
||||
@@ -406,6 +626,9 @@ test("empty stream with an open collect_spoken focus uses the focus prompt as th
|
||||
yield { type: "tool-result", payload: { toolName: "skill" } };
|
||||
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
|
||||
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
|
||||
if (input.spoken) {
|
||||
yield { type: "text-delta", payload: { text: input.spoken } };
|
||||
}
|
||||
yield { type: "finish" };
|
||||
})(),
|
||||
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }),
|
||||
@@ -413,10 +636,63 @@ test("empty stream with an open collect_spoken focus uses the focus prompt as th
|
||||
getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }),
|
||||
}) as never,
|
||||
});
|
||||
return { result, emitted };
|
||||
}
|
||||
|
||||
test("free-text 2022 relocation creates a new collect_spoken stem in the assistant body", async () => {
|
||||
const { result, emitted } = await runCollectTurn({
|
||||
firstDossier: relocation2022Dossier(null),
|
||||
laterDossier: relocation2022Dossier(collectFocus()),
|
||||
spoken: RECORDED_BODY,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.previousFocusId, null);
|
||||
assert.equal(result.collectSpokenEmitted, true);
|
||||
assert.equal(result.answerText, `${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`);
|
||||
assert.match(result.answerText, /还记得别的带年份的感情变化吗/);
|
||||
const deltas = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text);
|
||||
assert.ok(deltas.includes(RECORDED_BODY));
|
||||
assert.ok(deltas.includes(`\n\n${RELATIONSHIP_PROMPT}`));
|
||||
assert.doesNotMatch(result.answerText, /请点选/);
|
||||
assert.doesNotMatch(result.answerText, /focusId|current_question|choice_frame/);
|
||||
});
|
||||
|
||||
test("empty stream with a newly created collect_spoken focus uses the focus prompt as the assistant message", async () => {
|
||||
const { result, emitted } = await runCollectTurn({
|
||||
firstDossier: relocation2022Dossier(null),
|
||||
laterDossier: relocation2022Dossier(collectFocus()),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.answerText, RELATIONSHIP_PROMPT);
|
||||
assert.equal(result.collectSpokenEmitted, true);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: RELATIONSHIP_PROMPT }],
|
||||
);
|
||||
});
|
||||
|
||||
test("an already open collect_spoken focus is not appended again after a later turn", async () => {
|
||||
const { result } = await runCollectTurn({
|
||||
firstDossier: collectFocusDossier(),
|
||||
laterDossier: collectFocusDossier(),
|
||||
spoken: RECORDED_BODY,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.previousFocusId, FOCUS_ID);
|
||||
assert.equal(result.collectSpokenEmitted, false);
|
||||
assert.equal(result.answerText, RECORDED_BODY);
|
||||
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
|
||||
});
|
||||
|
||||
test("choice focus leaves the stem on the card and out of the body", async () => {
|
||||
const { result } = await runCollectTurn({
|
||||
firstDossier: choiceFocusDossier(),
|
||||
laterDossier: choiceFocusDossier(),
|
||||
spoken: RECORDED_BODY,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.collectSpokenEmitted, false);
|
||||
assert.doesNotMatch(result.answerText, /升学结果或学习环境/);
|
||||
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
|
||||
assert.doesNotMatch(result.answerText, /请点选/);
|
||||
});
|
||||
|
||||
@@ -143,6 +143,7 @@ export function conversationSummaryFixture(overrides: {
|
||||
|
||||
export function activeFocusFixture(overrides: {
|
||||
id?: string;
|
||||
questionId?: string;
|
||||
targetEvidenceId?: string | null;
|
||||
intent?: string;
|
||||
targetDomain?: string | null;
|
||||
@@ -152,7 +153,7 @@ export function activeFocusFixture(overrides: {
|
||||
return {
|
||||
id: overrides.id ?? FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: "question-1",
|
||||
question_id: overrides.questionId ?? "question-1",
|
||||
intent: overrides.intent ?? "confirm_revision",
|
||||
target_evidence_id: overrides.targetEvidenceId ?? null,
|
||||
target_domain: overrides.targetDomain ?? "career",
|
||||
|
||||
Reference in New Issue
Block a user