/** * 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> = { 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> = { 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 = [ [/冲突时不能按更高把握收口。?/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), }; }