8e214e290a
The other-domain fallback copy was being used as the first question. Opening now uses GENERIC_COLLECT_QUESTION; after-evidence other copy stays. Co-authored-by: Cursor <cursoragent@cursor.com>
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
/**
|
||
* 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: "也可以再说一件你记得大概时间的事。",
|
||
};
|
||
|
||
/** Second phrasing when that domain's collect focus was already established and not declined. */
|
||
export const USER_COLLECT_QUESTION_RETRY: 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,
|
||
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
|
||
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
|
||
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
|
||
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
|
||
adoptCue: "可以从下面选一个先用着。",
|
||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
|
||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
|
||
} 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;
|
||
stopReason?: string | null;
|
||
};
|
||
|
||
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));
|
||
}
|
||
|
||
export function stopReasonPrefix(reason: string | null | undefined): string | null {
|
||
if (reason === "user_uncertainty_too_high") return RECTIFICATION_USER_COPY.uncertaintyStop;
|
||
if (reason === "tied_first") return RECTIFICATION_USER_COPY.tiedFirstStop;
|
||
return null;
|
||
}
|
||
|
||
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;
|
||
const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : 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}。` : `。`;
|
||
const body = `${head}${representativeClause}${boundaryCopy}`;
|
||
return reason ? `${reason}${body}` : body;
|
||
}
|
||
if (rangeText) {
|
||
const head = progress ?? `更站得住的范围是 ${rangeText}`;
|
||
const body = `${head}。${boundaryCopy}`;
|
||
return reason ? `${reason}${body}` : body;
|
||
}
|
||
if (representative) {
|
||
const body = `当前代表分钟 ${representative}。${boundaryCopy}`;
|
||
return reason ? `${reason}${body}` : body;
|
||
}
|
||
const body = `当前几个候选还分不开。${boundaryCopy}`;
|
||
return reason ? `${reason}${body}` : body;
|
||
}
|
||
|
||
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,
|
||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||
RECTIFICATION_USER_COPY.tiedFirstStop,
|
||
...Object.values(USER_COLLECT_QUESTION),
|
||
...Object.values(USER_COLLECT_QUESTION_RETRY),
|
||
];
|
||
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),
|
||
};
|
||
}
|