df182c16d6
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>
435 lines
19 KiB
TypeScript
435 lines
19 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.
|
||
*/
|
||
|
||
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),
|
||
};
|
||
}
|