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