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>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
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");
|
||
}
|