宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、 用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事 都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。 BUG-740~743
671 lines
28 KiB
TypeScript
671 lines
28 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";
|
||
import {
|
||
REPORTED_TIME_LABEL,
|
||
boardDeclaredTimeLine,
|
||
reportedTimeOffsetCopy,
|
||
SWITCH_TO_RECTIFIED_LABEL,
|
||
switchToRectifiedConfirmCopy,
|
||
} from "./birth-time-provenance.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 = "先说你最容易想起的一两件,年月大概就行。";
|
||
|
||
/** One-time suffix on the first dated-collect stem. Not a chase-more turn. */
|
||
export const FIRST_DATED_COLLECT_INVITE = "想到别的也可以一起说。";
|
||
|
||
export function withFirstDatedCollectInvite(stem: string): string {
|
||
const spoken = stem.trim();
|
||
if (!spoken || spoken.includes(FIRST_DATED_COLLECT_INVITE)) return spoken;
|
||
return `${spoken}${FIRST_DATED_COLLECT_INVITE}`;
|
||
}
|
||
|
||
/** Six everyday examples named in the opening body. Not years. */
|
||
export const OPENING_COLLECT_DOMAINS = [
|
||
"上大学",
|
||
"第一份工作",
|
||
"搬到别的城市",
|
||
"谈恋爱或结婚",
|
||
"家里添丁或长辈住院",
|
||
"生病受伤",
|
||
] as const;
|
||
|
||
export function openingSpokenBody(range: readonly [string, string] | null): string {
|
||
const window = formatClockRange(range) ?? "当前这段";
|
||
return [
|
||
`眼下按 ${window} 来核对,用你记得的经历对照大运和盘面变化。`,
|
||
"最后给区间和代表分钟,不给精确到秒。",
|
||
`先说你最容易想起的一两件,比如${OPENING_COLLECT_DOMAINS.join("、")},年月大概就行。`,
|
||
].join("");
|
||
}
|
||
|
||
const OPENING_YEAR = /(?:19|20)\d{2}/;
|
||
const OPENING_SENTENCE = /(?<=[。!?])/;
|
||
|
||
export function isAcceptableOpeningBody(body: string): boolean {
|
||
const spoken = body.trim();
|
||
if (!spoken || OPENING_YEAR.test(spoken)) return false;
|
||
const sentences = spoken.split(OPENING_SENTENCE).map((part) => part.trim()).filter(Boolean);
|
||
if (sentences.length === 0 || sentences.length > 4) return false;
|
||
const hits = OPENING_COLLECT_DOMAINS.filter((domain) => spoken.includes(domain)).length;
|
||
return hits >= 5;
|
||
}
|
||
|
||
/** 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: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
|
||
};
|
||
|
||
export function collectQuestionForDomain(
|
||
domain: string,
|
||
_probeYear?: number | null,
|
||
): string | undefined {
|
||
return USER_COLLECT_QUESTION[domain];
|
||
}
|
||
|
||
/** 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: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
|
||
};
|
||
|
||
/** Count=2 → 「这两分钟」; >2 → 「这几个候选」; unknown / 0 / 1 → omit. */
|
||
export function rangeDeliveryCollectClosedSubject(candidateCount?: number): string | null {
|
||
if (candidateCount === 2) return "这两分钟";
|
||
if (typeof candidateCount === "number" && Number.isFinite(candidateCount) && candidateCount > 2) {
|
||
return "这几个候选";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function rangeDeliveryCollectClosed(candidateCount?: number): string {
|
||
const subject = rangeDeliveryCollectClosedSubject(candidateCount);
|
||
return subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。";
|
||
}
|
||
|
||
export const RECTIFICATION_USER_COPY = {
|
||
genericCollectQuestion: GENERIC_COLLECT_QUESTION,
|
||
collectQuestionByDomain: USER_COLLECT_QUESTION,
|
||
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
|
||
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
|
||
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
|
||
classifierUnavailableReply: "这边没接上,把刚才那句再发一次就行。",
|
||
declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
|
||
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
|
||
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||
collectHandoff: "接下来我们继续。",
|
||
evidenceNotRecorded: "这件我还没记上。请再说一次大概年月和发生的事。",
|
||
collectDeclinedAck: "记下了,这条按没有发生过记。",
|
||
collectSkippedAck: "记下了,这题先放着,后面换个问法再问一次。",
|
||
firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE,
|
||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
|
||
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
|
||
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
|
||
insufficientEventsGate: "再来一件记得大概年月的事就能开始筛。",
|
||
insufficientDomainsGate: "再来一件不是同一类的、记得大概年月的事就能开始筛。",
|
||
lowDateQualityGate: "两件事的日期还没对清。",
|
||
noCandidatesGate: "当前还排不出可比较的候选时间。",
|
||
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
|
||
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
|
||
rescoreSkipped: "这次没有重新比较,稍后再比一次。",
|
||
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
|
||
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
|
||
rangeDeliveryTitle: "目前范围",
|
||
rangeDeliveryMoreLikeThis: "更像这个",
|
||
rangeDeliveryRelativeLikelihood: "相对可能性",
|
||
rangeDeliveryNoWindow: "未来一年没有明显的时段",
|
||
rangeDeliveryNotCompared: "这一分钟还没对照",
|
||
rangeDeliveryAdopting: "正在采用…",
|
||
rangeDeliveryAdopted: "已采用",
|
||
rangeDeliveryTieBreakEntry: "再答两道参考题微调排序",
|
||
rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。",
|
||
rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。",
|
||
rangeDeliveryAdoptLocked: "先答完上面这道,再选时间。",
|
||
verificationReportSummary: "查看验证报告",
|
||
} as const;
|
||
|
||
/** Top-two relative-likelihood gap, in percentage points, that counts as a tie on the range card. */
|
||
export const RANGE_DELIVERY_TIE_PERCENT = 3;
|
||
|
||
export function rangeDeliveryTopTwoTied(
|
||
columns: readonly { probability_percent: number }[],
|
||
): boolean {
|
||
if (columns.length < 2) return false;
|
||
return Math.abs(columns[0].probability_percent - columns[1].probability_percent)
|
||
<= RANGE_DELIVERY_TIE_PERCENT;
|
||
}
|
||
|
||
export const RANGE_DELIVERY_DOMAIN_LABEL: Readonly<Record<string, string>> = {
|
||
education: "学业",
|
||
career: "事业",
|
||
relationship: "感情",
|
||
family: "家人",
|
||
relocation: "搬家",
|
||
finance: "财务",
|
||
health_pressure: "健康",
|
||
occupation: "职业",
|
||
other: "其他",
|
||
};
|
||
|
||
export function rangeDeliveryEventCopy(count: number): string {
|
||
return `对照了 ${count} 件经历`;
|
||
}
|
||
|
||
export function RANGE_DELIVERY_MORE_MINUTES(count: number): string {
|
||
return `还有 ${count} 个分钟可能性更低,已并入范围`;
|
||
}
|
||
|
||
function spokenYearMonth(yearMonth: string): string | null {
|
||
const match = /^(\d{4})(?:-(\d{2}))?/.exec(yearMonth.trim());
|
||
if (!match) return null;
|
||
const year = Number(match[1]);
|
||
const month = match[2] ? Number(match[2]) : null;
|
||
if (!Number.isFinite(year)) return null;
|
||
return month && month >= 1 && month <= 12 ? `${year} 年 ${month} 月` : `${year} 年`;
|
||
}
|
||
|
||
export type RangeDeliveryFitCounts = Readonly<{
|
||
strong: number;
|
||
medium: number;
|
||
weak: number;
|
||
total: number;
|
||
worst: Readonly<{ year_month: string; domain: string }> | null;
|
||
}>;
|
||
|
||
export function rangeDeliveryFitLine(fit: RangeDeliveryFitCounts | null): string {
|
||
if (!fit) return RECTIFICATION_USER_COPY.rangeDeliveryNotCompared;
|
||
const total = fit.total >= 0 ? fit.total : fit.strong + fit.medium + fit.weak;
|
||
const matched = fit.strong + fit.medium;
|
||
const worstMonth = fit.worst ? spokenYearMonth(fit.worst.year_month) : null;
|
||
const worstDomain = fit.worst
|
||
? RANGE_DELIVERY_DOMAIN_LABEL[fit.worst.domain] ?? fit.worst.domain
|
||
: "";
|
||
const worst = worstMonth && worstDomain ? `${worstMonth}那段${worstDomain}` : null;
|
||
return worst
|
||
? `${total} 件经历里 ${matched} 件对得上,最不合的是 ${worst}`
|
||
: `${total} 件经历里 ${matched} 件对得上`;
|
||
}
|
||
|
||
export function rangeDeliveryWindowLine(
|
||
windows: readonly { domain: string; from: string; to: string }[] | null,
|
||
): string {
|
||
if (windows == null) return RECTIFICATION_USER_COPY.rangeDeliveryNotCompared;
|
||
const next = windows[0];
|
||
if (!next) return RECTIFICATION_USER_COPY.rangeDeliveryNoWindow;
|
||
const month = spokenYearMonth(next.from) ?? next.from;
|
||
const domain = RANGE_DELIVERY_DOMAIN_LABEL[next.domain] ?? next.domain;
|
||
return `下一个值得留意的时段:${month}前后(${domain})`;
|
||
}
|
||
|
||
export function sharedTraitLine(line: string, columnCount: number): string {
|
||
const countLabel = columnCount === 2 ? "两个时间" : columnCount === 3 ? "三个时间" : `${columnCount} 个时间`;
|
||
const career = /^事业盘落在([^,]+),通常表现为(.+)$/.exec(line);
|
||
if (career) return `${countLabel}的事业盘都在${career[1]}:${career[2]}`;
|
||
const relationship = /^关系盘落在([^,]+),通常表现为(.+)$/.exec(line);
|
||
if (relationship) return `${countLabel}的关系盘都在${relationship[1]}:${relationship[2]}`;
|
||
return line;
|
||
}
|
||
|
||
export function postAdoptVerifyDoneCopy(
|
||
time: string | null | undefined,
|
||
hadVerifyQuestions: boolean,
|
||
): string {
|
||
if (hadVerifyQuestions) return RECTIFICATION_USER_COPY.postAdoptVerifyDone;
|
||
const clock = time?.trim().slice(0, 5) ?? "";
|
||
return clock
|
||
? `已采用 ${clock},新建对话即按这个时间排盘。`
|
||
: "已采用当前时间,新建对话即按这个时间排盘。";
|
||
}
|
||
|
||
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 stripRangeProgressClaims(body: string): string {
|
||
const spoken = body.trim();
|
||
if (!spoken) return "";
|
||
const kept = spoken
|
||
.split(/(?<=[。!?])/)
|
||
.map((part) => part.trim())
|
||
.filter((part) => part && !/收窄|范围从/.test(part));
|
||
return kept.join("");
|
||
}
|
||
|
||
export function withRescoreSkippedNotice(body: string): string {
|
||
const notice = RECTIFICATION_USER_COPY.rescoreSkipped;
|
||
const spoken = stripRangeProgressClaims(body);
|
||
if (spoken.includes("这次没有重新比较")) return spoken;
|
||
if (!spoken) return `记下了。${notice}`;
|
||
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`;
|
||
if (spoken.includes("记下了")) return `${prefix}${notice}`;
|
||
return `${prefix}记下了。${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"))
|
||
) {
|
||
return RECTIFICATION_USER_COPY.insufficientEventsGate;
|
||
}
|
||
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;
|
||
eventCount?: number | null;
|
||
fitPercent?: number | 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 deliveryTurnNarration(input: RangeNarrationInput = {}): string {
|
||
const rangeText = formatClockRange(input.credibleRange ?? null);
|
||
const sentence1 = rangeText
|
||
? `目前范围 ${rangeText}。`
|
||
: "当前几个候选还分不开。";
|
||
const count = typeof input.eventCount === "number" && input.eventCount >= 0 ? input.eventCount : 0;
|
||
const percent = typeof input.fitPercent === "number" && Number.isFinite(input.fitPercent)
|
||
? Math.round(input.fitPercent)
|
||
: null;
|
||
const sentence2 = percent != null
|
||
? `对照了 ${count} 件经历,事件吻合率 ${percent}%。`
|
||
: `对照了 ${count} 件经历。`;
|
||
return `${sentence1}${sentence2}${REPRESENTATIVE_MINUTE_DISCLAIMER}`;
|
||
}
|
||
|
||
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
|
||
return deliveryTurnNarration({ ...input, variant: "delivery" });
|
||
}
|
||
|
||
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.classifierUnavailableReply,
|
||
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
|
||
RECTIFICATION_USER_COPY.questionUpdated,
|
||
RECTIFICATION_USER_COPY.adoptCue,
|
||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||
RECTIFICATION_USER_COPY.collectHandoff,
|
||
RECTIFICATION_USER_COPY.evidenceNotRecorded,
|
||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||
RECTIFICATION_USER_COPY.collectSkippedAck,
|
||
FIRST_DATED_COLLECT_INVITE,
|
||
GENERIC_COLLECT_QUESTION,
|
||
openingSpokenBody(["04:45", "05:15"]),
|
||
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.rescoreSkipped,
|
||
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
|
||
RECTIFICATION_USER_COPY.deferredCareerWindow,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryNoWindow,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryNotCompared,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryAdopting,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryAdopted,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked,
|
||
rangeDeliveryCollectClosed(2),
|
||
rangeDeliveryCollectClosed(4),
|
||
rangeDeliveryCollectClosed(),
|
||
RECTIFICATION_USER_COPY.verificationReportSummary,
|
||
postAdoptVerifyDoneCopy("04:53", false),
|
||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
|
||
RANGE_DELIVERY_MORE_MINUTES(2),
|
||
rangeDeliveryEventCopy(3),
|
||
rangeDeliveryFitLine({
|
||
strong: 4,
|
||
medium: 3,
|
||
weak: 1,
|
||
total: 8,
|
||
worst: { year_month: "2024-05", domain: "relationship" },
|
||
}),
|
||
rangeDeliveryWindowLine([{ domain: "career", from: "2027-03", to: "2027-03" }]),
|
||
sharedTraitLine("事业盘落在巨蟹座,通常表现为做事以照顾人为主,在意团队里的感受", 3),
|
||
deliveryTurnNarration({
|
||
credibleRange: ["04:51", "04:59"],
|
||
representativeTime: "04:53",
|
||
eventCount: 7,
|
||
fitPercent: 80,
|
||
}),
|
||
"关系盘落在天秤座,通常表现为",
|
||
"事业盘落在巨蟹座,通常表现为",
|
||
"刚才那个日子是查过记录,还是凭记忆?",
|
||
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),
|
||
"还有吗?比如第一份工作、搬到别的城市、谈恋爱或分手、家里添丁或长辈住院。",
|
||
"再说一件带年月的事",
|
||
REPORTED_TIME_LABEL.hospital_record,
|
||
REPORTED_TIME_LABEL.approximate,
|
||
REPORTED_TIME_LABEL.period_only,
|
||
boardDeclaredTimeLine("05:00", "hospital_record"),
|
||
boardDeclaredTimeLine("05:00", "approximate"),
|
||
reportedTimeOffsetCopy({
|
||
source: "approximate",
|
||
reportedTime: "05:00",
|
||
range: ["04:48", "05:07"],
|
||
representativeTime: "04:53",
|
||
}) ?? "",
|
||
reportedTimeOffsetCopy({
|
||
source: "hospital_record",
|
||
reportedTime: "05:12",
|
||
range: ["04:48", "05:07"],
|
||
representativeTime: "04:53",
|
||
}) ?? "",
|
||
SWITCH_TO_RECTIFIED_LABEL,
|
||
switchToRectifiedConfirmCopy("05:12", "04:53"),
|
||
];
|
||
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),
|
||
};
|
||
}
|