fix(rectification): separate question slots from model prose
This commit is contained in:
@@ -2942,6 +2942,30 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
|
||||
.rectification-question-slot {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
.rectification-question-slot__spoken {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-question-slot__prompt {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rectification-question-slot__hint,
|
||||
.rectification-question-slot__status {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-snapshot {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||||
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
|
||||
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
|
||||
import {
|
||||
isPersistedFocusId,
|
||||
parseRectificationChoiceCard,
|
||||
@@ -95,6 +95,23 @@ type PersistedTurn = Readonly<{
|
||||
}>;
|
||||
|
||||
type CandidateResult = RectificationCandidateResult | null;
|
||||
type CurrentQuestionModel = Readonly<{
|
||||
kind: "choice" | "collect_spoken";
|
||||
prompt: 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 };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function RectificationCandidateCards({
|
||||
result,
|
||||
@@ -177,10 +194,6 @@ type RenderMessage = ChatMessageView & {
|
||||
choiceAttachment?: SettledChoiceAttachment;
|
||||
};
|
||||
|
||||
function turnOfferedSelection(message: RenderMessage): boolean {
|
||||
return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates"));
|
||||
}
|
||||
|
||||
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
|
||||
if (!receipt) return { steps: [], methods: [] };
|
||||
if (Array.isArray(receipt.tool_activities)) {
|
||||
@@ -232,11 +245,10 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
const raw = failed ? "" : turn.text ?? "";
|
||||
if (failed && !raw) return [];
|
||||
if (isIncompleteRunBanner(raw)) return [];
|
||||
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
|
||||
const completedReceipt = completedReceiptFromPersisted(turn.receipt);
|
||||
return [{
|
||||
role: "assistant",
|
||||
text: split.spoken,
|
||||
text: raw,
|
||||
renderKey: key,
|
||||
state: turn.status === "completed" || failed ? "settled" : "thinking",
|
||||
completedReceipt,
|
||||
@@ -286,6 +298,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
|
||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
@@ -309,6 +324,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||||
const boardId = useId();
|
||||
const boardTitleId = useId();
|
||||
const questionHintId = useId();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
|
||||
@@ -387,16 +403,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
// from parsing agent text or hidden sentinels.
|
||||
const applyCaseSnapshot = useCallback((payload: {
|
||||
latest_result?: unknown;
|
||||
current_question?: unknown;
|
||||
choice_card?: unknown;
|
||||
case?: { accepted_time?: unknown; confirmed_time?: unknown };
|
||||
case?: {
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
};
|
||||
} | null) => {
|
||||
if (!payload) return;
|
||||
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
|
||||
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
|
||||
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
|
||||
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
|
||||
? payload.case.status
|
||||
: null;
|
||||
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
setCaseSnapshotLoaded(true);
|
||||
if (confirmedTime) {
|
||||
setSavedTime(confirmedTime);
|
||||
setSavedStatus("confirmed");
|
||||
@@ -1025,19 +1053,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? [message.choiceAttachment.card.question_id]
|
||||
: []),
|
||||
);
|
||||
const liveChoiceHost = [...messages]
|
||||
.reverse()
|
||||
.find((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text)
|
||||
&& !message.choiceAttachment
|
||||
));
|
||||
const showLiveChoiceCard = Boolean(
|
||||
choiceCard
|
||||
&& liveChoiceHost
|
||||
currentQuestion?.kind === "choice"
|
||||
&& choiceCard
|
||||
&& !answeredQuestionIds.has(choiceCard.question_id)
|
||||
&& !turnOfferedSelection(liveChoiceHost)
|
||||
&& !busy
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null,
|
||||
@@ -1058,9 +1077,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
|
||||
? latestSettledAssistant.renderKey
|
||||
: undefined;
|
||||
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
|
||||
? liveChoiceHost.renderKey
|
||||
: undefined;
|
||||
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
|
||||
? currentQuestion.prompt
|
||||
: null;
|
||||
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion === null,
|
||||
);
|
||||
const showUnavailableQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion !== null
|
||||
&& ((currentQuestion.kind === "choice" && !choiceCard)
|
||||
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
|
||||
);
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
function submitChoice(key: ChoiceKey) {
|
||||
@@ -1125,10 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
: message;
|
||||
const settledChoice = message.choiceAttachment;
|
||||
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
|
||||
? choiceCard
|
||||
: null;
|
||||
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
|
||||
const visibleChoiceCard = settledChoice?.card;
|
||||
const vargaSentence = !message.failed
|
||||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||||
: null;
|
||||
@@ -1173,7 +1206,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
|
||||
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
|
||||
card={visibleChoiceCard}
|
||||
pending={Boolean(liveChoice) && busy}
|
||||
pending={false}
|
||||
disabled={readonly || Boolean(settledChoice)}
|
||||
selectedKey={settledChoice?.selectedKey ?? ""}
|
||||
onSelect={submitChoice}
|
||||
@@ -1183,6 +1216,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{collectSpokenPrompt && (
|
||||
<div className="rectification-question-slot__spoken">
|
||||
<p className="rectification-question-slot__prompt">{collectSpokenPrompt}</p>
|
||||
<p id={questionHintId} className="rectification-question-slot__hint" role="note">
|
||||
请在下方输入框回答,可以写你记得的年份、经过和结果。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
@@ -1221,13 +1285,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<Textarea
|
||||
ref={composer}
|
||||
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
aria-describedby={collectSpokenPrompt ? questionHintId : undefined}
|
||||
value={draft}
|
||||
disabled={!canSend}
|
||||
placeholder={readonly
|
||||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||||
: showLiveChoiceCard
|
||||
? "点上面的选项即可;想补一句细节再写"
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
: collectSpokenPrompt
|
||||
? "请回答上面的问题…"
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
|
||||
@@ -233,7 +233,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
|
||||
let collectSpokenEmitted = false;
|
||||
const collectSpokenEmitted = false;
|
||||
if (dossier.case.sessionId !== sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
}
|
||||
|
||||
@@ -48,12 +48,7 @@ import {
|
||||
spokenFollowupForUser,
|
||||
} from "./method-followup";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate";
|
||||
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
|
||||
import {
|
||||
collectSpokenPromptForNewFocus,
|
||||
composeCollectSpokenAssistantText,
|
||||
projectCurrentQuestion,
|
||||
} from "./turn-decision";
|
||||
import { projectCurrentQuestion } from "./turn-decision";
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
@@ -598,55 +593,10 @@ async function persistExhaustionCollect(input: {
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: spoken
|
||||
? composeCollectSpokenAssistantText(range, spoken).composed
|
||||
: range,
|
||||
hostNarration: range,
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
return persistCollectSpokenAssistantIfNew(input);
|
||||
}
|
||||
|
||||
async function persistApplied(
|
||||
accounting: AccountingClient,
|
||||
command: ApplyChoiceCommand,
|
||||
|
||||
@@ -1114,6 +1114,9 @@ export function decideConversationalSession(input: {
|
||||
holdoutValidation?: HoldoutValidationStatus;
|
||||
snapshotCurrent?: boolean;
|
||||
accepted?: boolean;
|
||||
inferenceRounds?: number;
|
||||
effectiveAnswerCount?: number;
|
||||
plateauRounds?: number;
|
||||
}): ReturnType<typeof decideRectification> {
|
||||
const coverageOpen = Boolean(
|
||||
input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"),
|
||||
@@ -1135,6 +1138,9 @@ export function decideConversationalSession(input: {
|
||||
engineAcceptAllowed: input.selectionAllowed,
|
||||
engineProposeAllowed: input.proposeAllowed,
|
||||
datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods) : undefined,
|
||||
inferenceRounds: input.inferenceRounds,
|
||||
effectiveAnswerCount: input.effectiveAnswerCount,
|
||||
plateauRounds: input.plateauRounds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Hydrate recovery for old Turns that stored process talk in
|
||||
* `assistant_message`. Live `answer.delta` is the model text as-is; the
|
||||
* runner and chat must not call these helpers on the live stream.
|
||||
*/
|
||||
|
||||
import {
|
||||
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
|
||||
RECTIFICATION_TOOL_DONE_LABELS,
|
||||
} from "../../rectification-activity-labels.ts";
|
||||
|
||||
const CJK_RE = /[\u4e00-\u9fff]/;
|
||||
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|current_question|current_probe|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast(?:_packet)?|choice_frame|deferred_followup|resolve-focus|questionId|active[\s_-]?focus)\b/i;
|
||||
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用|看看|自然)|我(?:决定|倾向|batch|需要用|需要继续|需要考虑|继续收集|继续访谈|继续自然|自然地|用自然语言|应该|认为|可以尝试|不能把|直接自然|确认理解|先向用户)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup|根据规则|规则要求|不调用工具|严格来说|实际上规则|也许我应该|当前探针|这回应的是当前探针|账本|草稿|quote 路径|写入需要日期精度|没有具体日期|先确认草稿|纠缠草稿|自然访谈|待确认状态/;
|
||||
const THIRD_PERSON_USER_RE = /用户/;
|
||||
const AGENT_SELF_RE = /我(?:应该|认为|可以|先|需要|不能|直接)|让我|也许我/;
|
||||
const ADDRESSES_USER_RE = /你|您|记下了|已经记下|已记录了|已收到|不用急|别担心|哪一年|有没有|哪件|大概年份|对吗|是不是|请你/;
|
||||
|
||||
const ACTIVITY_ECHO_LABELS = [
|
||||
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
|
||||
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
|
||||
];
|
||||
|
||||
export type SplitSpokenAndThinking = Readonly<{
|
||||
thinking: string;
|
||||
spoken: string;
|
||||
}>;
|
||||
|
||||
function isActivityEcho(text: string): boolean {
|
||||
const trimmed = text.trim().replace(/[。.…]+$/u, "");
|
||||
if (!trimmed) return false;
|
||||
return ACTIVITY_ECHO_LABELS.some((label) => {
|
||||
const bare = label.replace(/[。.…]+$/u, "");
|
||||
return trimmed === bare || trimmed === `正在${bare}`;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true;
|
||||
if (isActivityEcho(trimmed)) return true;
|
||||
if (INTERNAL_TOKEN_RE.test(trimmed)) return true;
|
||||
if (PROCESS_ZH_RE.test(trimmed)) return true;
|
||||
if (THIRD_PERSON_USER_RE.test(trimmed)) return true;
|
||||
if (AGENT_SELF_RE.test(trimmed) && !ADDRESSES_USER_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function nextStableChannelDelta(published: string, next: string): string {
|
||||
if (!next.startsWith(published)) return "";
|
||||
return next.slice(published.length);
|
||||
}
|
||||
|
||||
function splitSentences(text: string): string[] {
|
||||
const parts = text.split(/(?<=[。!?])\s*/u).map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : [text];
|
||||
}
|
||||
|
||||
function splitUnits(text: string): string[] {
|
||||
const lines = text.split(/\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length > 1) return lines.flatMap(splitSentences);
|
||||
return splitSentences(text);
|
||||
}
|
||||
|
||||
function isMixed(units: readonly string[]): boolean {
|
||||
return units.length > 1
|
||||
&& units.some(isRectificationProcessNarration)
|
||||
&& units.some((unit) => !isRectificationProcessNarration(unit));
|
||||
}
|
||||
|
||||
function splitParagraphs(text: string): string[] {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.flatMap((block) => {
|
||||
const trimmed = block.trim();
|
||||
if (!trimmed) return [];
|
||||
const units = splitUnits(trimmed);
|
||||
return isMixed(units) ? units : [trimmed];
|
||||
});
|
||||
}
|
||||
|
||||
export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
|
||||
const paragraphs = splitParagraphs(text);
|
||||
if (paragraphs.length === 0) return { thinking: "", spoken: text };
|
||||
|
||||
const thinking: string[] = [];
|
||||
const spoken: string[] = [];
|
||||
for (const paragraph of paragraphs) {
|
||||
if (isRectificationProcessNarration(paragraph)) thinking.push(paragraph);
|
||||
else spoken.push(paragraph);
|
||||
}
|
||||
|
||||
if (thinking.length === 0) return { thinking: "", spoken: text };
|
||||
const userFacing: string[] = [];
|
||||
for (const paragraph of spoken) {
|
||||
if (ADDRESSES_USER_RE.test(paragraph) || /[??]/.test(paragraph) || paragraph.length > 60) {
|
||||
userFacing.push(paragraph);
|
||||
} else {
|
||||
thinking.push(paragraph);
|
||||
}
|
||||
}
|
||||
return {
|
||||
thinking: thinking.join("\n\n"),
|
||||
spoken: userFacing.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
|
||||
const split = splitRectificationSpokenAndThinking(text);
|
||||
if (split.spoken.trim()) return split;
|
||||
if (!split.thinking.trim()) return { thinking: "", spoken: text };
|
||||
return { thinking: split.thinking, spoken: "" };
|
||||
}
|
||||
|
||||
export function settleRectificationSpokenAndThinking(
|
||||
answerRaw: string,
|
||||
thinkingRaw = "",
|
||||
): SplitSpokenAndThinking {
|
||||
const leak = splitRectificationSpokenAndThinking(answerRaw);
|
||||
const channelThinking = thinkingRaw.trim();
|
||||
const spoken = leak.spoken.trim();
|
||||
if (channelThinking) return { thinking: channelThinking, spoken };
|
||||
if (!spoken && leak.thinking.trim()) return { thinking: leak.thinking, spoken: "" };
|
||||
return { thinking: leak.thinking, spoken: spoken || (!leak.thinking ? answerRaw.trim() : "") };
|
||||
}
|
||||
@@ -67,46 +67,6 @@ function looksLikeChoiceSchema(schema: Readonly<Record<string, unknown>> | null
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyAnswerCollectSpokenFallback(
|
||||
answerText: string,
|
||||
question: Pick<CurrentQuestionProjection, "kind" | "prompt"> | null | undefined,
|
||||
): string | null {
|
||||
if (answerText.trim()) return null;
|
||||
if (question?.kind !== "collect_spoken") return null;
|
||||
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
|
||||
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;
|
||||
|
||||
@@ -83,10 +83,9 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
|
||||
test("persisted rectification turns hydrate after the async Case refresh", () => {
|
||||
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
|
||||
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
|
||||
assert.match(chat, /finalizeRectificationSpokenAndThinking/);
|
||||
assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /text: split\.spoken,/);
|
||||
assert.doesNotMatch(chat, /split\.spoken \|\| raw/);
|
||||
assert.doesNotMatch(chat, /spoken-answer/);
|
||||
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
@@ -248,10 +247,8 @@ test("usage completes or releases without hiding settlement failures", () => {
|
||||
assert.match(run, /answerTokens: 8_192/);
|
||||
assert.match(run, /thinkingTokens: 8_192/);
|
||||
assert.match(run, /activity.changed/);
|
||||
assert.match(run, /if \(!answerText\.trim\(\)\) \{[\s\S]*composeRectificationTurnNarration/);
|
||||
assert.match(run, /bindSpokenToOpenQuestion/);
|
||||
assert.match(run, /CHOICE_CARD_CONTINUATION_ACK/);
|
||||
assert.match(run, /openQuestionPromptFromToolResult/);
|
||||
assert.match(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
|
||||
assert.doesNotMatch(run, /bindSpokenToOpenQuestion|CHOICE_CARD_CONTINUATION_ACK|openQuestionPromptFromToolResult/);
|
||||
assert.doesNotMatch(run, /heldSpoken/);
|
||||
assert.match(run, /replace: true/);
|
||||
const narration = readFileSync(
|
||||
@@ -540,9 +537,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, /turnOfferedSelection/);
|
||||
assert.match(chat, /rectification-offer-candidates/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\(\s*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*currentQuestion\?\.kind === "choice"[\s\S]*choiceCard[\s\S]*answeredQuestionIds[\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")),
|
||||
@@ -637,7 +632,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, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
|
||||
assert.match(agent, /有持久化 current_question 时,题干与选项完全由结构化槽位和 UI 承担/);
|
||||
assert.match(agent, /正文不得提问、复述、改写或拼接题干/);
|
||||
});
|
||||
|
||||
test("clear current-turn events go through the batch evidence service", () => {
|
||||
|
||||
@@ -632,7 +632,7 @@ test("clicking A applies the choice without invoking a language model", async ()
|
||||
});
|
||||
assert.match(applied.narration, /已记录你的选择/);
|
||||
assert.match(applied.narration, /当前可信区间/);
|
||||
assert.match(applied.narration, /家里有没有结婚、添丁或住院/);
|
||||
assert.doesNotMatch(applied.narration, /家里有没有结婚、添丁或住院/);
|
||||
const fns = accounting.calls.map((call) => call.fn);
|
||||
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
|
||||
assert.equal(fns.includes("append_agentic_rectification_turn"), true);
|
||||
|
||||
@@ -565,7 +565,7 @@ test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric
|
||||
assert.match(idle.hostNarration ?? "", /04:47–04:53/);
|
||||
assert.match(idle.hostNarration ?? "", /04:51/);
|
||||
assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
|
||||
assert.match(idle.hostNarration ?? "", /升学|转学|考试/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
|
||||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
|
||||
|
||||
@@ -1,210 +1,37 @@
|
||||
/**
|
||||
* These helpers recover old leaked Turns on hydrate. They are not the live
|
||||
* spoken-answer classifier; `runV9AgentTurn` must not call them.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
finalizeRectificationSpokenAndThinking,
|
||||
isRectificationProcessNarration,
|
||||
nextStableChannelDelta,
|
||||
settleRectificationSpokenAndThinking,
|
||||
splitRectificationSpokenAndThinking,
|
||||
} from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const agentRun = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("a plain spoken follow-up stays on the answer channel", () => {
|
||||
const spoken = [
|
||||
"记下了,大约六岁入学小学。",
|
||||
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
|
||||
].join("\n\n");
|
||||
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(spoken), {
|
||||
thinking: "",
|
||||
spoken,
|
||||
});
|
||||
test("live and persisted assistant text stay verbatim after spoken-answer removal", () => {
|
||||
assert.match(chat, /const raw = failed \? "" : turn\.text \?\? ""/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.doesNotMatch(chat, /spoken-answer/);
|
||||
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(agentRun, /answerText = visible;/);
|
||||
assert.doesNotMatch(agentRun, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
});
|
||||
|
||||
test("process self-talk leaves the spoken conclusion on the answer channel", () => {
|
||||
const processTalk = [
|
||||
"用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。",
|
||||
"但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year,occurredFrom 只能按用户原话来。",
|
||||
"我决定先按六岁入学记下来,再在正文里确认那句晚年份是不是口误。",
|
||||
"让我调用 batch 写入 education_start。",
|
||||
].join("\n\n");
|
||||
const spoken = [
|
||||
"入学小学这条先按大约六岁记下。你第一句提到的那个很晚的年份,我理解是口误对吗?",
|
||||
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
|
||||
].join("\n\n");
|
||||
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`), {
|
||||
thinking: processTalk,
|
||||
spoken,
|
||||
});
|
||||
test("structured current_question, not model prose, owns the visible question slot", () => {
|
||||
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.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
|
||||
assert.match(chat, /<RectificationChoiceCard/);
|
||||
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
|
||||
});
|
||||
|
||||
test("English process talk is classified as thinking, not the spoken answer", () => {
|
||||
const split = splitRectificationSpokenAndThinking([
|
||||
"The proposedKind value was rejected. Retrying with education_start.",
|
||||
"记下了,那年九月上大学。",
|
||||
].join("\n\n"));
|
||||
|
||||
assert.match(split.thinking, /proposedKind/);
|
||||
assert.equal(split.spoken, "记下了,那年九月上大学。");
|
||||
});
|
||||
|
||||
test("stable channel deltas only emit the newly classified suffix", () => {
|
||||
assert.equal(nextStableChannelDelta("", "先核对升学年份。"), "先核对升学年份。");
|
||||
assert.equal(
|
||||
nextStableChannelDelta("先核对升学年份。", "先核对升学年份。\n\n再问初中。"),
|
||||
"\n\n再问初中。",
|
||||
);
|
||||
assert.equal(nextStableChannelDelta("先核对升学年份。", "另一段"), "");
|
||||
});
|
||||
|
||||
test("a lone process paragraph stays in thinking until a spoken conclusion arrives", () => {
|
||||
const processTalk = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n`), {
|
||||
thinking: processTalk,
|
||||
spoken: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("truncated third-person batch-tool narration stays in thinking", () => {
|
||||
const truncated = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
|
||||
assert.equal(isRectificationProcessNarration(truncated), true);
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(truncated), {
|
||||
thinking: truncated,
|
||||
spoken: "",
|
||||
});
|
||||
assert.deepEqual(finalizeRectificationSpokenAndThinking(truncated), {
|
||||
thinking: truncated,
|
||||
spoken: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("finalizing does not promote process-only self-talk into the spoken answer", () => {
|
||||
const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
|
||||
const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
|
||||
const processTalk = `${first}\n\n${last}`;
|
||||
assert.deepEqual(finalizeRectificationSpokenAndThinking(processTalk), {
|
||||
thinking: processTalk,
|
||||
spoken: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("Chinese interview planning after tools stays out of the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"读取校正记录",
|
||||
"整理多条事件证据",
|
||||
"比较候选时间",
|
||||
"职业类型已经记录,并重新计算了候选。虽然职业领域现在有信息了,但账户里仍然只有 3 件带评分日期的事件,还不足以拉开候选范围区间(当前不可分宽度 8 分钟)。",
|
||||
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分),但还需要更多带日期的经历来区分候选。当前还应继续收集事件。",
|
||||
"我用自然语言再问一件带大概年份的经历。根据 method_followup_plan,职业已经覆盖。当前 open_question 为空,next_action 为空。",
|
||||
"候选区分尚未充分(not_separated),应继续收集。",
|
||||
"让我继续访谈,问一件能帮助区分候选的职业前事。",
|
||||
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
|
||||
].join("\n\n");
|
||||
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**又是什么时候离开的?只要个大概年份就行。";
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
|
||||
assert.equal(split.spoken, spoken);
|
||||
assert.match(split.thinking, /occupation_note/);
|
||||
assert.match(split.thinking, /读取校正记录/);
|
||||
assert.match(split.thinking, /本轮对照了/);
|
||||
assert.doesNotMatch(split.thinking, /你大概是在哪一年入职/);
|
||||
assert.equal(isRectificationProcessNarration("读取校正记录"), true);
|
||||
assert.equal(isRectificationProcessNarration(spoken), false);
|
||||
});
|
||||
|
||||
test("planning about candidate_contrast_packet stays out of the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"这意味着:方法资料已齐,还不能出牌。",
|
||||
"服务器给了 candidate_contrast_packet,choice_frame 已写好。",
|
||||
"第 7 条边界:id=ask_candidate_discriminator 时不得 offer。",
|
||||
].join("\n\n");
|
||||
const spoken = "那次高考或重要考试,发挥有没有明显失常、压力很大?说有或没有就行。";
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
|
||||
assert.equal(split.spoken, spoken);
|
||||
assert.match(split.thinking, /这意味着/);
|
||||
assert.match(split.thinking, /candidate_contrast_packet/);
|
||||
assert.match(split.thinking, /不得 offer/);
|
||||
assert.match(split.thinking, /第 7 条边界/);
|
||||
assert.doesNotMatch(split.thinking, /发挥有没有明显失常/);
|
||||
assert.equal(isRectificationProcessNarration("这意味着:方法资料已齐"), true);
|
||||
assert.equal(isRectificationProcessNarration("服务器给了 candidate_contrast_packet"), true);
|
||||
assert.equal(isRectificationProcessNarration(spoken), false);
|
||||
|
||||
const settled = settleRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`, "");
|
||||
assert.equal(settled.spoken, spoken);
|
||||
assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/);
|
||||
});
|
||||
|
||||
test("draft-ledger planning stays out of the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"第二件离职事件已记录为草稿。现在账本有两件已确认加一件草稿。",
|
||||
"既然实习开始和实习离职都属于同一段职业经历并且相关联,用户后面的消息会继续补充,我先向用户确认这件离职信息,同时继续收集。",
|
||||
"由于离职是事务性的独立事件且 quote 路径有障碍,我继续自然访谈。",
|
||||
"先确认草稿再继续收集下一件。",
|
||||
"这里草稿是服务器接受但待确认状态。",
|
||||
"我应该继续访谈而不是纠缠草稿确认。",
|
||||
].join("\n\n");
|
||||
const spoken = [
|
||||
"好的,已经记下你这段职业经历:那年春天开始实习、秋天离职。加上之前上学,目前有了比较清晰的两大块时间线。",
|
||||
"不用急着回忆全部。毕业后正式入职的第一份工作,大概哪一年?有没有恋爱、结婚这样的关系节点?哪一件印象深就先说哪件,大概年份就好。",
|
||||
].join("\n\n");
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
|
||||
assert.equal(split.spoken, spoken);
|
||||
assert.match(split.thinking, /账本/);
|
||||
assert.match(split.thinking, /我应该继续访谈/);
|
||||
assert.match(split.thinking, /quote 路径/);
|
||||
assert.doesNotMatch(split.spoken, /账本/);
|
||||
assert.doesNotMatch(split.spoken, /我应该/);
|
||||
assert.doesNotMatch(split.spoken, /草稿是服务器/);
|
||||
assert.equal(isRectificationProcessNarration("我应该继续访谈而不是纠缠草稿确认。"), true);
|
||||
assert.equal(isRectificationProcessNarration(spoken.split("\n\n")[0]!), false);
|
||||
});
|
||||
|
||||
test("probe-deliberation with no user-facing close stays off the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"这回应的是当前探针(感情前事,用于对照 D9 差异)。",
|
||||
"当前探针是请核对一段还没用进评分的感情前事。用户给出的是一个主观感受,不是具体带日期的事件。",
|
||||
"让我看看是否应该把它作为一条证据记录。",
|
||||
"严格来说,用户没有给出有确定日期的新事件。根据规则,没有具体带日期事件就按 focus 处理。",
|
||||
"current_question 为 null,但 current_probe 有一条。",
|
||||
"也许我应该直接自然回应,不调用工具。规则要求承接。",
|
||||
"我认为这里不需要写证据。写入需要日期精度。",
|
||||
"我直接自然问。",
|
||||
].join("\n\n");
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(processTalk);
|
||||
assert.equal(split.spoken, "");
|
||||
assert.match(split.thinking, /当前探针/);
|
||||
assert.match(split.thinking, /current_question/);
|
||||
assert.match(split.thinking, /我直接自然问/);
|
||||
assert.equal(finalizeRectificationSpokenAndThinking(processTalk).spoken, "");
|
||||
|
||||
const settled = settleRectificationSpokenAndThinking(processTalk, "");
|
||||
assert.equal(settled.spoken, "");
|
||||
assert.doesNotMatch(settled.spoken, /探针/);
|
||||
assert.doesNotMatch(settled.spoken, /根据规则/);
|
||||
});
|
||||
|
||||
test("leaked process text on the answer channel is not mixed into native thinking", () => {
|
||||
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
|
||||
const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";
|
||||
assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, ""), {
|
||||
thinking: processTalk,
|
||||
spoken: "",
|
||||
});
|
||||
assert.deepEqual(settleRectificationSpokenAndThinking(spoken, processTalk), {
|
||||
thinking: processTalk,
|
||||
spoken,
|
||||
});
|
||||
assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, processTalk), {
|
||||
thinking: processTalk,
|
||||
spoken: "",
|
||||
});
|
||||
test("answer deltas expose the model text without a parser or regex cleanup", () => {
|
||||
assert.match(chat, /raw = event\.replace === true \? event\.text : raw \+ event\.text/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
});
|
||||
|
||||
@@ -9,14 +9,10 @@ import {
|
||||
persistServerOwnedFocus,
|
||||
stableFollowupQuestionId,
|
||||
} from "../src/lib/rectification-agentic/v9/server-focus.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 { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.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,
|
||||
@@ -28,7 +24,6 @@ import {
|
||||
conversationSummaryFixture,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
|
||||
@@ -92,26 +87,44 @@ test("skill version stays 10.0.13 for the spoken-collect visibility fix", () =>
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
|
||||
});
|
||||
|
||||
test("GET collect_spoken is not rendered as a standalone chat block", () => {
|
||||
test("cases current_question drives the unified question slot", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const choiceCard = readFileSync(new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(chat, /parseRectificationSpokenCollect/);
|
||||
assert.doesNotMatch(chat, /showLiveSpokenCollect/);
|
||||
assert.doesNotMatch(chat, /spokenCollect/);
|
||||
assert.doesNotMatch(chat, /liveSpoken/);
|
||||
assert.doesNotMatch(chat, /rectification-spoken-collect-prompt/);
|
||||
assert.doesNotMatch(chat, /aria-label="口述采集题"/);
|
||||
assert.doesNotMatch(styles, /rectification-spoken-collect-prompt/);
|
||||
assert.doesNotMatch(choiceCard, /parseRectificationSpokenCollect|RectificationSpokenCollect/);
|
||||
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.match(chat, /<RectificationChoiceCard/);
|
||||
assert.match(chat, /onSelect=\{submitChoice\}/);
|
||||
assert.match(chat, /onStop=\{submitStop\}/);
|
||||
assert.doesNotMatch(chat, /finalizeRectificationSpokenAndThinking/);
|
||||
assert.doesNotMatch(chat, /rectification-agentic\/v9\/spoken-answer/);
|
||||
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
|
||||
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
|
||||
});
|
||||
|
||||
test("collect_spoken current_question shows the server prompt and input hint", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
|
||||
assert.match(chat, /const collectSpokenPrompt =/);
|
||||
assert.match(chat, /rectification-question-slot__prompt/);
|
||||
assert.match(chat, /请在下方输入框回答/);
|
||||
assert.match(chat, /aria-describedby=\{collectSpokenPrompt \? questionHintId : undefined\}/);
|
||||
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
|
||||
});
|
||||
|
||||
test("missing current_question is explicit only for resumable cases", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
|
||||
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.match(chat, /readonly && \(/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
|
||||
});
|
||||
|
||||
test("choice_card still renders through the existing choice-card branch", () => {
|
||||
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("{messages.map((message) => {") ,
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
assert.match(render, /showLiveChoiceCard/);
|
||||
@@ -122,25 +135,6 @@ test("choice_card still renders through the existing choice-card branch", () =>
|
||||
assert.doesNotMatch(render, /showLiveSpokenCollect/);
|
||||
});
|
||||
|
||||
test("empty answerText with a collect_spoken focus falls back to the focus prompt; non-empty does not", () => {
|
||||
const collect = {
|
||||
kind: "collect_spoken" as const,
|
||||
prompt: RELATIONSHIP_PROMPT,
|
||||
};
|
||||
assert.equal(emptyAnswerCollectSpokenFallback("", collect), RELATIONSHIP_PROMPT);
|
||||
assert.equal(emptyAnswerCollectSpokenFallback(" ", collect), RELATIONSHIP_PROMPT);
|
||||
assert.equal(emptyAnswerCollectSpokenFallback("记下了。", collect), null);
|
||||
assert.equal(emptyAnswerCollectSpokenFallback("", {
|
||||
kind: "choice",
|
||||
prompt: RELATIONSHIP_PROMPT,
|
||||
}), null);
|
||||
assert.equal(emptyAnswerCollectSpokenFallback("", null), null);
|
||||
assert.equal(emptyAnswerCollectSpokenFallback("", {
|
||||
kind: "collect_spoken",
|
||||
prompt: " ",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
|
||||
const collect = openQuestionFromPersistedFocus(collectPersistResult());
|
||||
assert.equal(collect?.kind, "collect_spoken");
|
||||
@@ -306,18 +300,15 @@ test("agent prompt does not let the model write a persisted question stem", () =
|
||||
agent.indexOf("const agenticRectificationInstructions"),
|
||||
agent.indexOf("export function getRectificationV9Agent"),
|
||||
);
|
||||
assert.match(prompt, /有持久化当前问题时/);
|
||||
assert.match(prompt, /题干一律不由你写/);
|
||||
assert.match(prompt, /collect_spoken[\s\S]*服务器接在正文之后/);
|
||||
assert.match(prompt, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
|
||||
assert.match(prompt, /请点选/);
|
||||
assert.match(prompt, /有持久化 current_question 时/);
|
||||
assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
|
||||
assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/);
|
||||
assert.match(prompt, /没有 current_question 时也不要自拟区分题/);
|
||||
assert.match(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() {
|
||||
@@ -332,367 +323,12 @@ function collectFocus() {
|
||||
});
|
||||
}
|
||||
|
||||
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: "distinguish_candidates",
|
||||
targetDomain: "education",
|
||||
expectedAnswerSchema: {
|
||||
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(),
|
||||
append_agentic_rectification_turn: (_fn, args) => ({
|
||||
turn_id: "99999999-9999-4999-8999-999999999999",
|
||||
idempotent: false,
|
||||
assistant_message: args.p_assistant_message,
|
||||
}),
|
||||
});
|
||||
const filled = await persistEmptyCollectSpokenAssistant({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
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");
|
||||
assert.equal(turn?.args.p_assistant_message, RELATIONSHIP_PROMPT);
|
||||
assert.equal(turn?.args.p_user_message, null);
|
||||
assert.equal(turn?.args.p_status, "completed");
|
||||
|
||||
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: 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 appends a new collect_spoken stem from focus lifecycle, not body text", () => {
|
||||
test("agent route keeps question ownership in the server Case projection", () => {
|
||||
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, /persistCollectSpokenAssistantIfNew/);
|
||||
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
|
||||
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, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
|
||||
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)\(/);
|
||||
});
|
||||
|
||||
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: () => {
|
||||
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 }),
|
||||
});
|
||||
const result = await runV9AgentTurn({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: "22222222-2222-4222-8222-222222222222",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
action: "evidence",
|
||||
message: "2022年搬家",
|
||||
modelName: "gpt-4o-mini",
|
||||
accounting: accounting.client,
|
||||
billing: {
|
||||
reserve: async () => ({ success: true, status: 200 }),
|
||||
complete: async () => true,
|
||||
release: async () => true,
|
||||
},
|
||||
emit: (event) => { emitted.push(event); },
|
||||
buildAgent: async () => ({
|
||||
stream: async () => ({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "start" };
|
||||
yield { type: "tool-call", payload: { toolName: "skill", args: { name: "jyotish-birth-time-rectification" } } };
|
||||
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 }),
|
||||
}),
|
||||
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, /请点选/);
|
||||
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus|composeCollectSpokenAssistantText/);
|
||||
});
|
||||
|
||||
@@ -63,8 +63,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
|
||||
assert.match(prompt, /工具执行保持静默/);
|
||||
assert.match(prompt, /思考用简体中文写在思维链/);
|
||||
assert.match(prompt, /对用户说的话必须自己写在正文里/);
|
||||
assert.match(prompt, /题干和选项只由选择卡展示/);
|
||||
assert.match(prompt, /正文只自然承接/);
|
||||
assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
|
||||
assert.match(prompt, /每轮正文只输出一句确认或承接/);
|
||||
assert.match(prompt, /「先这样」由服务器/);
|
||||
assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/);
|
||||
assert.doesNotMatch(prompt, /财务与健康只有用户主动说才问/);
|
||||
@@ -407,6 +407,8 @@ test("a repeated identical tool call is treated as idempotent and does not abort
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("text-delta", { text: "已经记下。" }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
|
||||
@@ -23,15 +23,6 @@ import {
|
||||
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { messageContentHash } from "../src/lib/rectification-agentic/v9/message-origin.ts";
|
||||
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
bindSpokenToOpenQuestion,
|
||||
openQuestionPromptFromToolResult,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-narration.ts";
|
||||
import {
|
||||
createRectificationActivityReceiptState,
|
||||
receiptFromRectificationActivityState,
|
||||
reduceRectificationActivityReceipt,
|
||||
} from "../src/lib/rectification-activity-receipt.ts";
|
||||
|
||||
type StreamChunk = {
|
||||
type: string;
|
||||
@@ -606,7 +597,7 @@ test("Chinese process self-talk after tools is thinking, not the spoken answer",
|
||||
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
|
||||
});
|
||||
|
||||
test("process-only self-talk after tools is replaced by server narration, not retried", async () => {
|
||||
test("process-only self-talk after tools fails closed instead of becoming the spoken answer", async () => {
|
||||
let buildCount = 0;
|
||||
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
|
||||
const accounting = fakeAccounting({
|
||||
@@ -639,13 +630,16 @@ test("process-only self-talk after tools is replaced by server narration, not re
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(buildCount, 1);
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "empty_stream");
|
||||
assert.equal(result.answerText, "");
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.doesNotMatch(JSON.stringify(emitted), /我需要用批量工具/);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
|
||||
assert.equal(finalizedTurn?.args.p_status, "completed");
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
assert.equal(finalizedTurn?.args.p_status, "failed");
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
});
|
||||
|
||||
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
|
||||
@@ -683,7 +677,7 @@ test("a length-limited spoken answer is not billed or persisted as a completed t
|
||||
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
|
||||
});
|
||||
|
||||
test("length after a stamped open_question still completes without failing the choice card", async () => {
|
||||
test("length after a stamped open_question fails closed without rewriting the answer", async () => {
|
||||
const prompt = "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?";
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
@@ -709,12 +703,16 @@ test("length after a stamped open_question still completes without failing the c
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.equal(result.answerText, "接下来看下面这一问。");
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "answer_truncated");
|
||||
assert.equal(result.answerText, "");
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: "实习和离职的时间点都记下了,谢谢。" }],
|
||||
);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
});
|
||||
|
||||
test("answer deltas and tool activity are published before billing settles", async () => {
|
||||
@@ -818,7 +816,7 @@ test("browser disconnect aborts the run, finalizes retryable and releases usage"
|
||||
assert.equal(billing.released, 1);
|
||||
});
|
||||
|
||||
test("empty stream uses server narration instead of retrying the attempt", async () => {
|
||||
test("empty stream fails closed instead of synthesizing an answer", async () => {
|
||||
const { options, emitted, billing } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
@@ -830,11 +828,13 @@ test("empty stream uses server narration instead of retrying the attempt", async
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(billing.completed, 1);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "empty_stream");
|
||||
assert.equal(result.answerText, "");
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||
});
|
||||
|
||||
test("legacy Skill identity fails before billing reservation", async () => {
|
||||
@@ -1154,7 +1154,7 @@ test("does not retry set-focus with identical arguments", async () => {
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
});
|
||||
|
||||
test("duplicate compare after diagnostics still completes with server narration", async () => {
|
||||
test("duplicate compare after diagnostics fails closed without resetting the attempt", async () => {
|
||||
const executedMethods = [
|
||||
"ashtakavarga",
|
||||
"d1-rashi",
|
||||
@@ -1193,19 +1193,19 @@ test("duplicate compare after diagnostics still completes with server narration"
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "empty_stream");
|
||||
assert.equal(result.answerText, "");
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||
assert.equal(
|
||||
emitted.filter((event) => event.type === "tool.activity"
|
||||
&& (event as { tool?: string; status?: string }).tool === "rectification-compare-candidates"
|
||||
&& (event as { tool?: string; status?: string }).status === "started").length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
});
|
||||
|
||||
test("an unclaimed V10 attempt never starts the model", async () => {
|
||||
@@ -1408,9 +1408,9 @@ test("Chinese interview planning stays on reasoning-delta; the spoken answer is
|
||||
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
|
||||
});
|
||||
|
||||
test("timeout after a stamped open_question still completes without pasting the lock", async () => {
|
||||
test("timeout after a stamped open_question fails closed without pasting the lock", async () => {
|
||||
const prompt = "2016 年前后 · 高考或重要考试发挥明显失常、压力很大";
|
||||
const { options, emitted } = runOptions({
|
||||
const { options, emitted, billing } = runOptions({
|
||||
attemptTimeoutMs: 40,
|
||||
buildAgent: async () => ({
|
||||
stream: async (
|
||||
@@ -1443,19 +1443,16 @@ test("timeout after a stamped open_question still completes without pasting the
|
||||
}) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.equal(result.answerText, "接下来看下面这一问。");
|
||||
assert.doesNotMatch(result.answerText, /有没有/);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: "接下来看下面这一问。" }],
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "run_timeout");
|
||||
assert.equal(result.answerText, "");
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||
assert.deepEqual(emitted.filter((event) => event.type === "answer.delta"), []);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
});
|
||||
|
||||
test("open_question acknowledgements stream live instead of waiting for flush", async () => {
|
||||
test("open_question text deltas remain the model answer instead of being rewritten", async () => {
|
||||
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
@@ -1479,28 +1476,21 @@ test("open_question acknowledgements stream live instead of waiting for flush",
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.answerText, "好的,毕业这条也记下了。");
|
||||
assert.equal(result.answerText, "好的,毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?");
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[
|
||||
{ type: "answer.delta", text: "好的," },
|
||||
{ type: "answer.delta", text: "毕业这条也记下了。" },
|
||||
{ type: "answer.delta", text: "\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?" },
|
||||
],
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
|
||||
assert.match(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
|
||||
});
|
||||
|
||||
test("persisted choice prompt replaces a competing model follow-up without a topic denylist", async () => {
|
||||
test("persisted choice prompt does not rewrite a competing model follow-up", async () => {
|
||||
const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?";
|
||||
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
|
||||
assert.equal(
|
||||
bindSpokenToOpenQuestion(spoken, prompt),
|
||||
"好的,2020 年 6 月毕业这条也记下了。",
|
||||
);
|
||||
assert.equal(openQuestionPromptFromToolResult({
|
||||
type: "tool-result",
|
||||
payload: { toolName: "rectification-record-evidence-batch", result: { open_question: { prompt } } },
|
||||
}), prompt);
|
||||
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
@@ -1520,19 +1510,17 @@ test("persisted choice prompt replaces a competing model follow-up without a top
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.answerText, "好的,2020 年 6 月毕业这条也记下了。");
|
||||
assert.doesNotMatch(result.answerText, /入学考试/);
|
||||
assert.equal(result.answerText, spoken);
|
||||
assert.match(result.answerText, /入学考试/);
|
||||
assert.doesNotMatch(result.answerText, /有没有明显入职/);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: result.answerText }],
|
||||
[{ type: "answer.delta", text: spoken }],
|
||||
);
|
||||
});
|
||||
|
||||
test("persisted choice card owns a matching year-locked follow-up", async () => {
|
||||
test("persisted choice card does not rewrite a matching year-locked follow-up", async () => {
|
||||
const spoken = "好,实习和离职都记下了。\n\n2023 年前后,你有没有入职或者职责明显加重过?";
|
||||
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
|
||||
assert.equal(bindSpokenToOpenQuestion(spoken, prompt), "好,实习和离职都记下了。");
|
||||
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
@@ -1544,7 +1532,10 @@ test("persisted choice card owns a matching year-locked follow-up", async () =>
|
||||
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", {
|
||||
toolName: "rectification-record-evidence-batch",
|
||||
result: { accepted_count: 1, open_question: { prompt } },
|
||||
result: {
|
||||
accepted_count: 1,
|
||||
open_question: { prompt: "2023 年前后 · 入职、升职或职责明显加重" },
|
||||
},
|
||||
}),
|
||||
chunk("text-delta", { text: spoken }),
|
||||
chunk("finish"),
|
||||
@@ -1552,57 +1543,16 @@ test("persisted choice card owns a matching year-locked follow-up", async () =>
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.answerText, "好,实习和离职都记下了。");
|
||||
assert.doesNotMatch(result.answerText, /你有没有入职或者职责明显加重过/);
|
||||
assert.equal(result.answerText, spoken);
|
||||
assert.match(result.answerText, /你有没有入职或者职责明显加重过/);
|
||||
assert.doesNotMatch(result.answerText, /有没有明显入职、升职或职责明显加重/);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: result.answerText }],
|
||||
[{ type: "answer.delta", text: spoken }],
|
||||
);
|
||||
});
|
||||
|
||||
test("structured-choice acknowledgements are not kept as a second assistant reply", () => {
|
||||
const prompt = "2012 年 11 月前后,有没有入职、升职或职责明显加重?";
|
||||
assert.equal(
|
||||
bindSpokenToOpenQuestion("已记下你刚才的选择,候选比较也随之更新了。", prompt),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
bindSpokenToOpenQuestion("已记录你的选择,并更新了候选比较。接下来这一问和家里有关,请看下方选项。", prompt),
|
||||
"",
|
||||
);
|
||||
assert.equal(bindSpokenToOpenQuestion("记下了。", prompt), "记下了。");
|
||||
});
|
||||
|
||||
test("choice-card acknowledgements do not repeat a different-domain event", () => {
|
||||
const intern = "实习和离职的时间点都记下了,谢谢。";
|
||||
assert.equal(
|
||||
bindSpokenToOpenQuestion(intern, "2005 年 5 月前后,有没有搬家、离乡或长期异地?"),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
bindSpokenToOpenQuestion(intern, "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?"),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
bindSpokenToOpenQuestion("好,实习和离职都记下了。", "2023 年前后 · 入职、升职或职责明显加重"),
|
||||
"好,实习和离职都记下了。",
|
||||
);
|
||||
});
|
||||
|
||||
test("lock-only prompt is not spliced into speech", () => {
|
||||
const lock = "2023 年前后 · 入职、升职或职责明显加重";
|
||||
assert.equal(bindSpokenToOpenQuestion("", lock), "");
|
||||
assert.equal(bindSpokenToOpenQuestion("记下了。", lock), "记下了。");
|
||||
});
|
||||
|
||||
test("full-sentence lock is rendered only by the persisted choice card", () => {
|
||||
const spoken = "好的。\n\n2016 年高考发挥失常过吗?";
|
||||
const prompt = "2023 年前后,有没有明显入职、升职或职责明显加重?";
|
||||
assert.equal(bindSpokenToOpenQuestion(spoken, prompt), "好的。");
|
||||
});
|
||||
|
||||
test("model terminal text-delta is the reply even when Case narration could be composed", async () => {
|
||||
test("model terminal text-delta is the reply even when Case evidence is available", async () => {
|
||||
const spoken = "职业已经记下。你入职大概是哪一年?说个年份就行。";
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
|
||||
Reference in New Issue
Block a user