/** * 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> = { education: ["上学", "大学", "毕业", "考试", "学业"], finance: ["收入", "买房", "贷款", "欠债", "钱"], relocation: ["搬家", "住", "外地"], health_pressure: ["生病", "受伤", "住院", "压力"], family: ["家里", "父母", "家人", "添丁"], career: ["工作", "入职", "换工作"], relationship: ["交往", "分手", "结婚", "感情"], occupation: ["工作", "做什么", "职业"], }; export const USER_COLLECT_QUESTION: Readonly> = { 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> = { 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> = { 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> = { 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 = [ [/冲突时不能按更高把握收口。?/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} 件事。`; }