About 30 fixed rectification lines lose the receipt tone: no 「已记录。」 openings (收到 when the range holds, 好 when it moves), 麻烦 instead of 请, 时间 instead of 候选 in what the user reads, and no 「新建对话」 after adoption. Numbers, clocks, ranges and the honesty boundaries are unchanged. CHOICE_ACK_RE also recognises the new acknowledgements, so a model sentence that repeats one is still dropped; the delivery guard matches the delivery sentence by containment and does not depend on the opening. Full suite 4973 / fail 24, identical to 61d6f248; build keeps / Static. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
808 lines
35 KiB
TypeScript
808 lines
35 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 { splitAfterSentencePunctuation } from "../sentence-split.ts";
|
||
import { chartSummaryCopy } from "./core/segment-summary.ts";
|
||
import { unknownProductDeliveryCopy, type ProductDelivery } from "./v9/product-delivery.ts";
|
||
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";
|
||
|
||
/** Fixed illustrative answer in the opening stem. Not derived from the birth date. */
|
||
export const OPENING_ANSWER_EXAMPLE = "2015 年夏天换了工作";
|
||
|
||
/** Six everyday examples named in the opening stem. Not years. */
|
||
export const OPENING_COLLECT_DOMAINS = [
|
||
"上大学",
|
||
"第一份工作",
|
||
"搬到别的城市",
|
||
"谈恋爱或结婚",
|
||
"家里添丁",
|
||
"生病住院",
|
||
] as const;
|
||
|
||
/**
|
||
* Zero-evidence opening stem (question slot of the first collect focus). It
|
||
* carries the six examples and one example answer (BUG-504 / BUG-1049): the
|
||
* reader must see what kind of thing to say and how precise the date needs to
|
||
* be. The year inside 「」 is a fixed illustration, never derived from the
|
||
* birth date, and it only lives in the stem — the opening body stays year-free.
|
||
* Server-owned: the opening set-focus stores this text regardless of what the
|
||
* model writes in `spokenPrompt`. Kept literal (source locks); tests assert it
|
||
* names every OPENING_COLLECT_DOMAINS entry and OPENING_ANSWER_EXAMPLE.
|
||
*/
|
||
export const GENERIC_COLLECT_QUESTION = "先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。";
|
||
|
||
/** 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}`;
|
||
}
|
||
|
||
/**
|
||
* Opening body in plain words (BUG-1049, product 2026-09-26): what we are doing
|
||
* and how, no question, no years, no jargon. The examples live in the stem
|
||
* right below it, so the body does not list them again.
|
||
*/
|
||
export function openingSpokenBody(range: readonly [string, string] | null, productDelivery?: ProductDelivery | null): string {
|
||
const window = formatClockRange(range);
|
||
if (productDelivery) return [
|
||
`我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`,
|
||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||
].join("");
|
||
return [
|
||
window
|
||
? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。`
|
||
: "我们来把你的出生时间缩小到更准的范围。",
|
||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||
].join("");
|
||
}
|
||
|
||
/**
|
||
* Words the opening body must not use (BUG-1049). The reader has not been told
|
||
* what any of these mean yet. The delivery card still carries the
|
||
* representative-minute boundary (REPRESENTATIVE_MINUTE_DISCLAIMER).
|
||
*/
|
||
export const OPENING_BODY_JARGON = [
|
||
"大运",
|
||
"盘面",
|
||
"分盘",
|
||
"代表分钟",
|
||
"精确到秒",
|
||
"候选",
|
||
"区间",
|
||
"Dasha",
|
||
"dasha",
|
||
] as const;
|
||
|
||
const OPENING_YEAR = /(?:19|20)\d{2}/;
|
||
|
||
/**
|
||
* A model-written opening body is kept only when it says what we do and how
|
||
* (range + 星盘 + 年月), in plain words, without a question, years or the
|
||
* stem's examples. Anything else is replaced by `openingSpokenBody`.
|
||
*/
|
||
export function isAcceptableOpeningBody(
|
||
body: string,
|
||
range: readonly [string, string] | null = null,
|
||
productDelivery?: ProductDelivery | null,
|
||
): boolean {
|
||
const spoken = body.trim();
|
||
if (productDelivery && (!spoken.includes("用来解读的星盘") || /缩小|更准的范围|最可能|已确认/.test(spoken))) return false;
|
||
if (!spoken || OPENING_YEAR.test(spoken)) return false;
|
||
if (/[??]/.test(spoken)) return false;
|
||
if (OPENING_BODY_JARGON.some((word) => spoken.includes(word))) return false;
|
||
const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean);
|
||
if (sentences.length === 0 || sentences.length > 3) return false;
|
||
if (!spoken.includes("星盘") || !spoken.includes("年月")) return false;
|
||
if (range && !(spoken.includes(range[0]) && spoken.includes(range[1]))) return false;
|
||
// Listing the examples again would repeat the stem shown right below.
|
||
const hits = OPENING_COLLECT_DOMAINS.filter((domain) => spoken.includes(domain)).length;
|
||
return hits <= 2;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** BUG-1146: the adopt message that carries the first post-adopt check (VOICE 采用后一行). */
|
||
export function postAdoptCheckLead(savedTime: string | null | undefined): string {
|
||
const clock = /^\d{2}:\d{2}/.exec(savedTime?.trim() ?? "")?.[0];
|
||
return clock ? `已采用 ${clock}。我们再核对一件过去的事。` : "再核对一件过去的事。";
|
||
}
|
||
|
||
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: "这次没有重新比较,等下会再比一次。",
|
||
/** BUG-1178: an event dated after today is not recorded or scored. */
|
||
futureDatedEvent: "有一件事的日期在今天之后,这件先不算;如果年份打错了,直接告诉我正确的年份就行。",
|
||
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
|
||
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
|
||
rangeDeliveryTitle: "目前范围",
|
||
rangeDeliveryMoreLikeThis: "更像这个",
|
||
rangeDeliveryRelativeLikelihood: "相对可能性",
|
||
rangeDeliveryIndistinct: "这几个时刻按现在的方法区分不开。",
|
||
rangeDeliveryNotNarrowed: "这个窗口按现在的方法缩不下去。",
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* D4 (product 2026-09-26): per-column relative likelihood is shown only when
|
||
* the first column leads the second by at least this many percentage points.
|
||
* Below it the numbers carry no discrimination (the in-window constant part of
|
||
* every event score is ~5x the part that moves with the minute), so the card
|
||
* shows no numbers and one `rangeDeliveryIndistinct` sentence instead.
|
||
*/
|
||
export const RANGE_DELIVERY_PERCENT_MIN_GAP = 5;
|
||
|
||
export function rangeDeliveryShowsPercents(
|
||
columns: readonly { probability_percent: number }[],
|
||
): boolean {
|
||
if (columns.length < 2) return false;
|
||
const ranked = columns.map((column) => column.probability_percent).sort((left, right) => right - left);
|
||
return ranked[0]! - ranked[1]! >= RANGE_DELIVERY_PERCENT_MIN_GAP;
|
||
}
|
||
|
||
/** D3 (product 2026-09-26): subtitle under the range title. */
|
||
export function rangeDeliveryMostLikely(time: string): string {
|
||
return `最可能 ${time}`;
|
||
}
|
||
|
||
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 = splitAfterSentencePunctuation(spoken, "。!?")
|
||
.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 = {
|
||
productDelivery?: ProductDelivery | null;
|
||
segmentSummary?: import("./core/segment-summary.ts").SegmentSummary | null;
|
||
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;
|
||
/** Scored tap answers (choice, not unsure). The delivery body counts these, not typed events (BUG-1085). */
|
||
choiceCount?: 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 (input.productDelivery || input.segmentSummary) {
|
||
const head = input.segmentSummary ? chartSummaryCopy(input.segmentSummary)
|
||
: unknownProductDeliveryCopy(input.productDelivery!);
|
||
return `${prefix}${head}${rangeText ? `分钟范围 ${rangeText}。` : ""}${variant === "delivery" ? boundaryCopy : ""}`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* BUG-1085 (2026-09-29 D2): the delivery body no longer says 「对照了 N 件经历,
|
||
* 事件吻合率 P%」. Typed events do not narrow the range (BUG-1084) and the fit
|
||
* rate says nothing about whether candidates separate. Second sentence counts
|
||
* the scored choice questions; an unnarrowed window says so plainly and never
|
||
* invites more events.
|
||
*/
|
||
export function deliveryTurnNarration(input: RangeNarrationInput = {}): string {
|
||
const rangeText = formatClockRange(input.credibleRange ?? null);
|
||
const sentence1 = input.segmentSummary
|
||
? chartSummaryCopy(input.segmentSummary) + (rangeText ? `分钟范围 ${rangeText}。` : "")
|
||
: input.productDelivery ? unknownProductDeliveryCopy(input.productDelivery) + (rangeText ? `分钟范围 ${rangeText}。` : "")
|
||
: rangeText ? `目前范围 ${rangeText}。` : "当前几个候选还分不开。";
|
||
const choices = typeof input.choiceCount === "number" && Number.isFinite(input.choiceCount)
|
||
? Math.max(0, Math.trunc(input.choiceCount))
|
||
: 0;
|
||
const sentence2 = choices > 0 ? `用了 ${choices} 道选择题。` : "";
|
||
const sentence3 = deliveryRangeUnnarrowed(input.openingRange, input.credibleRange)
|
||
? RECTIFICATION_USER_COPY.rangeDeliveryNotNarrowed
|
||
: "";
|
||
// BUG-1147: the segment delivery card prints the same boundary line; say it once.
|
||
return `${sentence1}${sentence2}${sentence3}${input.segmentSummary ? "" : REPRESENTATIVE_MINUTE_DISCLAIMER}`;
|
||
}
|
||
|
||
function deliveryRangeUnnarrowed(
|
||
opening: readonly [string, string] | null | undefined,
|
||
current: readonly [string, string] | null | undefined,
|
||
): boolean {
|
||
if (!opening || !current) return false;
|
||
const openingWidth = rangeWidthMinutes(opening[0], opening[1]);
|
||
const currentWidth = rangeWidthMinutes(current[0], current[1]);
|
||
return openingWidth != null && currentWidth != null && currentWidth >= openingWidth;
|
||
}
|
||
|
||
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 splitAfterSentencePunctuation(replaced, "。!?;;", { consumeFollowingWhitespace: true })
|
||
.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.futureDatedEvent,
|
||
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
|
||
RECTIFICATION_USER_COPY.deferredCareerWindow,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood,
|
||
RECTIFICATION_USER_COPY.rangeDeliveryIndistinct,
|
||
rangeDeliveryMostLikely("05:07"),
|
||
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",
|
||
choiceCount: 5,
|
||
openingRange: ["04:45", "05:15"],
|
||
}),
|
||
deliveryTurnNarration({
|
||
credibleRange: ["04:45", "05:15"],
|
||
representativeTime: "04:53",
|
||
choiceCount: 3,
|
||
openingRange: ["04:45", "05:15"],
|
||
}),
|
||
"关系盘落在天秤座,通常表现为",
|
||
"事业盘落在巨蟹座,通常表现为",
|
||
"刚才那个日子是查过记录,还是凭记忆?",
|
||
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),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* BUG-1136 (D2): the evidence turn's recap is the server's, built from the
|
||
* batch's accepted items. Two or fewer are listed inline; more read as a count
|
||
* and the list sits in the message, collapsed.
|
||
*/
|
||
export function evidenceTurnRecap(lines: readonly string[]): string | null {
|
||
const items = lines.map((line) => line.trim()).filter(Boolean);
|
||
if (!items.length) return null;
|
||
if (items.length <= 2) return `记下了:${items.join("、")}。`;
|
||
return `记下了 ${items.length} 件事。`;
|
||
}
|