merge origin/staging into report chart render branch
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Keep BUG-607 after 602–606 and retain both 09-09 changelog entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 15:13:37 +08:00
co-authored by Cursor
67 changed files with 1826 additions and 172 deletions
@@ -28,7 +28,47 @@ export type CollectDomain =
| "health_pressure"
| "other";
export const GENERIC_COLLECT_QUESTION = "你最容易想起的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家,记得大概年份就行。";
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 domains 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[]>> = {
@@ -76,6 +116,8 @@ export const RECTIFICATION_USER_COPY = {
hostNarrationFallback: "我按现有材料继续往下收。",
collectHandoff: "接下来我们继续。",
collectDeclinedAck: "记下了,这方面先跳过。",
collectSkippedAck: "记下了,这题先放着。",
firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE,
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
@@ -395,6 +437,10 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.collectHandoff,
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,
@@ -35,8 +35,15 @@ import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { alreadyDelivered } from "./delivery-turn-guard";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
import { stripQuestionSentences } from "./collect-prompt";
import {
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingRangeFromCandidateRange,
openingSpokenBody,
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
} from "../user-copy";
import { stripQuestionSentences, trimEvidenceTurnBody } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
@@ -221,13 +228,20 @@ function shouldAutoRetry(errorCode: string, signal?: AbortSignal): boolean {
return !signal?.aborted && isRetryableError(errorCode);
}
function openingBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
function clockWindow(range: { start_time?: string | null; end_time?: string | null } | null | undefined): 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 buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
const confirmed = dossier.evidence.filter((item) => item.status === "confirmed");
const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation");
const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6);
const range = dossier.case.candidateRange;
const uncertaintyType = range && typeof range === "object"
? "用户的出生时间存在一个服务端保存的不确定范围"
const window = clockWindow(range);
const uncertaintyType = window
? `当前搜索窗口 ${window},来自用户在资料里声明的不确定档`
: "用户的出生时间精度仍需通过经历证据核对";
const clue = typeof birthTimeClue === "string" && birthTimeClue.trim()
? birthTimeClue.trim()
@@ -235,12 +249,13 @@ function openingBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): st
return [
"【服务端 opening brief】",
`Case 状态:${dossier.case.status}`,
`当前搜索窗口:${window ?? "尚未锁定"}。来源:intake 声明的不确定档。`,
`出生时间不确定类型:${uncertaintyType}`,
`已有证据摘要:已确认 ${confirmed.length} 条,待澄清或待确认 ${pending.length}${domains.length ? `;已覆盖 ${domains.join("、")}` : ""}`,
...(clue
? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`]
: []),
"正文只打招呼,说明可以慢慢说、记得大概年份即可,不要求一次说完。不要提问,不要举大学、工作、搬家的例子。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题。",
"做法要点:一句当前窗口与核对做法;一句「最后给区间和代表分钟,不给精确到秒」;一句「想到几件说几件,有大概年月就行」并点出升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。一条消息可以报多件,想到几件说几件。不得写具体年份,不要求先准备材料。不要提问。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,题干写成「先说你最容易想起的一两件,年月大概就行」。",
].join("\n");
}
@@ -994,6 +1009,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (next !== answerText) answerText = next;
}
}
if (action === "opening") {
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
if (!isAcceptableOpeningBody(answerText)) {
answerText = openingSpokenBody(openingRange);
}
} else if (action === "evidence") {
answerText = trimEvidenceTurnBody(answerText);
}
const rangeAfterEvidence = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
@@ -1098,7 +1121,7 @@ function buildAgentMessages(
if (options.action === "opening") {
return [bootstrap, {
role: "user",
content: [timeContext, caseContext, openingBrief(dossier, birthTimeClue)].join("\n"),
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue)].join("\n"),
}];
}
return [bootstrap, {
@@ -19,4 +19,6 @@ export const MACHINE_VOICE_LEXICON = [
"有没有记得住时间的收入变化、大笔支出或欠债",
"继续收窄",
"范围还在",
"领先",
"落后",
] as const;
@@ -977,13 +977,20 @@ export async function applyCollectFocusDenial(
focusId: string;
deferFollowup?: boolean;
narrateAdopt?: AdoptNarrationWriter;
status?: "declined" | "skipped";
},
): Promise<{
narration: string;
ack: string;
closeStatus: "declined" | "skipped";
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
focus: ConversationFocus | null;
}> {
const closeStatus = input.status === "skipped" ? "skipped" : "declined";
const ack = closeStatus === "skipped"
? RECTIFICATION_USER_COPY.collectSkippedAck
: RECTIFICATION_USER_COPY.collectDeclinedAck;
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
if (!focus || focus.id !== input.focusId) {
@@ -991,12 +998,14 @@ export async function applyCollectFocusDenial(
}
await resolveV10ConversationFocus(accounting, input.userId, input.caseId, {
focusId: input.focusId,
status: "declined",
status: closeStatus,
evidenceId: null,
});
if (input.deferFollowup === true) {
return {
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
narration: ack,
ack,
closeStatus,
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
@@ -1024,7 +1033,7 @@ export async function applyCollectFocusDenial(
targetDomain: focus.targetDomain,
},
},
}, "declined");
}, closeStatus);
const decision = decideFromDossier(withDeclined, { birthDate });
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
@@ -1040,6 +1049,8 @@ export async function applyCollectFocusDenial(
});
return {
narration: nextInterview.hostNarration,
ack,
closeStatus,
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
focus: nextInterview.focus ?? null,
@@ -1061,15 +1072,16 @@ export async function persistCollectDenialTurn(input: {
userMessage: string | null;
applied: CollectDenialApplied;
}): Promise<{ streamText: string; turnId: string }> {
const ack = input.applied.ack ?? RECTIFICATION_USER_COPY.collectDeclinedAck;
const hasNextStem = Boolean(
input.applied.focus
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
&& input.applied.narration.trim(),
);
const stored = hasNextStem
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
? composeCollectSpokenAssistantText(ack, input.applied.narration)
: input.applied.narration;
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
const streamText = hasNextStem ? ack : input.applied.narration;
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: input.userMessage,
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.18";
export const RECTIFICATION_SKILL_VERSION = "10.0.19";
@@ -19,7 +19,6 @@ import {
} from "./choice-card";
import {
explainRangeChange,
explainScoreMovement,
type ClusterScoreDelta,
} from "./probe-explain.ts";
@@ -118,12 +117,8 @@ export function composeChoiceNarration(input: {
return "已记录你的选择。这是盘外核对,不会改候选分数。";
}
if (input.appliedInference) {
const movement = explainScoreMovement(input.deltasByCluster ?? []);
const range = explainRangeChange(input.credibleBefore, input.credibleAfter);
if (movement || range) {
return ["已记录你的选择", movement, range].filter(Boolean).join("。") + "。";
}
return "已记录你的选择,并更新了候选比较。";
return `已记录,${range || "范围没变"}`;
}
return "已记录你的选择。";
}
@@ -7,6 +7,30 @@
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
export const EVIDENCE_VALUE_JUDGMENT_PHRASES = [
"很有帮助",
"很有价值",
"很有分量",
"特别有用",
] as const;
function spokenSentences(body: string): string[] {
return body.split(SENTENCE_SPLIT).map((part) => part.trim()).filter(Boolean);
}
export function trimEvidenceTurnBody(body: string): string {
const spoken = body.trim();
if (!spoken) return "";
const sentences = spokenSentences(spoken);
let text = sentences.length > 2
? (/[。!??]$/.test(sentences[0] ?? "") ? sentences[0] ?? "" : `${sentences[0] ?? ""}`)
: spoken;
for (const phrase of EVIDENCE_VALUE_JUDGMENT_PHRASES) {
text = text.replaceAll(phrase, "");
}
return text.replace(/[,、]{2,}/g, "").replace(/[ \t]+/g, " ").trim();
}
function isQuestionSentence(text: string, stem: string): boolean {
if (!text) return false;
if (stem && text === stem) return true;
@@ -84,6 +84,7 @@ import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
withFirstDatedCollectInvite,
} from "../user-copy.ts";
export { GENERIC_COLLECT_QUESTION };
@@ -194,6 +195,7 @@ export type MethodFollowup = Readonly<{
selection_score?: number;
probe_id?: string;
collect_retry?: boolean;
invite_more_once?: boolean;
block_periods?: Readonly<Record<"A" | "B" | "C", BlockScanBlock>>;
widen_windows?: Readonly<Record<"A" | "B", import("./window-widen.ts").WidenWindowOption>>;
date_reliability_evidence_id?: string;
@@ -501,9 +503,15 @@ function topicIntent(topic: Readonly<Record<string, unknown>>): string {
return typeof topic.intent === "string" ? topic.intent : "";
}
function topicStatus(topic: Readonly<Record<string, unknown>>): string {
return typeof topic.status === "string" ? topic.status : "";
}
function declinedDomains(
topics: readonly Readonly<Record<string, unknown>>[],
options: { includeSkipped?: boolean } = {},
): Set<string> {
const includeSkipped = options.includeSkipped !== false;
const domains = new Set<string>();
for (const topic of topics) {
if (isOpeningOtherCollectTopic(topic)) continue;
@@ -512,6 +520,7 @@ function declinedDomains(
// Missing intent stays collect for legacy declined_skipped rows.
if (intent === "distinguish_candidates") continue;
if (intent && intent !== "collect_method_evidence") continue;
if (!includeSkipped && topicStatus(topic) === "skipped") continue;
const domain = topicDomain(topic);
if (domain) domains.add(domain);
}
@@ -739,7 +748,7 @@ export function reverseVerifyRemainingForAdopt(input: {
return remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declinedDomains(input.declinedTopics ?? []),
declinedDomains(input.declinedTopics ?? [], { includeSkipped: false }),
new Set(input.askedProbeKeys ?? []),
input.birthDate,
);
@@ -1204,7 +1213,12 @@ export function spokenFollowupForUser(
const mapped = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
: USER_COLLECT_QUESTION[domain];
return mapped ?? null;
if (!mapped) return null;
const dated = (DATED_COLLECT_ORDER as readonly string[]).includes(domain);
if (followup.invite_more_once && dated && followup.collect_retry !== true) {
return withFirstDatedCollectInvite(mapped);
}
return mapped;
}
export const DATED_COLLECT_ORDER = [
@@ -1253,15 +1267,29 @@ function datedCollectDomainBlocked(
|| answeredYes.has(domain);
}
export function datedCollectAlreadyAsked(
declined: ReadonlySet<string>,
topics: readonly Readonly<Record<string, unknown>>[] = [],
): boolean {
if (DATED_COLLECT_ORDER.some((domain) => declined.has(domain))) return true;
return DATED_COLLECT_ORDER.some((domain) => domainCollectFocusAsked(topics, domain));
}
export function nextDatedCollectFollowup(
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
answeredYes: ReadonlySet<string> = new Set(),
options?: { askedDatedCollect?: boolean },
): MethodFollowup | null {
for (const domain of DATED_COLLECT_ORDER) {
if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue;
const next = datedCollectFollowup(domain, evidence);
if (next) return next;
if (next) {
return {
...next,
invite_more_once: options?.askedDatedCollect !== true,
};
}
}
return null;
}
@@ -1350,7 +1378,9 @@ export function exhaustionSpokenCollectFollowup(input: {
}): MethodFollowup | null {
const declined = declinedDomains(input.declinedTopics ?? []);
const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes);
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, input.declinedTopics ?? []),
});
if (dated) return dated;
if (
!declined.has("occupation")
@@ -1876,9 +1906,15 @@ export function buildMethodFollowupPlan(input: {
const collectRetry = base.intent === "collect_method_evidence"
&& typeof base.domain === "string"
&& domainCollectFocusAsked(askedRows, base.domain);
const inviteMoreOnce = base.intent === "collect_method_evidence"
&& typeof base.domain === "string"
&& (DATED_COLLECT_ORDER as readonly string[]).includes(base.domain)
&& !collectRetry
&& !datedCollectAlreadyAsked(declinedDomains(input.declinedTopics ?? []), askedRows);
return {
...base,
...(collectRetry ? { collect_retry: true } : {}),
...(inviteMoreOnce ? { invite_more_once: true } : { invite_more_once: false }),
choice_frame: attach
? buildChoiceFrame(base, {
observations: input.observations,
@@ -1901,6 +1937,7 @@ export function buildMethodFollowupPlan(input: {
const ask = (why: string, varga: string, extra = "") =>
agentHint(why, varga, extra, input.evidence);
const declined = declinedDomains(input.declinedTopics ?? []);
const reverseVerifyDeclined = declinedDomains(input.declinedTopics ?? [], { includeSkipped: false });
const answeredYes = domainsAnsweredYes(input.answeredProbes, [
...(input.eventProbes ?? []),
...(input.eventClarificationProbes ?? []),
@@ -2129,7 +2166,7 @@ export function buildMethodFollowupPlan(input: {
const probe = remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declined,
reverseVerifyDeclined,
askedKeys,
input.birthDate,
)[0] ?? null;
@@ -2504,7 +2541,12 @@ export function buildMethodFollowupPlan(input: {
&& precisionCard?.choice_frame
) {
next = precisionCard;
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes))) {
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, [
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
]),
}))) {
next = makeFollowup(datedCollect);
} else if (!occupationCovered) {
if (meetsAcceptanceEventQuality(input.evidence)) {
@@ -2680,7 +2722,12 @@ export function buildMethodFollowupPlan(input: {
next = null;
}
if (!next) {
const leftoverDated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
const leftoverDated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, [
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
]),
});
if (leftoverDated) {
next = makeFollowup(leftoverDated);
} else if (!occupationCovered) {
@@ -223,6 +223,20 @@ export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): stri
return "";
}
function clockMinutes(value: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
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 explainRangeChange(
before: readonly [string, string] | null | undefined,
after: readonly [string, string] | null | undefined,
@@ -233,5 +247,11 @@ export function explainRangeChange(
const endAfter = clockTime(after?.[1]);
if (!startBefore || !endBefore || !startAfter || !endAfter) return "";
if (startBefore === startAfter && endBefore === endAfter) return "范围没变";
return `范围从 ${startBefore}${endBefore} 收到 ${startAfter}${endAfter}`;
const afterLabel = `${startAfter}${endAfter}`;
const widthBefore = rangeWidthMinutes(startBefore, endBefore);
const widthAfter = rangeWidthMinutes(startAfter, endAfter);
if (widthAfter != null && widthBefore != null && widthAfter > widthBefore) {
return `范围变为 ${afterLabel}`;
}
return `范围收到 ${afterLabel}`;
}
@@ -36,6 +36,14 @@ export function shouldDeclineCollectFocus(
return classified?.intent === "answer_current_focus" && classified.answer_class === "no";
}
export function isCollectDeclineUtterance(message: string): boolean {
return message.trim().replace(/[.!?]+$/u, "") === "";
}
export function isCollectSkipUtterance(message: string): boolean {
return message.trim().replace(/[.!?]+$/u, "") === "";
}
export function shouldContinueAgentForDatedEvent(
classified: RectificationTurnIntent | null,
): boolean {
@@ -66,6 +66,11 @@ export type RectificationNatalRecast = Readonly<{
confirmation_allowed: false;
}>;
export type RectificationInferenceMark = Readonly<{
time: string;
eliminated: boolean;
}>;
export type RectificationCandidateResult = Readonly<{
resultId: string;
candidates: readonly RectificationCandidate[];
@@ -95,6 +100,8 @@ export type RectificationCandidateResult = Readonly<{
credibleRange: readonly [string, string] | null;
rangeDelivery: RangeDeliveryProjection | null;
verificationReportMarkdown: string | null;
/** Inference-layer minutes including eliminated ones. Empty when no inference_state. */
inferenceMarks?: readonly RectificationInferenceMark[];
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -284,6 +291,24 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null {
return [start, end];
}
function parseInferenceMarks(receipt: Record<string, unknown> | null): readonly RectificationInferenceMark[] {
const state = record(receipt?.inference_state);
if (!state || !Array.isArray(state.candidates)) return [];
const marks: RectificationInferenceMark[] = [];
const seen = new Set<string>();
for (const value of state.candidates) {
const row = record(value);
const clock = time(row?.time);
if (!clock || seen.has(clock)) continue;
seen.add(clock);
marks.push({
time: clock,
eliminated: row?.status === "eliminated",
});
}
return marks;
}
export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null {
const snapshot = record(value);
if (!snapshot || typeof snapshot.resultId !== "string") return null;
@@ -372,6 +397,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
?? verificationMarkdownFromUnknown(
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
),
inferenceMarks: parseInferenceMarks(receipt),
};
}
@@ -45,7 +45,7 @@ export type RectificationTimelineView = Readonly<{
ticks: readonly TimelineTick[];
/** `05:0705:09` */
rangeLabel: string;
/** `2 分钟` */
/** `3 分钟` */
widthLabel: string;
}>;
@@ -131,12 +131,23 @@ function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] {
return ticks;
}
export type TimelineInferenceMark = Readonly<{
time: string;
eliminated: boolean;
}>;
export type RectificationTimelineInput = Readonly<{
/** `candidate_range`: the case's current search window, and the axis. */
searchWindow: readonly [string, string] | null;
/** `credible_range`; absent during block scan, where the band is the window. */
credibleRange: readonly [string, string] | null;
candidateTimes: readonly string[];
/**
* Inference-layer minutes with elimination status. When present and non-empty,
* these are the marks; `eliminated` is the only in/out judge. When absent,
* engine `candidateTimes` are drawn solid.
*/
inferenceMarks?: readonly TimelineInferenceMark[];
stage: RectificationTimelineStage | null;
}>;
@@ -177,9 +188,12 @@ export function buildRectificationTimeline(
// boundaries do not reach the client as structured data (see PROGRESS).
const marks: TimelineMark[] = [];
if (input.stage === "minute") {
const sourced = input.inferenceMarks && input.inferenceMarks.length > 0
? input.inferenceMarks.map((mark) => ({ time: mark.time, eliminated: mark.eliminated }))
: input.candidateTimes.map((time) => ({ time, eliminated: false }));
const seen = new Set<number>();
for (const raw of input.candidateTimes) {
const parsed = parseClockMinutes(raw);
for (const item of sourced) {
const parsed = parseClockMinutes(item.time);
if (parsed === null) continue;
const at = alignToAxis(parsed, axisStart, axisEnd);
if (at < axisStart || at > axisEnd) continue;
@@ -188,8 +202,8 @@ export function buildRectificationTimeline(
marks.push({
key: `m${at}`,
percent: timelinePercent(at, axisStart, axisEnd),
// Closed interval: a candidate sitting on a boundary is still in range.
state: at >= bandStart && at <= bandEnd ? "in" : "out",
// Status from the inference layer, never band position (BUG-603).
state: item.eliminated ? "out" : "in",
});
}
marks.sort((left, right) => left.percent - right.percent);
@@ -203,6 +217,6 @@ export function buildRectificationTimeline(
marks,
ticks: buildTicks(axisStart, axisEnd),
rangeLabel: `${formatClockMinutes(bandStart)}${formatClockMinutes(bandEnd)}`,
widthLabel: timelineDurationLabel(bandEnd - bandStart),
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
};
}