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
@@ -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" />