Files
Jyotisha/frontend/src/lib/rectification-agentic/user-copy.ts
T
Jesse_Chen df182c16d6
Independent Staging Quality Gate / validate (push) Successful in 10m56s
Independent Staging Quality Gate / publish (push) Successful in 9m1s
fix(rectification): replay answered probes after evidence rescore (BUG-587, BUG-588)
Carry answered probe defs into the new candidate set, replay them by minute, and announce range changes on evidence turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 10:38:51 +08:00

435 lines
19 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.
*/
import { PROBE_EXPLAIN_COPY } from "./v9/probe-explain.ts";
import { STEP_STATE_COPY } from "./v9/step-state.ts";
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: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
};
/** 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: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
};
export const RECTIFICATION_USER_COPY = {
genericCollectQuestion: GENERIC_COLLECT_QUESTION,
collectQuestionByDomain: USER_COLLECT_QUESTION,
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
collectHandoff: "接下来我们继续。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
insufficientEventsGate: "还差带月份的经历,领域不限。",
insufficientDomainsGate: "还差另一个领域的带月份经历。",
lowDateQualityGate: "两件事的日期还没对清。",
noCandidatesGate: "当前还排不出可比较的候选时间。",
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
rangeDeliveryTitle: "这次给出的范围",
rangeDeliveryRepresentativeLabel: "排盘用",
rangeDeliveryEventsLabel: "对照经历",
rangeDeliveryStableLabel: "稳定的主题",
rangeDeliverySensitiveLabel: "随分钟变的主题",
rangeDeliveryAdopt: "按这个范围用",
rangeDeliveryContinue: "再补一件经历",
divergenceTitle: "这几段还没对照过的差别",
divergenceUnlike: "都不像",
divergenceUnsure: "说不好",
} as const;
export function rangeDeliveryEventCopy(count: number): string {
return `对照了 ${count} 件带年月的经历`;
}
export function rangeDeliveryStableCopy(labels: readonly string[]): string {
return labels.length ? `这段里稳定的是${labels.join("、")}` : "";
}
export function rangeDeliverySensitiveCopy(labels: readonly string[]): string {
return labels.length ? `随分钟变的是${labels.join("、")}` : "";
}
export function withCompareFailedRetryNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.compareFailedRetry;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
return `${spoken}\n\n${notice}`;
}
export function withLastSuccessfulCompareNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.lastSuccessfulCompareRange;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
return `${spoken}\n\n${notice}`;
}
export const ACCEPTANCE_GATE_COPY: Readonly<Record<string, string>> = {
insufficient_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
insufficient_dated_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
insufficient_domain_diversity: RECTIFICATION_USER_COPY.insufficientDomainsGate,
insufficient_domains: RECTIFICATION_USER_COPY.insufficientDomainsGate,
low_date_quality: RECTIFICATION_USER_COPY.lowDateQualityGate,
no_candidates: RECTIFICATION_USER_COPY.noCandidatesGate,
};
export function acceptanceGateNarration(
reasons: readonly string[] | null | undefined,
missingEventCount?: number | null,
): string | null {
const tokens = (reasons ?? []).map((item) => item.trim()).filter(Boolean);
if (
(tokens.includes("insufficient_events") || tokens.includes("insufficient_dated_events"))
&& typeof missingEventCount === "number"
&& Number.isFinite(missingEventCount)
&& missingEventCount > 0
) {
return `还差 ${missingEventCount} 件带月份的经历,领域不限。`;
}
for (const reason of tokens) {
const copy = ACCEPTANCE_GATE_COPY[reason];
if (copy) return copy;
}
return null;
}
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 rangeChangedAfterEvidence(
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string | null {
const fromText = formatClockRange(from);
const toText = formatClockRange(to);
if (!fromText || !toText || fromText === toText) return null;
return `范围从 ${fromText} 变为 ${toText}`;
}
export function withRangeChangedAfterEvidence(
body: string,
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string {
const notice = rangeChangedAfterEvidence(from, to);
if (!notice) return body;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}`;
return `${prefix}${notice}`;
}
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;
if (reason === "probe_pool_exhausted") return RECTIFICATION_USER_COPY.probePoolExhaustedStop;
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.collectHandoff,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
RECTIFICATION_USER_COPY.probePoolExhaustedStop,
RECTIFICATION_USER_COPY.insufficientEventsGate,
RECTIFICATION_USER_COPY.insufficientDomainsGate,
RECTIFICATION_USER_COPY.lowDateQualityGate,
RECTIFICATION_USER_COPY.noCandidatesGate,
RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
RECTIFICATION_USER_COPY.compareFailedRetry,
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
RECTIFICATION_USER_COPY.deferredCareerWindow,
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
RECTIFICATION_USER_COPY.rangeDeliveryRepresentativeLabel,
RECTIFICATION_USER_COPY.rangeDeliveryEventsLabel,
RECTIFICATION_USER_COPY.rangeDeliveryStableLabel,
RECTIFICATION_USER_COPY.rangeDeliverySensitiveLabel,
RECTIFICATION_USER_COPY.rangeDeliveryAdopt,
RECTIFICATION_USER_COPY.rangeDeliveryContinue,
RECTIFICATION_USER_COPY.divergenceTitle,
RECTIFICATION_USER_COPY.divergenceUnlike,
RECTIFICATION_USER_COPY.divergenceUnsure,
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
rangeDeliveryEventCopy(3),
rangeDeliveryStableCopy(["事业方向"]),
rangeDeliverySensitiveCopy(["婚恋(D9"]),
"关系盘落在天秤座,通常表现为",
"事业盘落在巨蟹座,通常表现为",
"刚才那个日子是查过记录,还是凭记忆?",
PROBE_EXPLAIN_COPY.unsureImpact,
PROBE_EXPLAIN_COPY.splitGroups,
`${PROBE_EXPLAIN_COPY.vargaWhyPrefix} D9 ${PROBE_EXPLAIN_COPY.vargaWhySuffix}`,
STEP_STATE_COPY.collect.headline,
STEP_STATE_COPY.collect.reason,
STEP_STATE_COPY.collect.next,
STEP_STATE_COPY.discriminate.headline,
STEP_STATE_COPY.discriminate.reason,
STEP_STATE_COPY.discriminate.next,
STEP_STATE_COPY.deliver.headline,
STEP_STATE_COPY.deliver.reason,
STEP_STATE_COPY.deliver.next,
STEP_STATE_COPY.deliverExhausted.reason,
STEP_STATE_COPY.deliverUncertain.reason,
STEP_STATE_COPY.postAdopt.headline,
STEP_STATE_COPY.postAdopt.reason,
STEP_STATE_COPY.postAdopt.next,
"这不是已确认的唯一出生分钟。",
"这只是粗看。",
"的判断是稳定的",
"会随分钟变,看盘时按范围读。",
"没有整段都稳定的主题",
"没有会随分钟变的主题。",
"事业方向",
"婚恋(D9",
"性格底色",
...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),
};
}