Files
Jyotisha/frontend/src/lib/rectification-agentic/user-copy.ts
T
Jesse_Chen 329f8a2109
Independent Staging Quality Gate / validate (push) Successful in 11m34s
Independent Staging Quality Gate / publish (push) Successful in 1m56s
fix(rectification): pin the adopt card and drop casual adopt copy
The time card stayed under an earlier collect question, and the close said to just use a time for now. Keep the card on the delivery turn and present the analysis as this round's result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 02:03:19 +08:00

279 lines
12 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 = "从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家,记得大概年份就行。";
/** Everyday words a collect spoken prompt must mention for its server domain. */
export const COLLECT_DOMAIN_KEYWORDS: Readonly<Record<string, readonly string[]>> = {
education: ["上学", "大学", "毕业", "考试", "学业"],
finance: ["收入", "买房", "贷款", "欠债", "钱"],
relocation: ["搬家", "住", "外地"],
health_pressure: ["生病", "受伤", "住院", "压力"],
family: ["家里", "父母", "家人", "添丁"],
career: ["工作", "入职", "换工作"],
relationship: ["交往", "分手", "结婚", "感情"],
occupation: ["工作", "做什么", "职业"],
};
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: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
} 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;
/** Extra stop sentence after BUG-503 prefixes; used when the probe pool is exhausted. */
stopExplain?: 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;
const explain = variant === "delivery" ? (input.stopExplain?.trim() || null) : null;
const prefix = [reason, explain].filter(Boolean).join("");
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 collapsed = Boolean(range && range[0] === range[1]);
if (collapsed) {
const head = progress ?? `眼下更站得住的是 ${representative}`;
const body = `${head}${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}` : ``;
const body = `${head}${representativeClause}${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
if (rangeText) {
const head = progress ?? `更站得住的范围是 ${rangeText}`;
const body = `${head}${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
if (representative) {
const body = `当前代表分钟 ${representative}${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
const body = `当前几个候选还分不开。${boundaryCopy}`;
return prefix ? `${prefix}${body}` : body;
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
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.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
...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),
};
}