feat(rectification): 出卡加精度门槛,补经历改成系统点名
宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、 用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事 都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。 BUG-740~743
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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} 件`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user