Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/turn-narration.ts
T
Jesse_ChenandCursor 52477306cc
Independent Staging Quality Gate / validate (push) Successful in 10m43s
Independent Staging Quality Gate / publish (push) Successful in 9m8s
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>
2026-08-30 15:49:20 +08:00

166 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseAgentChoiceCopy } from "./choice-card";
import type { V9CaseDossier } from "./tool-service";
export type RectificationNarrationDto = Readonly<{
acknowledgedFacts: readonly string[];
nextQuestion: string | null;
}>;
export function publicNarrationDtoFromDossier(dossier: V9CaseDossier): RectificationNarrationDto {
const acknowledgedFacts = dossier.evidence
.filter((item) => item.status === "confirmed" || item.status === "draft" || item.status === "pending_confirmation")
.slice(-4)
.map((item) => item.summary.trim())
.filter((item) => item.length >= 2);
const choice = parseAgentChoiceCopy(dossier.conversationSummary.activeFocus?.expectedAnswerSchema ?? null);
return {
acknowledgedFacts,
nextQuestion: choice?.prompt ?? null,
};
}
export function composeRectificationTurnNarration(dto: RectificationNarrationDto): string {
const parts: string[] = [];
if (dto.acknowledgedFacts.length > 0) {
parts.push(`已经记下:${dto.acknowledgedFacts.join("")}。`);
}
if (dto.nextQuestion && /[?]/.test(dto.nextQuestion)) parts.push(dto.nextQuestion);
if (parts.length === 0) {
return "请继续说下一件你记得比较清楚、大概带年份的经历。";
}
return parts.join("");
}
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();
return text.length > 0 ? text : null;
}
export function readOpenQuestionPrompt(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
return promptFromQuestionField(row.open_question)
?? promptFromQuestionField(row.current_question);
}
export function openQuestionPromptFromToolResult(chunk: {
type?: string;
payload?: unknown;
object?: unknown;
}): string | null {
if (chunk.type !== "tool-result") return null;
const payload = chunk.payload && typeof chunk.payload === "object" && !Array.isArray(chunk.payload)
? chunk.payload as Record<string, unknown>
: null;
return readOpenQuestionPrompt(payload?.result)
?? readOpenQuestionPrompt(payload?.output)
?? readOpenQuestionPrompt(chunk.object)
?? readOpenQuestionPrompt(chunk.payload);
}
const YEAR_RE = /(?:19|20)\d{2}/g;
const FOREIGN_MARKERS = [
"高考",
"入学考试",
"发挥失常",
"发挥明显失常",
"上学",
"小学",
"学业",
"入职",
"升职",
"职责",
"实习",
"离职",
"搬家",
"离乡",
"异地",
"认真关系",
"分手",
"结婚",
"感情",
"家人",
] as const;
const MARKER_GROUPS: readonly (readonly string[])[] = [
["高考", "入学考试", "发挥失常", "发挥明显失常", "上学", "小学", "学业"],
["入职", "升职", "职责", "实习", "离职"],
["搬家", "离乡", "异地"],
["认真关系", "分手", "结婚", "感情"],
["家人"],
];
export function extractSpokenQuestion(spoken: string): string | null {
const parts = spoken
.split(/\n{2,}/)
.flatMap((block) => block.split(/(?<=[。!??])\s*/u))
.map((part) => part.trim())
.filter((part) => part.length > 0 && /[?]/.test(part));
const last = parts.at(-1);
return last && last.length >= 4 ? last : null;
}
function yearsIn(text: string): number[] {
return [...text.matchAll(YEAR_RE)].map((match) => Number(match[0]));
}
export function spokenQuestionMatchesLock(question: string, lockPrompt: string): boolean {
const lockYears = yearsIn(lockPrompt);
const askedYears = yearsIn(question);
if (lockYears.length > 0 && !lockYears.some((year) => askedYears.includes(year))) return false;
if (askedYears.some((year) => !lockYears.includes(year))) return false;
const lockedMarkers = FOREIGN_MARKERS.filter((marker) => lockPrompt.includes(marker));
const foreign = FOREIGN_MARKERS.filter((marker) => !lockedMarkers.includes(marker));
return !foreign.some((marker) => question.includes(marker));
}
function ackFitsOpenQuestion(part: string, lock: string): boolean {
for (const group of MARKER_GROUPS) {
if (!group.some((marker) => part.includes(marker))) continue;
if (!group.some((marker) => lock.includes(marker))) return false;
}
return true;
}
export function overlayChoicePromptFromSpoken(
fallbackPrompt: string,
spoken: string | null | undefined,
): string {
const asked = extractSpokenQuestion(spoken ?? "");
if (!asked || !spokenQuestionMatchesLock(asked, fallbackPrompt)) return fallbackPrompt;
const clipped = asked.replace(/\s+/g, " ").trim();
if (clipped.length < 4 || clipped.length > 80) return fallbackPrompt;
return clipped;
}
const CHOICE_ACK_RE = /已记录你的选择|已记下你刚才的选择|候选比较也随之更新|请看下方选项/;
export const CHOICE_CARD_CONTINUATION_ACK = "接下来看下面这一问。";
/**
* A persisted choice focus is rendered by the choice card. Keep only the
* acknowledgement in chat so the Agent cannot create a second, divergent
* discriminator in prose.
*/
export function bindSpokenToOpenQuestion(spoken: string, nextQuestion: string | null): string {
const lock = nextQuestion?.trim() ?? "";
if (!lock) return spoken.trim();
const ack = spoken
.split(/\n{2,}/)
.flatMap((block) => block.split(/(?<=[。!])\s*/u))
.map((part) => part.trim())
.filter((part) => (
part.length > 0
&& !/[?]/.test(part)
&& !part.includes(lock)
&& !CHOICE_ACK_RE.test(part)
&& ackFitsOpenQuestion(part, lock)
))
.slice(0, 2);
return ack.join("\n\n");
}