Files
Jyotisha/frontend/src/lib/rectification-agentic/user-copy.ts
T
Jesse_Chen ed9497e76a fix(voice): speak first, then keep Level 2 skeleton and boundary semantics
Natal answers were opening on parameter tables, and rectification turns were one-sentence legal copy. Centralize user-facing strings, keep representative-minute and question-slot red lines, and stop duplicating the opening collect prompt as a second assistant message.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 02:27:50 +08:00

220 lines
8.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.
/**
* User-visible rectification copy. Tone lives here; decision gates do not.
*
* See frontend/docs/VOICE.md. Semantic constraints in comments must survive
* rewrites: question-slot ownership, representative-minute boundary, and
* no unique-minute claim.
*/
export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。";
/** Phrases that keep the unique-minute boundary in delivery/adopt turns. */
export const BOUNDARY_SEMANTIC_PHRASES = [
"不是已确认的唯一出生分钟",
"不是已经确认的唯一出生分钟",
] as const;
export type CollectDomain =
| "relationship"
| "career"
| "family"
| "occupation"
| "education"
| "relocation"
| "finance"
| "health_pressure"
| "other";
export const GENERIC_COLLECT_QUESTION = "从你最容易想起来的开始就好——比如哪年上的大学、哪年换的工作,记得大概年份就行。";
export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
relationship: "感情这边,还记得哪年认真在一起、分开,或结婚吗?有年份就很好。",
career: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
family: "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
occupation: "你平时主要做什么工作?",
education: "上学这边,还记得哪年升学、转学或大考吗?",
relocation: "有没有哪年搬家,或开始长期住在外地?",
finance: "钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?",
health_pressure: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
other: "也可以再说一件你记得大概时间的事。",
};
export const RECTIFICATION_USER_COPY = {
genericCollectQuestion: GENERIC_COLLECT_QUESTION,
collectQuestionByDomain: USER_COLLECT_QUESTION,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "可以从下面选一个先用着。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
} as const;
export type RangeNarrationVariant = "delivery" | "intermediate";
export type RangeNarrationInput = {
credibleRange?: readonly [string, string] | null;
representativeTime?: string | null;
openingRange?: readonly [string, string] | null;
variant?: RangeNarrationVariant;
};
function clockMinutes(value: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(value.trim());
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
export function rangeWidthMinutes(start: string, end: string): number | null {
const from = clockMinutes(start);
const to = clockMinutes(end);
if (from == null || to == null) return null;
const width = to >= from ? to - from : to + 24 * 60 - from;
return width > 0 ? width : null;
}
export function formatClockRange(range: readonly [string, string] | null | undefined): string | null {
if (!range?.[0] || !range[1]) return null;
return range[0] === range[1] ? range[0] : `${range[0]}${range[1]}`;
}
export function openingRangeFromCandidateRange(
range: { start_time?: string | null; end_time?: string | null } | null | undefined,
): readonly [string, string] | null {
const start = range?.start_time?.trim().slice(0, 5) || "";
const end = range?.end_time?.trim().slice(0, 5) || "";
return start && end ? [start, end] : null;
}
export function containsBoundarySemantics(text: string): boolean {
return BOUNDARY_SEMANTIC_PHRASES.some((phrase) => text.includes(phrase));
}
function progressClause(
openingRange: readonly [string, string] | null | undefined,
rangeText: string,
currentRange: readonly [string, string],
): string | null {
if (!openingRange) return null;
const openingWidth = rangeWidthMinutes(openingRange[0], openingRange[1]);
const currentWidth = rangeWidthMinutes(currentRange[0], currentRange[1]);
if (!openingWidth || !currentWidth || currentWidth >= openingWidth) return null;
return `已经从最初的 ${openingWidth} 分钟收到 ${rangeText}${currentWidth} 分钟`;
}
export function nonConvergingRangeNarration(
input: RangeNarrationInput = {},
boundaryCopy = REPRESENTATIVE_MINUTE_DISCLAIMER,
): string {
const range = input.credibleRange ?? null;
const representative = input.representativeTime?.trim() || null;
const rangeText = formatClockRange(range);
const variant = input.variant ?? "delivery";
const progress = range && rangeText
? progressClause(input.openingRange, rangeText, range)
: null;
if (variant === "intermediate") {
if (rangeText && representative) {
return progress
? `${progress}。眼下更像 ${representative}`
: `范围已经收到 ${rangeText} 附近,眼下更像 ${representative}`;
}
if (rangeText) {
return progress ? `${progress}` : `范围已经收到 ${rangeText} 附近。`;
}
if (representative) return `眼下更像 ${representative}`;
return "当前几个候选还分不开。";
}
if (rangeText && representative) {
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}` : ``;
return `${head}${representativeClause}${boundaryCopy}`;
}
if (rangeText) {
const head = progress ?? `更站得住的范围是 ${rangeText}`;
return `${head}${boundaryCopy}`;
}
if (representative) {
return `当前代表分钟 ${representative}${boundaryCopy}`;
}
return `当前几个候选还分不开。${boundaryCopy}`;
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`;
}
const INSTRUCTION_TONE_SENTENCE = /不得|请写成/;
const ENGINE_MEANING_REPLACEMENTS: ReadonlyArray<readonly [RegExp, string]> = [
[/冲突时不能按更高把握收口。?/g, "两边看法还不一致,先不往更窄处收。"],
[/下面是本轮实际执行的技法,不能当作唯一分钟确认。?/g, ""],
[/不能确认唯一分钟或声称已完成精确分钟校准。?/g, "还不能把某一分钟当成已确认。"],
[/缺少该验证时不能确认唯一分钟。?/g, "所以还不能把某一分钟当成已确认。"],
[/不能确认唯一分钟。/g, "还不能把某一分钟当成已确认。"],
];
/**
* Consumer-side layer: engine `user_meaning` stays as model/audit material.
* User-visible surfaces must run this (or an equivalent display_copy field).
*/
export function engineMeaningToDisplayCopy(meaning: string | null | undefined): string {
if (!meaning) return "";
const replaced = ENGINE_MEANING_REPLACEMENTS.reduce(
(text, [pattern, next]) => text.replace(pattern, next),
meaning,
);
return replaced
.split(/(?<=[。!?;])\s*/)
.filter((sentence) => sentence.trim() && !INSTRUCTION_TONE_SENTENCE.test(sentence))
.join("")
.replace(/\s+/g, " ")
.trim();
}
export function listUserVisibleCopy(): string[] {
const values: string[] = [
REPRESENTATIVE_MINUTE_DISCLAIMER,
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY.choicePrompt,
RECTIFICATION_USER_COPY.unclearFocusReply,
RECTIFICATION_USER_COPY.questionUpdated,
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
...Object.values(USER_COLLECT_QUESTION),
];
return values;
}
export function accidentCaseHardcodedTurns(input: {
openingRange?: readonly [string, string] | null;
credibleRange: readonly [string, string];
representativeTime: string;
}): {
openingCollect: string;
discriminatorRedirect: string;
unclearFocusReply: string;
staleQuestion: string;
intermediateRange: string;
financeCollect: string;
deliveryAdopt: string;
} {
const shared = {
credibleRange: input.credibleRange,
representativeTime: input.representativeTime,
openingRange: input.openingRange ?? null,
};
return {
openingCollect: GENERIC_COLLECT_QUESTION,
discriminatorRedirect: RECTIFICATION_USER_COPY.choicePrompt,
unclearFocusReply: RECTIFICATION_USER_COPY.unclearFocusReply,
staleQuestion: RECTIFICATION_USER_COPY.questionUpdated,
intermediateRange: nonConvergingRangeNarration({ ...shared, variant: "intermediate" }),
financeCollect: USER_COLLECT_QUESTION.finance,
deliveryAdopt: deliveryAdoptNarration(shared),
};
}