feat(rectification): 出卡加精度门槛,补经历改成系统点名
Independent Staging Quality Gate / validate (push) Failing after 6m28s
Independent Staging Quality Gate / publish (push) Skipped

宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、
用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事
都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。

BUG-740~743
This commit is contained in:
jesse-ux
2026-09-16 18:35:27 +08:00
parent 317e9f1886
commit cfb41daf3d
75 changed files with 4763 additions and 383 deletions
+45 -1
View File
@@ -2595,7 +2595,8 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
}
/* Last in the row and first to be clipped: the count is context, not the
answer, and it is the only item the reader can do without. */
.rectification-timeline__answered {
.rectification-timeline__answered,
.rectification-timeline__dated {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -3111,6 +3112,49 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.rectification-choice-card.is-embedded .birth-time-choice-question legend {
display: unset;
}
.rectification-event-entry {
display: grid;
gap: var(--space-3);
margin: var(--space-3) 0 var(--space-4);
margin-inline-start: var(--assistant-content-inset);
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
}
.rectification-event-entry__label {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-event-entry__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rectification-event-entry__chip {
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: 999px;
background: transparent;
color: var(--color-ink);
font-size: var(--type-caption);
}
.rectification-event-entry__chip[data-selected="true"] {
border-color: var(--color-action);
background: var(--color-action-soft);
color: var(--color-action);
}
.rectification-event-date {
display: grid;
gap: var(--space-2);
}
.rectification-event-date__footer {
display: flex;
justify-content: flex-end;
padding: 0 var(--space-3) var(--space-3);
}
.rectification-choice-why {
margin: 0 0 var(--space-3);
color: var(--color-ink-secondary);
+1
View File
@@ -1736,6 +1736,7 @@ export default function Home() {
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}`}
declaredTime={rectificationDeclaredTime}
birthDate={profile.date || null}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
@@ -52,6 +52,7 @@ export type RectificationChatPanel = Readonly<{
export type ConversationalBirthTimeRectificationProps = Readonly<{
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
declaredTime: string | null;
birthDate: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -62,6 +63,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
export function ConversationalBirthTimeRectification({
declaredTime,
birthDate,
models,
selectedModelId,
onSelectModel,
@@ -71,6 +73,7 @@ export function ConversationalBirthTimeRectification({
}: ConversationalBirthTimeRectificationProps) {
const props = {
declaredTime,
birthDate,
models,
selectedModelId,
onSelectModel,
@@ -0,0 +1,100 @@
"use client";
import { useMemo, useState } from "react";
import { EventDatePicker, type EventDateValue } from "@/components/event-date-picker";
import { Button } from "@/components/ui/button";
import {
COLLECT_KIND_ORDER,
KIND_ORAL,
type CollectKind,
} from "@/lib/rectification-agentic/v9/collection-question-pool";
import { RANGE_DELIVERY_DOMAIN_LABEL } from "@/lib/rectification-agentic/user-copy";
export type EventDateEntrySubmit = Readonly<{
domain: CollectKind;
year: number;
month: number;
day: number | null;
}>;
export function formatEventDateEntryMessage(input: EventDateEntrySubmit): string {
const when = input.day
? `${input.year}${input.month}${input.day}`
: `${input.year}${input.month}`;
return `${when}${KIND_ORAL[input.domain]}`;
}
export function eventDateYearRange(
birthYear: number | null | undefined,
nowYear = new Date().getFullYear(),
): { minYear: number; maxYear: number } {
const maxYear = nowYear;
if (typeof birthYear === "number" && birthYear >= 1900 && birthYear <= maxYear) {
return { minYear: birthYear, maxYear };
}
return { minYear: Math.max(1900, maxYear - 80), maxYear };
}
export function EventDateEntryCard({
defaultDomain,
defaultYear,
defaultMonth,
minYear,
maxYear,
disabled = false,
onSubmit,
}: {
readonly defaultDomain: CollectKind;
readonly defaultYear?: number;
readonly defaultMonth?: number;
readonly minYear: number;
readonly maxYear: number;
readonly disabled?: boolean;
readonly onSubmit: (value: EventDateEntrySubmit) => void;
}) {
const [domain, setDomain] = useState<CollectKind>(defaultDomain);
const initial = useMemo<EventDateValue | null>(() => {
if (!defaultYear || defaultYear < minYear || defaultYear > maxYear) return null;
const month = defaultMonth && defaultMonth >= 1 && defaultMonth <= 12 ? defaultMonth : 1;
return { year: defaultYear, month, day: null };
}, [defaultYear, defaultMonth, minYear, maxYear]);
const [date, setDate] = useState<EventDateValue | null>(initial);
return (
<form
className="rectification-event-entry"
onSubmit={(event) => {
event.preventDefault();
if (!date || disabled) return;
onSubmit({ domain, year: date.year, month: date.month, day: date.day });
}}
>
<p className="rectification-event-entry__label"></p>
<div className="rectification-event-entry__chips" role="group" aria-label="经历类型">
{COLLECT_KIND_ORDER.map((kind) => (
<button
key={kind}
type="button"
className="rectification-event-entry__chip"
data-selected={kind === domain}
disabled={disabled}
onClick={() => setDomain(kind)}
>
{RANGE_DELIVERY_DOMAIN_LABEL[kind] ?? kind}
</button>
))}
</div>
<EventDatePicker
value={date}
minYear={minYear}
maxYear={maxYear}
disabled={disabled}
onChange={setDate}
/>
<Button type="submit" disabled={disabled || !date}>
</Button>
</form>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export type EventDateValue = Readonly<{
year: number;
month: number;
day: number | null;
}>;
type EventDatePickerProps = {
readonly value: EventDateValue | null;
readonly minYear: number;
readonly maxYear: number;
readonly disabled?: boolean;
readonly onChange: (value: EventDateValue) => void;
};
function clampYear(year: number, minYear: number, maxYear: number): number {
return Math.min(maxYear, Math.max(minYear, year));
}
function formatValue(value: EventDateValue | null): string {
if (!value) return "选择年月";
if (value.day) return `${value.year}${value.month}${value.day}`;
return `${value.year}${value.month}`;
}
export function EventDatePicker({
value,
minYear,
maxYear,
disabled = false,
onChange,
}: EventDatePickerProps) {
const labelId = useId();
const valueId = useId();
const [open, setOpen] = useState(false);
const selected = value
? new Date(value.year, value.month - 1, value.day ?? 1)
: undefined;
const startMonth = new Date(minYear, 0);
const endMonth = new Date(maxYear, 11);
return (
<div className="rectification-event-date">
<span id={labelId}></span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button
type="button"
variant="outline"
disabled={disabled}
aria-labelledby={`${labelId} ${valueId}`}
data-empty={!value}
className="w-full justify-start px-3 text-left font-normal data-[empty=true]:text-muted-foreground"
/>}
>
<span id={valueId}>{formatValue(value)}</span>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
key={value ? `${value.year}-${value.month}-${value.day ?? 0}` : "empty"}
mode="single"
className="[--cell-size:2.75rem] [&_button[data-selected-single=true]]:text-primary-foreground!"
selected={selected}
defaultMonth={selected ?? new Date(clampYear(maxYear - 10, minYear, maxYear), 0)}
captionLayout="dropdown"
navLayout="after"
startMonth={startMonth}
endMonth={endMonth}
reverseYears
disabled={{ before: new Date(minYear, 0, 1), after: new Date(maxYear, 11, 31) }}
onSelect={(nextDate) => {
if (nextDate === undefined) return;
onChange({
year: nextDate.getFullYear(),
month: nextDate.getMonth() + 1,
day: nextDate.getDate(),
});
}}
/>
<div className="rectification-event-date__footer">
<Button
type="button"
variant="ghost"
disabled={disabled || !value}
onClick={() => {
if (!value) return;
onChange({ year: value.year, month: value.month, day: null });
setOpen(false);
}}
>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}
@@ -113,6 +113,13 @@ import {
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import {
EventDateEntryCard,
eventDateYearRange,
formatEventDateEntryMessage,
} from "./event-date-entry-card";
import { parseYearEntryQuestionId } from "@/lib/rectification-agentic/v9/collection-question-pool";
import { birthYearFromDate } from "@/lib/rectification-agentic/v9/adult-floor";
import {
applyLiveCandidateOffer,
copyTextForMessage,
@@ -221,6 +228,8 @@ type RectificationAgenticChatProps = Readonly<{
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, for the board before any candidate exists. */
declaredTime: string | null;
/** Profile birth date; the year-entry picker spans this year through the current year. */
birthDate: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -389,6 +398,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
initialTurns,
initialSnapshot,
declaredTime,
birthDate,
models,
selectedModelId,
onSelectModel,
@@ -1412,7 +1422,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null,
});
const yearEntry = parseYearEntryQuestionId(currentQuestion?.question_id);
const yearRange = eventDateYearRange(birthYearFromDate(birthDate));
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const liveSelectionCardKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
@@ -1745,6 +1758,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
</>
) : yearEntry ? (
<>
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
<EventDateEntryCard
defaultDomain={yearEntry.domain}
defaultYear={yearEntry.year ?? undefined}
defaultMonth={yearEntry.month ?? undefined}
minYear={yearRange.minYear}
maxYear={yearRange.maxYear}
disabled={busy || readonly}
onSubmit={(value) => {
void send("message", formatEventDateEntryMessage(value));
}}
/>
</>
) : (
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
)}
@@ -4,10 +4,7 @@ import type { RectificationCandidateResult } from "@/lib/rectification-candidate
import {
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
rangeDeliveryCaptionWithoutClosedInvite,
rangeDeliveryClosedInviteFromHint,
rangeDeliveryEventCopy,
rangeDeliveryShowsOpenCollectInvite,
} from "@/lib/rectification-agentic/user-copy";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
import {
@@ -47,17 +44,10 @@ export function RectificationRangeDelivery({
const markdown = delivery?.verification_markdown
?? result.verificationReportMarkdown;
const sharedTraits = delivery?.shared_traits ?? [];
const closedInvite = rangeDeliveryClosedInviteFromHint(delivery?.narrow_hint);
const showOpenCollectInvite = rangeDeliveryShowsOpenCollectInvite({
hint: delivery?.narrow_hint,
columns,
});
// BUG-691: while the birth record disagrees with the range the adopt entry says what
// it actually does — swap the charting clock — instead of the neutral 「更像这个」.
const recordConflict = delivery?.record_conflict ?? null;
const caption = showOpenCollectInvite || closedInvite
? rangeDeliveryCaptionWithoutClosedInvite(delivery?.narrow_hint)
: delivery?.narrow_hint ?? null;
const caption = delivery?.narrow_hint ?? null;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
@@ -67,11 +57,6 @@ export function RectificationRangeDelivery({
{caption ? (
<p className="rectification-range-delivery__narrow">{caption}</p>
) : null}
{showOpenCollectInvite && closedInvite ? (
<p className="rectification-range-delivery__invite">
{closedInvite}
</p>
) : null}
{delivery?.provenance_line ? (
<p className="rectification-range-delivery__provenance">{delivery.provenance_line}</p>
) : null}
@@ -46,6 +46,9 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
{view.answeredLabel ? (
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
) : null}
{view.datedEventLabel ? (
<span className="rectification-timeline__dated">{view.datedEventLabel}</span>
) : null}
</p>
<div
className="rectification-timeline__axis"
@@ -56,6 +59,7 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
view.widthLabel,
view.workingLabel,
view.answeredLabel,
view.datedEventLabel,
].filter(Boolean).join("")}
>
<span className="rectification-timeline__rule" />
@@ -0,0 +1,33 @@
/**
* Range-delivery precision gate (D1). Width, display-gap, and exact score
* tie are independent of scoring / cluster merge.
*/
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
import { RANGE_DELIVERY_TIE_PERCENT, rangeWidthMinutes } from "../user-copy.ts";
export function deliveryWidthMinutes(
range: readonly [string, string] | null | undefined,
): number | null {
if (!range?.[0] || !range[1]) return null;
if (range[0] === range[1]) return 0;
return rangeWidthMinutes(range[0], range[1]);
}
export function probabilityToPercent(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
const raw = value <= 1 ? value * 100 : value;
return Math.min(100, Math.round(raw));
}
export function computePrecisionGateMet(input: {
range: readonly [string, string] | null | undefined;
topTwoPercents: readonly number[];
tiedForFirst: boolean;
}): boolean {
if (input.tiedForFirst) return false;
const width = deliveryWidthMinutes(input.range);
if (width == null || width > RECTIFICATION_POLICY.deliveryMaxWidthMinutes) return false;
if (input.topTwoPercents.length < 2) return input.topTwoPercents.length === 1;
return input.topTwoPercents[0]! - input.topTwoPercents[1]! > RANGE_DELIVERY_TIE_PERCENT;
}
@@ -231,6 +231,10 @@ export type DecideRectificationInput = Readonly<{
windowWidenSuggested?: boolean;
refreshExhausted?: boolean;
targetedCollectExhausted?: boolean;
/** D1: width ≤ 10, top-two display gap > 3, not an exact first-place tie. */
precisionGateMet?: boolean;
/** Guided window / skip-retry / uncovered-domain pool is empty. */
guidedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
/** Unasked D9/D10 style questions remain. */
@@ -359,6 +363,16 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& !probe
&& input.targetedCollectExhausted !== false
) {
if (!mayDeliverOnPrecision(input)) {
return collect(
separation,
holdout,
range,
probe,
waitToNarrowCapability(rangeDeliveryCapability),
stopClass.reason,
);
}
return deliverRange(
input,
separation,
@@ -372,7 +386,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (coverageBlocks) {
const engineOffers = input.engineCeiling.acceptanceAllowed
|| input.engineCeiling.proposeAllowed;
const narrowingOpen = stillNeedNarrowing(input);
const narrowingOpen = stillNeedNarrowing(input) && !mayDeliverOnPrecision(input);
if (
stopClass?.kind !== "keep_collecting"
&& input.trainingGateOpen !== false
@@ -406,17 +420,37 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return holdoutValidation(separation, range, capability);
}
// coverageBlocks already collected when the training gate is closed.
// Dated-pool empty is not delivery until refresh and targeted collect are
// exhausted (BUG-654). Personality still does not occupy this slot.
// Uncovered collect lines keep collecting only while D1 is unmet
// (BUG-654 + precision gate). Personality still does not occupy this slot.
// Omitted flags mean the helper/unit path: do not wait. Production
// decideFromDossier always passes explicit booleans.
if (stillNeedNarrowing(input)) {
if (stillNeedNarrowing(input) && !mayDeliverOnPrecision(input)) {
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
}
if (stopClass?.kind === "exhausted") {
if (!mayDeliverOnPrecision(input)) {
return collect(
separation,
holdout,
range,
probe,
waitToNarrowCapability(rangeDeliveryCapability),
stopReason,
);
}
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
if (!mayDeliverOnPrecision(input)) {
return collect(
separation,
holdout,
range,
probe,
waitToNarrowCapability(rangeDeliveryCapability),
"probe_pool_exhausted",
);
}
return finish("adopt_representative", {
input,
separation,
@@ -427,9 +461,29 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
stopReason: "probe_pool_exhausted",
});
}
if (!mayDeliverOnPrecision(input)) {
return collect(
separation,
holdout,
range,
probe,
waitToNarrowCapability(rangeDeliveryCapability),
stopReason,
);
}
return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability);
}
if (stopClass?.kind === "exhausted") {
if (!mayDeliverOnPrecision(input)) {
return collect(
separation,
holdout,
range,
probe,
waitToNarrowCapability(rangeDeliveryCapability),
stopReason,
);
}
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (input.accepted) {
@@ -445,7 +499,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
capability: rangeDeliveryCapability,
});
}
if (input.datedMethodCollectOpen === true && !input.userStopped) {
if (input.datedMethodCollectOpen === true && !input.userStopped && !mayDeliverOnPrecision(input)) {
return collect(separation, holdout, range, null, capability, stopReason);
}
if (confirmationAllowed) {
@@ -588,6 +642,20 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
}
/**
* Exhausted / offer delivery is blocked until D1, the user stops, or the
* guided pool is empty. Omitted flags keep the helper/unit path delivering.
*/
export function mayDeliverOnPrecision(input: DecideRectificationInput): boolean {
if (input.userStopped === true) return true;
if (input.guidedCollectExhausted === true) return true;
if (input.precisionGateMet === true) return true;
if (input.precisionGateMet === undefined && input.guidedCollectExhausted === undefined) {
return true;
}
return false;
}
export function shouldHoldForTieBreak(
input: Pick<
DecideRectificationInput,
@@ -119,30 +119,6 @@ export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
};
/** Exact-day examples asked first after the seven targeted-collect lines close. */
export const RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES = [
"登记结婚那天",
"入职第一天",
"手术那天",
"孩子出生那天",
"拿到录取通知那天",
] as const;
/** Year-month examples outside the seven targeted-collect lines. */
export const RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES = [
"换专业",
"出国",
"打官司",
"创业",
"重病",
"亲人离世",
] as const;
/** Stable invite stem after the count-dependent subject. */
export const RANGE_DELIVERY_COLLECT_CLOSED_INVITE = `你要是还记得确切哪一天的事,不限领域,说出来我接着算——${RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.join("、")}都成。记不得哪一天的,有年月也行,比如${RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.join("、")}`;
const CLOSED_INVITE_HEAD = /(?:|)?$/u;
/** Count=2 → 「这两分钟」; >2 → 「这几个候选」; unknown / 0 / 1 → omit. */
export function rangeDeliveryCollectClosedSubject(candidateCount?: number): string | null {
if (candidateCount === 2) return "这两分钟";
@@ -154,16 +130,7 @@ export function rangeDeliveryCollectClosedSubject(candidateCount?: number): stri
export function rangeDeliveryCollectClosed(candidateCount?: number): string {
const subject = rangeDeliveryCollectClosedSubject(candidateCount);
const head = subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。";
return `${head}${RANGE_DELIVERY_COLLECT_CLOSED_INVITE}`;
}
export function rangeDeliveryClosedInviteFromHint(hint: string | null | undefined): string | null {
if (!hint) return null;
const at = hint.indexOf(RANGE_DELIVERY_COLLECT_CLOSED_INVITE);
if (at < 0) return null;
const head = hint.slice(0, at).match(CLOSED_INVITE_HEAD);
return `${head?.[0] ?? ""}${RANGE_DELIVERY_COLLECT_CLOSED_INVITE}`;
return subject ? `${subject}按现有信息分不开。` : "按现有信息分不开。";
}
export const RECTIFICATION_USER_COPY = {
@@ -179,8 +146,8 @@ export const RECTIFICATION_USER_COPY = {
hostNarrationFallback: "我按现有材料继续往下收。",
collectHandoff: "接下来我们继续。",
evidenceNotRecorded: "这件我还没记上。请再说一次大概年月和发生的事。",
collectDeclinedAck: "记下了,这方面先跳过。",
collectSkippedAck: "记下了,这题先放着。",
collectDeclinedAck: "记下了,这条按没有发生过记。",
collectSkippedAck: "记下了,这题先放着,后面换个问法再问一次。",
firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE,
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
@@ -220,26 +187,6 @@ export function rangeDeliveryTopTwoTied(
<= RANGE_DELIVERY_TIE_PERCENT;
}
export function rangeDeliveryCaptionWithoutClosedInvite(
hint: string | null | undefined,
): string | null {
if (!hint) return null;
const invite = rangeDeliveryClosedInviteFromHint(hint);
if (!invite) return hint;
const stripped = hint.replaceAll(invite, "").replace(/[]\s*$/u, "").trim();
return stripped.length > 0 ? stripped : null;
}
export function rangeDeliveryShowsOpenCollectInvite(input: {
hint: string | null | undefined;
columns: readonly { probability_percent: number }[];
}): boolean {
return Boolean(
rangeDeliveryClosedInviteFromHint(input.hint)
&& rangeDeliveryTopTwoTied(input.columns),
);
}
export const RANGE_DELIVERY_DOMAIN_LABEL: Readonly<Record<string, string>> = {
education: "学业",
career: "事业",
@@ -343,6 +343,8 @@ function adoptHostNarration(input: {
catalog.remainingSplitTimes,
catalog.remainingCandidateCount,
catalog.remainingCredibleRange,
catalog.guidedCollectWindows,
true,
);
if (!hint || delivered.includes(hint)) return delivered;
return `${delivered} ${hint}`.replace(/\s+/g, " ").trim();
@@ -452,8 +454,11 @@ function isTargetedCollectFocus(focus: {
? focus.expectedAnswerSchema.collect_kind
: "";
return questionId.startsWith("collect:targeted:")
|| questionId.startsWith("collect:guided:")
|| kind.startsWith("targeted:")
|| kind.startsWith("guided:")
|| schemaKind.startsWith("targeted:")
|| schemaKind.startsWith("guided:")
|| focus.expectedAnswerSchema?.targeted_collect === true;
}
@@ -500,6 +505,9 @@ function dossierWithClosedFocus<T extends {
|| (status === "resolved" && isTargetedCollectFocus(focus))
|| intent === "reverse_verify",
);
const questionId = focus?.questionId ?? "";
const retryClosed = (status === "skipped" || status === "declined")
&& (questionId.includes(":retry") || String(focus?.targetKind ?? "").endsWith(":retry"));
const declinedSkippedTopics = rememberClosed
? [
...dossier.conversationSummary.declinedSkippedTopics,
@@ -510,6 +518,7 @@ function dossierWithClosedFocus<T extends {
...(focus?.questionId ? { questionId: focus.questionId } : {}),
...(focus?.targetKind ? { target_kind: focus.targetKind } : {}),
...(focus?.expectedAnswerSchema ? { expected_answer_schema: focus.expectedAnswerSchema } : {}),
...(retryClosed ? { retry: true } : {}),
},
]
: dossier.conversationSummary.declinedSkippedTopics;
@@ -2053,6 +2062,8 @@ async function persistExhaustionCollect(input: {
catalog.remainingSplitTimes,
catalog.remainingCandidateCount,
catalog.remainingCredibleRange,
catalog.guidedCollectWindows,
true,
);
const range = nonConvergingRangeNarration({
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
@@ -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.26";
export const RECTIFICATION_SKILL_VERSION = "10.0.27";
@@ -27,11 +27,15 @@ export const CHOICE_STOP_MESSAGE = "先这样";
export const CHOICE_SKIP_QUESTION_LABEL = "这题跳过";
export const CHOICE_SKIP_QUESTION_MESSAGE = "这题跳过";
export const HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
export const TARGETED_COLLECT_OPTION_A = "有过这件事";
export const TARGETED_COLLECT_OPTION_B = "没有发生过";
export const TARGETED_COLLECT_OPTION_A = "有,我来填时间";
export const TARGETED_COLLECT_OPTION_B = "这类事都没有过";
export const TARGETED_COLLECT_OPTION_C = "记不太清楚";
export const TARGETED_COLLECT_OPTION_D = "这条先跳过";
export const TARGETED_COLLECT_WHY_USER = "答有的话再说大概年月,没有或记不清就问下一条。";
export const GUIDED_WINDOW_OPTION_A = "有,我来填时间";
export const GUIDED_WINDOW_OPTION_B = "这段没有";
export const GUIDED_WINDOW_OPTION_C = "记不清";
export const GUIDED_WINDOW_OPTION_D = "这条先跳过";
export const TARGETED_COLLECT_WHY_USER = "答有的话再用选择器填年月,没有或记不清就问下一条。";
export const TARGETED_COLLECT_KEEP_HINT = "照发服务端卡片,不要自己写题干。";
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -578,6 +582,10 @@ export function buildTargetedCollectExistenceFrame(input: {
questionId: string;
methodId: string;
prompt: string;
optionA?: string;
optionB?: string;
optionC?: string;
optionD?: string;
}): RectificationChoiceFrame | null {
const prompt = clippedCopy(input.prompt, 4, 80);
if (!prompt) return null;
@@ -588,10 +596,10 @@ export function buildTargetedCollectExistenceFrame(input: {
prompt,
varga: null,
why: TARGETED_COLLECT_WHY_USER,
option_a_hint: TARGETED_COLLECT_OPTION_A,
option_b_hint: TARGETED_COLLECT_OPTION_B,
neither_label: TARGETED_COLLECT_OPTION_C,
unsure_label: TARGETED_COLLECT_OPTION_D,
option_a_hint: input.optionA ?? TARGETED_COLLECT_OPTION_A,
option_b_hint: input.optionB ?? TARGETED_COLLECT_OPTION_B,
neither_label: input.optionC ?? TARGETED_COLLECT_OPTION_C,
unsure_label: input.optionD ?? TARGETED_COLLECT_OPTION_D,
option_a_answer_class: "yes",
option_b_answer_class: "no",
option_c_answer_class: "unsure",
@@ -19,7 +19,9 @@ export const COLLECT_KIND_ORDER = [
export type CollectKind = (typeof COLLECT_KIND_ORDER)[number];
export type CollectionPoolKind = "invite" | "anchor" | "generic" | "targeted";
export type CollectionPoolKind = "invite" | "anchor" | "generic" | "targeted" | "guided";
export type GuidedCollectSource = "window" | "retry" | "open";
export type CollectionEvidence = Readonly<{
status: string;
@@ -45,6 +47,9 @@ export type CollectionPoolItem = Readonly<{
existencePrompt?: string;
yearPrompt?: string;
remainingLine?: string;
guidedSource?: GuidedCollectSource;
monthLo?: number;
monthHi?: number;
}>;
const KIND_EXAMPLES: Readonly<Record<CollectKind, string>> = {
@@ -148,7 +153,12 @@ export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySe
const questionId = topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, "");
if (questionId.startsWith("collect:")) keys.add(questionId);
const kind = topicCollectKind(topic);
if (kind.startsWith("anchor:") || kind.startsWith("generic:") || kind.startsWith("targeted:")) {
if (
kind.startsWith("anchor:")
|| kind.startsWith("generic:")
|| kind.startsWith("targeted:")
|| kind.startsWith("guided:")
) {
keys.add(`collect:${kind}`);
}
}
@@ -494,18 +504,40 @@ const TARGETED_EXAMPLES: Readonly<Record<CollectKind, readonly [string, string]>
health_pressure: ["哪年住院或手术", "哪年身体明显垮过一截"],
};
const TARGETED_EXISTENCE_PROMPT: Readonly<Record<CollectKind, string>> = {
education: "升过学或考试发挥明显变过吗?",
career: "换过工作或岗位变过吗?",
relocation: "搬家或换过城市吗?",
relationship: "结过婚或订过婚吗?",
family: "家里添过丁或长辈住过院吗?",
finance: "收入明显变过或有过大笔进出吗?",
health_pressure: "住院、做过手术或身体明显垮过吗?",
export const KIND_ORAL: Readonly<Record<CollectKind, string>> = {
education: "升学、转学或大考",
career: "入职、换工作或职责变重",
relocation: "搬家或开始长期住外地",
relationship: "开始认真关系、分手或结婚",
family: "添丁、长辈住院或做过手术",
finance: "收入明显变过大笔进出或欠债",
health_pressure: "住院、手术、受伤或特别难熬",
};
export const TARGETED_EXISTENCE_PROMPT: Readonly<Record<CollectKind, string>> = {
education: "学业上有没有过升学、转学、毕业或考试发挥明显变过,哪一年都算?",
career: "工作上有没有过入职、换工作或职责明显变重,哪一年都算?",
relocation: "有没有搬过家、换过城市或出国长期住,哪一年都算?",
relationship: "感情上有没有过开始一段认真关系、分手、订婚或结婚,哪一年都算?",
family: "家里有没有过添丁、长辈住院或做过手术,哪一年都算?",
finance: "钱的方面有没有过收入明显变化、大笔进出或欠债,哪一年都算?",
health_pressure: "身体上有没有过住院、手术、受伤或特别难熬的一段时间,哪一年都算?",
};
export const TARGETED_EXISTENCE_PROMPT_RETRY: Readonly<Record<CollectKind, string>> = {
education: "上学这边再问一次:有没有转过学、中断过学业,或者某次大考发挥特别差?",
career: "工作这边再问一次:有没有换过行、被裁过,或者职责一下子变重?",
relocation: "住的地方再问一次:有没有搬到别的城市,或者出国住过一段时间?",
relationship: "感情这边再问一次:有没有开始过一段认真关系、分手,或者订过婚?",
family: "父母或祖辈有没有住过院、做过手术,或者家里添过小孩?",
finance: "钱的方面再问一次:有没有收入一下子变过、大笔进出,或者欠过债?",
health_pressure: "身体这边再问一次:有没有住过院、做过手术,或者连续几个月特别难熬?",
};
export const TARGETED_YEAR_PROMPT = "大概哪年几月?";
export const GUIDED_NARROW_HINT = "再对照几件经历会更准";
/** Range-endpoint wording that used to name two clocks as if they were the remaining candidates. */
export const SPLIT_ENDPOINT_PHRASE = /能把 (?:[01]\d|2[0-3]):[0-5]\d 和 (?:[01]\d|2[0-3]):[0-5]\d 分开/;
@@ -581,7 +613,7 @@ export function parseTargetedCollectQuestionId(
questionId: string | null | undefined,
): TargetedCollectRef | null {
const trimmed = (questionId ?? "").replace(/:(?:next|next2|next3)$/, "").trim();
const match = /^collect:targeted:([a-z_]+)(?::(year))?$/.exec(trimmed);
const match = /^collect:targeted:([a-z_]+)(?::(year|retry))?$/.exec(trimmed);
if (!match) return null;
const domain = normalizeCollectKind(match[1]);
if (!domain) return null;
@@ -592,13 +624,70 @@ export function parseTargetedCollectKind(
kind: string | null | undefined,
): TargetedCollectRef | null {
const trimmed = (kind ?? "").trim();
const match = /^targeted:([a-z_]+)(?::(year))?$/.exec(trimmed);
const match = /^targeted:([a-z_]+)(?::(year|retry))?$/.exec(trimmed);
if (!match) return null;
const domain = normalizeCollectKind(match[1]);
if (!domain) return null;
return { domain, stage: match[2] === "year" ? "year" : "existence" };
}
export type GuidedWindowRef = Readonly<{
domain: CollectKind;
year: number;
monthLo: number;
monthHi: number;
stage: TargetedCollectStage;
}>;
export function parseGuidedWindowQuestionId(
questionId: string | null | undefined,
): GuidedWindowRef | null {
const trimmed = (questionId ?? "").replace(/:(?:next|next2|next3)$/, "").trim();
const match = /^collect:guided:window:(\d{4}):(\d{1,2}):(\d{1,2}):([a-z_]+)(?::(year))?$/.exec(trimmed);
if (!match) return null;
const domain = normalizeCollectKind(match[4]);
if (!domain) return null;
const year = Number(match[1]);
const monthLo = Number(match[2]);
const monthHi = Number(match[3]);
if (!Number.isInteger(year) || monthLo < 1 || monthLo > 12 || monthHi < 1 || monthHi > 12) {
return null;
}
return { domain, year, monthLo, monthHi, stage: match[5] === "year" ? "year" : "existence" };
}
export function guidedWindowQuestionId(
window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: CollectKind }>,
stage: TargetedCollectStage = "existence",
): string {
const base = `collect:guided:window:${window.year}:${window.month_lo}:${window.month_hi}:${window.domain}`;
return stage === "year" ? `${base}:year` : base;
}
export function parseYearEntryQuestionId(
questionId: string | null | undefined,
): Readonly<{ domain: CollectKind; year: number | null; month: number | null }> | null {
const guided = parseGuidedWindowQuestionId(questionId);
if (guided?.stage === "year") {
return { domain: guided.domain, year: guided.year, month: guided.monthLo };
}
const targeted = parseTargetedCollectQuestionId(questionId);
if (targeted?.stage === "year") {
return { domain: targeted.domain, year: null, month: null };
}
return null;
}
export function guidedWindowPrompt(
window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: CollectKind }>,
): string {
const oral = KIND_ORAL[window.domain];
if (window.month_lo === window.month_hi) {
return `${window.year}${window.month_lo} 月前后,有没有${oral}`;
}
return `${window.year}${window.month_lo}${window.month_hi} 月之间,有没有${oral}`;
}
export function targetedCollectQuestionId(
domain: CollectKind,
stage: TargetedCollectStage = "existence",
@@ -615,10 +704,25 @@ export function isTargetedCollectTopic(topic: CollectionTopic): boolean {
const questionId = topicQuestionId(topic);
const kind = topicCollectKind(topic);
return questionId.startsWith("collect:targeted:")
|| questionId.startsWith("collect:guided:")
|| kind.startsWith("targeted:")
|| kind.startsWith("guided:")
|| topicDomain(topic) === "targeted";
}
function topicRetryFlag(topic: CollectionTopic): boolean {
return topic.retry === true || topic.retried === true;
}
function isDistinguishProbeTopic(topic: CollectionTopic): boolean {
const questionId = topicQuestionId(topic);
const source = typeof topic.source === "string" ? topic.source : "";
return questionId.startsWith("distinguish:")
|| source === "event_probe"
|| source === "dasha_boundary"
|| source === "dasha_activation";
}
export function isTargetedCollectClosed(
topics: readonly CollectionTopic[] = [],
): boolean {
@@ -645,6 +749,8 @@ export function isTargetedCollectExistenceFollowup(followup: {
kind_hint?: string | null;
} | null | undefined): boolean {
if (!followup) return false;
const guided = parseGuidedWindowQuestionId(followup.collection_key);
if (guided) return guided.stage === "existence";
const ref = parseTargetedCollectQuestionId(followup.collection_key)
?? parseTargetedCollectKind(followup.kind_hint);
return ref?.stage === "existence";
@@ -660,24 +766,30 @@ export function isTargetedCollectExistenceFocus(focus: {
const schemaKind = schema && typeof schema.collect_kind === "string"
? schema.collect_kind
: null;
const guided = parseGuidedWindowQuestionId(focus.questionId);
if (guided) return guided.stage === "existence";
const ref = parseTargetedCollectQuestionId(focus.questionId)
?? parseTargetedCollectKind(focus.targetKind)
?? parseTargetedCollectKind(schemaKind);
return ref?.stage === "existence";
}
export function declinedDomainNames(topics: readonly CollectionTopic[]): CollectKind[] {
return [...collectDeclinedKinds(topics)];
}
function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<CollectKind> {
const declined = new Set<CollectKind>();
for (const topic of topics) {
if (isDistinguishProbeTopic(topic)) continue;
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") continue;
const intent = typeof topic.intent === "string" ? topic.intent : "";
if (status !== "declined" && !(status === "skipped" && topicRetryFlag(topic))) continue;
const questionId = topicQuestionId(topic);
const collectIntent = intent === "collect_method_evidence"
|| questionId.startsWith("collect:");
if (!collectIntent) continue;
if (questionId.startsWith("collect:invite:")) continue;
if (questionId.startsWith("collect:other:")) continue;
if (questionId.startsWith("collect:guided:window:")) continue;
const guided = parseGuidedWindowQuestionId(questionId);
if (guided) continue;
const targeted = targetedCollectRefFromTopic(topic);
if (targeted) {
declined.add(targeted.domain);
@@ -692,22 +804,49 @@ function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<C
return declined;
}
function skippedOnceDomains(topics: readonly CollectionTopic[]): CollectKind[] {
const skipped = new Set<CollectKind>();
const closed = collectDeclinedKinds(topics);
for (const topic of topics) {
if (isDistinguishProbeTopic(topic)) continue;
const status = topicStatus(topic);
if (status !== "skipped" || topicRetryFlag(topic)) continue;
const targeted = targetedCollectRefFromTopic(topic);
const domain = targeted?.domain
?? normalizeCollectKind(topicDomain(topic));
if (!domain || closed.has(domain)) continue;
if (targeted?.stage === "year") continue;
if (parseGuidedWindowQuestionId(topicQuestionId(topic))) continue;
skipped.add(domain);
}
return COLLECT_KIND_ORDER.filter((domain) => skipped.has(domain));
}
function targetedDomainClosed(
topics: readonly CollectionTopic[],
domain: CollectKind,
): boolean {
for (const topic of topics) {
if (isDistinguishProbeTopic(topic)) continue;
const status = topicStatus(topic);
if (status === "active" || !status) continue;
if (parseGuidedWindowQuestionId(topicQuestionId(topic))) continue;
const targeted = targetedCollectRefFromTopic(topic);
if (targeted?.domain !== domain) continue;
if (status === "declined" || status === "skipped") return true;
if (status === "resolved" && targeted.stage === "existence") return true;
if (status === "declined") return true;
if (status === "skipped" && topicRetryFlag(topic)) return true;
if (status === "resolved" && targeted.stage === "year") return true;
}
return false;
}
export function domainNeedsExistenceRetry(
topics: readonly CollectionTopic[],
domain: CollectKind,
): boolean {
return skippedOnceDomains(topics).includes(domain);
}
export function pendingTargetedYearDomain(
topics: readonly CollectionTopic[] = [],
evidence: readonly CollectionEvidence[] = [],
@@ -734,6 +873,33 @@ export function pendingTargetedYearDomain(
return null;
}
export function pendingGuidedYearWindow(
topics: readonly CollectionTopic[] = [],
evidence: readonly CollectionEvidence[] = [],
): GuidedWindowRef | null {
const covered = coveredCollectKinds(evidence);
for (const topic of topics) {
const window = parseGuidedWindowQuestionId(topicQuestionId(topic));
if (!window || window.stage !== "existence") continue;
if (topicStatus(topic) !== "resolved") continue;
if (covered.has(window.domain)) continue;
const yearId = guidedWindowQuestionId({
year: window.year,
month_lo: window.monthLo,
month_hi: window.monthHi,
domain: window.domain,
}, "year");
const yearClosed = topics.some((row) => {
const id = topicQuestionId(row).replace(/:(?:next|next2|next3)$/, "");
const status = topicStatus(row);
return id === yearId && Boolean(status) && status !== "active";
});
if (yearClosed) continue;
return window;
}
return null;
}
function remainingTargetedDomains(
layers: readonly string[],
evidence: readonly CollectionEvidence[],
@@ -741,14 +907,21 @@ function remainingTargetedDomains(
topics: readonly CollectionTopic[] = [],
): CollectKind[] {
const covered = coveredCollectKinds(evidence);
const domains: CollectKind[] = [];
const retry = new Set(skippedOnceDomains(topics));
const fromLayers: CollectKind[] = [];
for (const layer of layers) {
const domain = REMAINING_LAYER_DOMAIN[layer];
if (!domain || declined.has(domain) || covered.has(domain)) continue;
if (!domain || declined.has(domain) || covered.has(domain) || retry.has(domain)) continue;
if (targetedDomainClosed(topics, domain)) continue;
if (!domains.includes(domain)) domains.push(domain);
if (!fromLayers.includes(domain)) fromLayers.push(domain);
}
return domains;
const rest: CollectKind[] = [];
for (const domain of COLLECT_KIND_ORDER) {
if (declined.has(domain) || covered.has(domain) || retry.has(domain)) continue;
if (targetedDomainClosed(topics, domain)) continue;
if (!fromLayers.includes(domain)) rest.push(domain);
}
return [...fromLayers, ...rest];
}
function clockRangePair(
@@ -770,6 +943,119 @@ export function remainingCandidatesLine(
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选,能把它们分开的是这几条线:${examples.join("、")}`;
}
export function remainingGuidedLine(
splitTimes: readonly [string, string] | null | undefined,
candidateCount: number,
credibleRange?: readonly [string, string] | null,
): string | null {
const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes);
if (!range || candidateCount < 1) return null;
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选,${GUIDED_NARROW_HINT}`;
}
export type GuidedCollectWindow = Readonly<{
year: number;
month_lo: number;
month_hi: number;
domain: string;
split: Readonly<{ left: number; right: number }>;
}>;
function windowAlreadyAsked(
topics: readonly CollectionTopic[],
window: GuidedCollectWindow,
domain: CollectKind,
): boolean {
const key = guidedWindowQuestionId({
year: window.year,
month_lo: window.month_lo,
month_hi: window.month_hi,
domain,
});
return topics.some((topic) => {
const status = topicStatus(topic);
if (status === "active" || !status) return false;
return topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, "") === key;
});
}
export function guidedWindowPool(
windows: readonly GuidedCollectWindow[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
): CollectionPoolItem[] {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return [];
if (pendingGuidedYearWindow(declinedTopics, evidence)) return [];
const declined = collectDeclinedKinds(declinedTopics);
const remainingLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange);
const items: CollectionPoolItem[] = [];
for (const window of windows) {
const domain = normalizeCollectKind(window.domain);
if (!domain || declined.has(domain)) continue;
if (windowAlreadyAsked(declinedTopics, window, domain)) continue;
const prompt = guidedWindowPrompt({
year: window.year,
month_lo: window.month_lo,
month_hi: window.month_hi,
domain,
});
items.push({
kind: "guided",
guidedSource: "window",
value: 1.7,
prompt,
key: guidedWindowQuestionId({
year: window.year,
month_lo: window.month_lo,
month_hi: window.month_hi,
domain,
}),
domain,
targetKind: `guided:window:${domain}`,
year: window.year,
monthLo: window.month_lo,
monthHi: window.month_hi,
existencePrompt: prompt,
yearPrompt: TARGETED_YEAR_PROMPT,
...(remainingLine ? { remainingLine } : {}),
});
}
return items;
}
export function guidedRetryPool(
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
): CollectionPoolItem[] {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return [];
if (pendingGuidedYearWindow(declinedTopics, evidence)) return [];
const covered = coveredCollectKinds(evidence);
const remainingLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange);
return skippedOnceDomains(declinedTopics).flatMap((domain) => {
if (covered.has(domain)) return [];
const prompt = TARGETED_EXISTENCE_PROMPT_RETRY[domain];
return [{
kind: "guided" as const,
guidedSource: "retry" as const,
value: 1.55,
prompt,
key: targetedCollectQuestionId(domain) + ":retry",
domain,
targetKind: `targeted:${domain}:retry`,
year: null,
existencePrompt: prompt,
yearPrompt: TARGETED_YEAR_PROMPT,
...(remainingLine ? { remainingLine } : {}),
}];
});
}
export function targetedCollectPool(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
@@ -832,7 +1118,24 @@ export function rangeNarrowHint(
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
windows: readonly GuidedCollectWindow[] = [],
delivering = false,
): string {
const guided = guidedWindowPool(
windows,
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
);
const retry = guidedRetryPool(
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
);
const open = targetedCollectPool(
remainingLayers,
evidence,
@@ -841,13 +1144,21 @@ export function rangeNarrowHint(
candidateCount,
credibleRange,
);
if (open.length === 0) {
if (guided.length + retry.length + open.length === 0) {
const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes);
if (range && (candidateCount ?? 0) > 0) {
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选。${rangeDeliveryCollectClosed(candidateCount)}`;
if (delivering) {
if (range && (candidateCount ?? 0) > 0) {
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选。${rangeDeliveryCollectClosed(candidateCount)}`;
}
return rangeDeliveryCollectClosed(candidateCount);
}
return rangeDeliveryCollectClosed(candidateCount);
if (range && (candidateCount ?? 0) > 0) {
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选,${GUIDED_NARROW_HINT}`;
}
return GUIDED_NARROW_HINT;
}
const guidedLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange);
if (guidedLine && (guided.length > 0 || retry.length > 0)) return guidedLine;
const remaining = remainingCandidatesLine(
splitTimes,
candidateCount ?? 0,
@@ -879,6 +1190,31 @@ export function targetedCollectExhausted(
).length === 0;
}
export function guidedCollectExhausted(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
windows: readonly GuidedCollectWindow[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
): boolean {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return false;
if (pendingGuidedYearWindow(declinedTopics, evidence)) return false;
if (guidedWindowPool(windows, evidence, declinedTopics, splitTimes, candidateCount).length > 0) {
return false;
}
if (guidedRetryPool(evidence, declinedTopics, splitTimes, candidateCount).length > 0) {
return false;
}
return targetedCollectExhausted(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
);
}
export const COLLECT_FLOW_BANNED_PHRASES = [
"任何领域",
"领域不限",
@@ -26,6 +26,9 @@ import {
type RectificationDecision,
} from "../core/rectification-decision.ts";
import { evaluateCandidateSeparation } from "../core/candidate-separation.ts";
import { rangeFromTimes } from "../core/credible-range.ts";
import { rankActive } from "../core/convergence-evaluator.ts";
import { computePrecisionGateMet, probabilityToPercent } from "../core/precision-gate.ts";
import { warnRepresentativeTimeInconsistency } from "../core/representative-time-guard.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import {
@@ -60,6 +63,8 @@ import {
remainingSplitLayers,
remainingSplitTimes,
targetedCollectExhausted,
guidedCollectExhausted,
type GuidedCollectWindow,
} from "./collection-question-pool.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts";
@@ -399,6 +404,7 @@ export function rectificationFollowupCatalog(
remainingSplitTimes: remainingSplitTimes(activeTimes.length ? activeTimes : topCandidateTimes),
remainingCandidateCount: remainingCandidateCount(activeTimes.length ? activeTimes : topCandidateTimes),
remainingCredibleRange: inference?.credible_range ?? null,
guidedCollectWindows: refinement.guided_collect_windows as readonly GuidedCollectWindow[],
};
}
@@ -500,12 +506,32 @@ export type DecideFromDossierOptions = Readonly<{
snapshotCurrent?: boolean;
refreshExhausted?: boolean;
targetedCollectExhausted?: boolean;
guidedCollectExhausted?: boolean;
precisionGateMet?: boolean;
}>;
function deliveryPercentsFromDossier(
dossier: DecisionDossier,
inference: InferenceState | null,
): number[] {
if (inference?.candidates?.length) {
return rankActive(inference.candidates).slice(0, 3).map((item) => probabilityToPercent(item.probability));
}
const scores = [...candidateScoresFromDossier(dossier.latestResult)]
.sort((left, right) => right.score - left.score || left.time.localeCompare(right.time));
const total = scores.reduce((sum, row) => sum + Math.max(row.score, 0), 0);
return scores.slice(0, 3).map((row) => (
total > 0 ? Math.round(Math.max(row.score, 0) / total * 100) : 0
));
}
function narrowingExhaustion(
dossier: DecisionDossier,
inference: InferenceState | null,
options?: Pick<DecideFromDossierOptions, "refreshExhausted" | "targetedCollectExhausted">,
options?: Pick<
DecideFromDossierOptions,
"refreshExhausted" | "targetedCollectExhausted" | "guidedCollectExhausted" | "precisionGateMet"
>,
catalog?: ReturnType<typeof rectificationFollowupCatalog>,
) {
const live = catalog ?? rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
@@ -524,6 +550,15 @@ function narrowingExhaustion(
&& row.answer_count === inference.answered_probes.length;
}),
);
const scores = candidateScoresFromDossier(dossier.latestResult);
const separation = evaluateCandidateSeparation(scores);
const range = inference?.credible_range
?? live.remainingCredibleRange
?? rangeFromTimes(separation.credibleRange)
?? (separation.representativeTime
? [separation.representativeTime, separation.representativeTime] as const
: null);
const percents = deliveryPercentsFromDossier(dossier, inference);
return {
refreshExhausted: options?.refreshExhausted ?? (
attempted
@@ -538,6 +573,21 @@ function narrowingExhaustion(
live.remainingSplitTimes,
live.remainingCandidateCount,
),
guidedCollectExhausted: options?.guidedCollectExhausted
?? guidedCollectExhausted(
remainingLayers,
dossier.evidence,
declined,
live.guidedCollectWindows,
live.remainingSplitTimes,
live.remainingCandidateCount,
),
precisionGateMet: options?.precisionGateMet
?? computePrecisionGateMet({
range,
topTwoPercents: percents,
tiedForFirst: separation.tiedForFirst,
}),
};
}
@@ -827,6 +877,7 @@ export function decideFromDossier(
remainingSplitTimes: catalog.remainingSplitTimes,
remainingCandidateCount: catalog.remainingCandidateCount,
remainingCredibleRange: catalog.remainingCredibleRange,
guidedWindows: catalog.guidedCollectWindows,
...followupCaseArgs({
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
@@ -951,6 +1002,7 @@ export function decideAfterInferenceChange(input: {
remainingSplitTimes: catalog.remainingSplitTimes,
remainingCandidateCount: catalog.remainingCandidateCount,
remainingCredibleRange: catalog.remainingCredibleRange,
guidedWindows: catalog.guidedCollectWindows,
...followupCaseArgs({
stage: input.dossier.case.stage,
blockScan: input.dossier.case.blockScan,
@@ -432,6 +432,8 @@ export function buildRangeDelivery(input: {
remainingSplitTimes(activeTimes),
remainingCandidateCount(activeTimes),
inference?.credible_range,
[],
true,
),
tie_break_available: input.tieBreakAvailable === true,
tie_break_note: input.tieBreakAvailable !== true
@@ -687,6 +687,7 @@ export function engineRequestBody(input: {
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
declinedDomains?: readonly string[];
columnTimes?: readonly string[];
refreshProbes?: boolean;
}): Record<string, unknown> {
@@ -722,11 +723,38 @@ export function engineRequestBody(input: {
timezone_source: snapshot.timezone_source,
local_time_status: snapshot.local_time_status,
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
...(sanitizeDeclinedDomainsForEngine(input.declinedDomains).length
? { declined_domains: sanitizeDeclinedDomainsForEngine(input.declinedDomains) }
: {}),
...(columnTimes.length ? { column_times: columnTimes } : {}),
...(input.refreshProbes === true ? { refresh_probes: true } : {}),
};
}
const ENGINE_COLLECT_DOMAINS = new Set([
"education",
"career",
"relocation",
"relationship",
"family",
"finance",
"health_pressure",
]);
export function sanitizeDeclinedDomainsForEngine(
domains: readonly string[] | null | undefined,
): string[] {
const seen = new Set<string>();
const next: string[] = [];
for (const raw of domains ?? []) {
const domain = raw.trim();
if (!ENGINE_COLLECT_DOMAINS.has(domain) || seen.has(domain)) continue;
seen.add(domain);
next.push(domain);
}
return next;
}
export type V9VedastroValidateResult = Readonly<{
status: "passed" | "failed" | "not_evaluated";
canConfirmExactMinute: boolean;
@@ -840,6 +868,7 @@ export async function runV9CandidateScore(input: {
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
declinedDomains?: readonly string[];
columnTimes?: readonly string[];
refreshProbes?: boolean;
}): Promise<V9EngineScoreResult> {
@@ -81,6 +81,10 @@ import {
serverOwnedChoiceCopy,
buildTargetedCollectExistenceFrame,
TARGETED_COLLECT_KEEP_HINT,
GUIDED_WINDOW_OPTION_A,
GUIDED_WINDOW_OPTION_B,
GUIDED_WINDOW_OPTION_C,
GUIDED_WINDOW_OPTION_D,
type RectificationChoiceCard,
type RectificationChoiceFrame,
} from "./choice-card.ts";
@@ -97,8 +101,10 @@ import {
collectionQuestionPool,
isInviteCollectTopic,
pendingTargetedYearDomain,
pendingGuidedYearWindow,
remainingCandidatesLine,
remainingCandidateCount,
remainingGuidedLine,
targetedCollectPool,
targetedCollectQuestionId,
TARGETED_YEAR_PROMPT,
@@ -106,8 +112,13 @@ import {
isTargetedCollectExistenceFocus,
parseTargetedCollectQuestionId,
parseTargetedCollectKind,
parseGuidedWindowQuestionId,
guidedWindowQuestionId,
guidedWindowPool,
guidedRetryPool,
type CollectKind,
type CollectionPoolItem,
type GuidedCollectWindow,
} from "./collection-question-pool.ts";
import {
canonicalCollectDomain,
@@ -674,11 +685,25 @@ export function rebuildTargetedCollectExistenceFrame(input: {
kindHint?: string | null;
prompt?: string | null;
}): RectificationChoiceFrame | null {
const prompt = (input.prompt ?? "").trim();
const guided = parseGuidedWindowQuestionId(input.questionId);
if (guided?.stage === "existence") {
const questionId = (input.questionId ?? "").trim();
if (!questionId || !prompt) return null;
return buildTargetedCollectExistenceFrame({
questionId,
methodId: targetedCollectMethodId(input.domain ?? guided.domain),
prompt,
optionA: GUIDED_WINDOW_OPTION_A,
optionB: GUIDED_WINDOW_OPTION_B,
optionC: GUIDED_WINDOW_OPTION_C,
optionD: GUIDED_WINDOW_OPTION_D,
});
}
const ref = parseTargetedCollectQuestionId(input.questionId)
?? parseTargetedCollectKind(input.kindHint);
if (ref?.stage !== "existence") return null;
const questionId = (input.questionId ?? "").trim() || targetedCollectQuestionId(ref.domain);
const prompt = (input.prompt ?? "").trim();
if (!questionId || !prompt) return null;
return buildTargetedCollectExistenceFrame({
questionId,
@@ -1403,8 +1428,8 @@ export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup {
const theme = domain in REVERSE_VERIFY_THEME
? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME]
: "dated_event";
const kindHint = item.kind === "targeted"
? `targeted:${domain}`
const kindHint = item.kind === "targeted" || item.kind === "guided"
? item.targetKind ?? (item.kind === "guided" ? `guided:${domain}` : `targeted:${domain}`)
: item.kind === "anchor" && item.targetKind && item.year != null
? `anchor:${item.targetKind}:${item.year}`
: item.kind === "generic"
@@ -1425,6 +1450,7 @@ export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup {
spoken_prompt: item.prompt,
collection_key: item.key,
...(item.year ? { probe_year: item.year, year_label: `${item.year}` } : {}),
...(item.monthLo ? { probe_month: item.monthLo } : {}),
};
}
@@ -1443,11 +1469,37 @@ export function targetedCollectFollowup(
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
windows: readonly GuidedCollectWindow[] = [],
): MethodFollowup | null {
const pendingGuided = pendingGuidedYearWindow(declinedTopics, evidence);
if (pendingGuided) {
return guidedYearFollowup(pendingGuided);
}
const pendingYear = pendingTargetedYearDomain(declinedTopics, evidence);
if (pendingYear) {
return targetedCollectYearFollowup(pendingYear);
}
const windowItem = guidedWindowPool(
windows,
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
)[0];
if (windowItem) {
return guidedWindowExistenceFollowup(windowItem, splitTimes, candidateCount, credibleRange);
}
const retryItem = guidedRetryPool(
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
)[0];
if (retryItem) {
return targetedCollectExistenceFollowup(retryItem, splitTimes, candidateCount, credibleRange);
}
const top = targetedCollectPool(
remainingLayers,
evidence,
@@ -1482,6 +1534,54 @@ export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup
};
}
function guidedYearFollowup(window: {
domain: CollectKind;
year: number;
monthLo: number;
monthHi: number;
}): MethodFollowup {
const base = targetedCollectYearFollowup(window.domain);
return {
...base,
kind_hint: `guided:window:${window.domain}:year`,
collection_key: guidedWindowQuestionId({
year: window.year,
month_lo: window.monthLo,
month_hi: window.monthHi,
domain: window.domain,
}, "year"),
probe_year: window.year,
probe_month: window.monthLo,
};
}
function guidedWindowExistenceFollowup(
item: CollectionPoolItem,
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
): MethodFollowup {
const base = followupFromPoolItem(item);
const frame = buildTargetedCollectExistenceFrame({
questionId: item.key,
methodId: base.method_id,
prompt: item.existencePrompt ?? item.prompt,
optionA: GUIDED_WINDOW_OPTION_A,
optionB: GUIDED_WINDOW_OPTION_B,
optionC: GUIDED_WINDOW_OPTION_C,
optionD: GUIDED_WINDOW_OPTION_D,
});
const remaining = item.remainingLine
?? remainingGuidedLine(splitTimes, candidateCount ?? remainingCandidateCount(), credibleRange);
return {
...base,
choice_frame: frame,
choice_kind: "existence",
spoken_prompt: remaining ?? item.prompt,
collection_key: item.key,
};
}
function targetedCollectExistenceFollowup(
item: CollectionPoolItem,
splitTimes?: readonly [string, string] | null,
@@ -1551,9 +1651,10 @@ export function isRemainingEvidenceCollect(
|| key.startsWith("collect:anchor:")
|| key.startsWith("collect:generic:")
|| key.startsWith("collect:targeted:")
|| key.startsWith("collect:guided:")
) return true;
const hint = followup.kind_hint ?? "";
if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:") || hint.startsWith("targeted:")) return true;
if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:") || hint.startsWith("targeted:") || hint.startsWith("guided:")) return true;
return typeof followup.domain === "string"
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
}
@@ -2097,6 +2198,7 @@ export function buildMethodFollowupPlan(input: {
remainingSplitTimes?: readonly [string, string] | null;
remainingCandidateCount?: number;
remainingCredibleRange?: readonly [string, string] | null;
guidedWindows?: readonly GuidedCollectWindow[];
tieBreakRequested?: boolean;
}): MethodFollowupPlan {
const makeFollowup = (
@@ -2762,6 +2864,7 @@ export function buildMethodFollowupPlan(input: {
input.remainingSplitTimes,
input.remainingCandidateCount,
input.remainingCredibleRange,
input.guidedWindows ?? [],
))
) {
// BUG-651: no yearless personality as the next discriminator.
@@ -2964,6 +3067,7 @@ export function buildMethodFollowupPlan(input: {
input.remainingSplitTimes,
input.remainingCandidateCount,
input.remainingCredibleRange,
input.guidedWindows ?? [],
);
if (targeted) next = targeted;
else if (pendingHoldout) next = makeFollowup(pendingHoldout, false);
@@ -233,6 +233,46 @@ export type DiscriminatingEventProbe = Readonly<{
display_date_label?: string;
}>;
export type GuidedCollectWindowRow = Readonly<{
year: number;
month_lo: number;
month_hi: number;
domain: string;
split: Readonly<{ left: number; right: number }>;
}>;
export function parseGuidedCollectWindows(value: unknown): readonly GuidedCollectWindowRow[] {
if (!Array.isArray(value)) return [];
const rows: GuidedCollectWindowRow[] = [];
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const year = typeof row.year === "number" ? row.year : Number.NaN;
const monthLo = typeof row.month_lo === "number" ? row.month_lo : Number.NaN;
const monthHi = typeof row.month_hi === "number" ? row.month_hi : Number.NaN;
const domain = typeof row.domain === "string" ? row.domain.trim() : "";
const split = row.split && typeof row.split === "object" && !Array.isArray(row.split)
? row.split as Record<string, unknown>
: null;
const left = typeof split?.left === "number" ? split.left : Number.NaN;
const right = typeof split?.right === "number" ? split.right : Number.NaN;
if (!Number.isInteger(year) || year < 1900 || year > 2100) continue;
if (!Number.isInteger(monthLo) || monthLo < 1 || monthLo > 12) continue;
if (!Number.isInteger(monthHi) || monthHi < 1 || monthHi > 12) continue;
if (!domain || !EVENT_PROBE_DOMAINS.includes(domain as EventProbeDomain)) continue;
if (!Number.isInteger(left) || !Number.isInteger(right)) continue;
if (left < 0 || right < 0) continue;
rows.push({
year,
month_lo: monthLo,
month_hi: monthHi,
domain,
split: { left, right },
});
}
return rows;
}
export type ProspectiveWindow = Readonly<{
domain: string;
from: string;
@@ -963,6 +1003,7 @@ export function refinementFromDecisionReceipt(
precision_stage: PrecisionStage | null;
oos_blind_prompts: readonly OosBlindPrompt[];
discriminating_event_probes: readonly DiscriminatingEventProbe[];
guided_collect_windows: readonly GuidedCollectWindowRow[];
event_clarification_probes: readonly DiscriminatingEventProbe[];
evidence_collection_probes: readonly DiscriminatingEventProbe[];
candidate_contrast_opportunities: readonly CandidateContrastOpportunity[];
@@ -980,6 +1021,7 @@ export function refinementFromDecisionReceipt(
precision_stage: parsePrecisionStage(receipt?.precision_stage),
oos_blind_prompts: parseOosBlindPrompts(receipt?.oos_blind_prompts),
discriminating_event_probes: parseDiscriminatingEventProbes(receipt?.discriminating_event_probes),
guided_collect_windows: parseGuidedCollectWindows(receipt?.guided_collect_windows),
event_clarification_probes: parseClarificationProbes(
receipt?.event_clarification_probes ?? receipt?.discriminating_event_probes,
),
@@ -14,6 +14,7 @@ import {
toEngineEvents,
} from "./engine-client.ts";
import { refinementFromDecisionReceipt, type DiscriminatingEventProbe } from "./refinement-packet.ts";
import { declinedDomainNames } from "./collection-question-pool.ts";
import { trainingScoreableGate } from "./evidence-model.ts";
import {
evidenceLedgerFingerprint,
@@ -267,6 +268,7 @@ async function defaultRefreshDiscriminatorProbes(
candidateRange,
events,
askedProbeKeys: askedKeysFromState(input.state, input.dossier),
declinedDomains: declinedDomainNames(input.dossier.conversationSummary.declinedSkippedTopics),
columnTimes: times,
refreshProbes: true,
});
@@ -24,6 +24,7 @@ import {
} from "./inference-adapter.ts";
import { columnTimesForSlowCompare } from "./column-times-for-compare.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import { declinedDomainNames } from "./collection-question-pool.ts";
import { blockScanRequestExtras } from "./search-window.ts";
import {
cachedEngineScoreIsReusable,
@@ -377,6 +378,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
candidateRange,
events,
askedProbeKeys,
declinedDomains: declinedDomainNames(dossier.conversationSummary.declinedSkippedTopics),
columnTimes,
});
const engineCompareMs = Date.now() - scoreStarted;
@@ -107,6 +107,7 @@ export function expectedAnswerSchemaFor(
choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence",
scoring: frame.scoring,
...(followup.collection_key?.startsWith("collect:targeted:")
|| followup.collection_key?.startsWith("collect:guided:")
? {
targeted_collect: true,
collect_kind: followup.kind_hint ?? null,
@@ -122,7 +123,10 @@ export function expectedAnswerSchemaFor(
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check";
const targetedCollect = Boolean(followup.collection_key?.startsWith("collect:targeted:"));
const targetedCollect = Boolean(
followup.collection_key?.startsWith("collect:targeted:")
|| followup.collection_key?.startsWith("collect:guided:"),
);
if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null;
const stamped = stampChoiceSchemaWithProbe(
schema,
@@ -338,6 +342,14 @@ export function collectFocusSchema(followup: MethodFollowup): Record<string, unk
schema.date_reliability = true;
schema.target_evidence_id = followup.date_reliability_evidence_id;
}
const yearEntry = (followup.kind_hint ?? "").endsWith(":year")
|| (followup.collection_key ?? "").endsWith(":year");
if (yearEntry) {
schema.event_date_entry = true;
if (followup.domain) schema.default_domain = followup.domain;
if (typeof followup.probe_year === "number") schema.default_year = followup.probe_year;
if (typeof followup.probe_month === "number") schema.default_month = followup.probe_month;
}
return schema;
}
@@ -69,6 +69,8 @@ export type RectificationTimelineView = Readonly<{
* the projection carries no count never a zero this module invented.
*/
answeredLabel: string | null;
/** `已对照 N 件`. Null when the projection carries no dated-event count. */
datedEventLabel: string | null;
}>;
/** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */
@@ -181,6 +183,8 @@ export type RectificationTimelineInput = Readonly<{
workingAdopted?: boolean;
/** `inference_state.answered_probe_count`; absent when not projected. */
answeredProbeCount?: number | null;
/** Dated confirmed events already on the ledger. */
datedEventCount?: number | null;
}>;
/**
@@ -252,6 +256,7 @@ export function buildRectificationTimeline(
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
workingLabel: workingMinuteLabel(input.workingTime, input.workingAdopted === true),
answeredLabel: answeredProbeLabel(input.answeredProbeCount),
datedEventLabel: datedEventLabel(input.datedEventCount),
};
}
@@ -278,3 +283,8 @@ export function answeredProbeLabel(count: number | null | undefined): string | n
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
return `已答 ${count}`;
}
export function datedEventLabel(count: number | null | undefined): string | null {
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
return `已对照 ${count}`;
}