fix(rectification): exhaustion exit, explain layer, range reading, unknown-time scan (BUG-565–568)
Independent Staging Quality Gate / validate (push) Successful in 9m20s
Independent Staging Quality Gate / publish (push) Successful in 6m51s

Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-07 09:10:37 +08:00
co-authored by Cursor
parent 43a26a3ccb
commit 814c924e4a
91 changed files with 5362 additions and 224 deletions
@@ -221,12 +221,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{source === "unknown" && (
<div className="birth-time-detail-note onboarding-card-reveal" role="status">
<p>已跳过具体出生时间。保存后可以直接使用首页功能,生时校正以后需要时再做。</p>
<button
className="button-secondary"
type="button"
onClick={() => onPatch({ birthTimeSource: "period_only", birthTimeStatus: "reported" })}
>我可以选一段时间范围</button>
<p>可以直接开始生时校正:先从你记得的经历比出大致时段。</p>
</div>
)}
</div>
@@ -37,8 +37,10 @@ import {
isRecommendedRectificationCandidate,
natalRecastMeaning,
parseRectificationCandidateResult,
parseRectificationStepState,
workingRectificationHouseTable,
type RectificationCandidateResult,
type RectificationStepState,
} from "@/lib/rectification-candidate-result";
import {
diffRectificationBoard,
@@ -167,26 +169,13 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n
function RectificationReadonlyRange({
range,
onStop,
}: Readonly<{
range: readonly [string, string];
onStop?: () => void;
}>) {
if (!onStop) {
return (
<p className="rectification-readonly-range" role="status">
目前范围 {range[0]}–{range[1]},还在收窄
</p>
);
}
return (
<button
type="button"
className="rectification-readonly-range"
onClick={onStop}
>
<p className="rectification-readonly-range" role="status">
目前范围 {range[0]}–{range[1]},还在收窄
</button>
</p>
);
}
@@ -446,6 +435,7 @@ type CaseSnapshotState = Readonly<{
question: CurrentQuestionModel | null;
questionSource: "focus" | "unavailable" | null;
choice: ChoiceCardModel | null;
stepState: RectificationStepState | null;
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
@@ -467,6 +457,7 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
question: currentQuestionFromSnapshot(payload.current_question),
questionSource: questionSourceFromSnapshot(payload.question_source),
choice: parseRectificationChoiceCard(payload.choice_card),
stepState: parseRectificationStepState(payload.step_state),
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
@@ -508,6 +499,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
const [stepState, setStepState] = useState<RectificationStepState | null>(() => caseSnapshotState(initialSnapshot)?.stepState ?? null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
@@ -654,6 +646,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
step_state?: unknown;
turns?: unknown;
question_source?: unknown;
next_user_action?: { id?: unknown };
@@ -679,6 +672,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setStepState(parseRectificationStepState(payload.step_state));
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
@@ -1718,7 +1712,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange
range={candidateResult.credibleRange}
onStop={readonly || busy ? undefined : submitStop}
/>
)}
</div>
@@ -1779,15 +1772,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<span className="rectification-adopt-status__hint">之后新建对话即按此时间排盘。</span>
</div>
)}
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
<button
type="button"
className="rectification-collect-stop"
onClick={submitStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
<div className="rectification-composer-meta">
{stepState ? (
<p className="rectification-step-state" role="status">
{stepState.headline} — {stepState.reason} — 下一步:{stepState.next}
</p>
) : null}
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
<button
type="button"
className="rectification-collect-stop"
onClick={submitStop}
>
{CHOICE_STOP_LABEL}
</button>
)}
</div>
<ChatComposer
inputRef={composer}
value={draft}
@@ -21,8 +21,13 @@ type RectificationChoiceCardProps = Readonly<{
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
const [hoveredKey, setHoveredKey] = useState<ChoiceKey | "">("");
const selectedKey = props.selectedKey || localSelected;
const answered = Boolean(selectedKey);
const impactKey = (selectedKey === "A" || selectedKey === "B" || selectedKey === "C" || selectedKey === "D"
? selectedKey
: hoveredKey) as ChoiceKey | "";
const impactLine = impactKey ? props.card.answer_impact?.[impactKey] ?? "" : "";
function select(key: ChoiceKey) {
if (props.pending || props.disabled || selectedKey) return;
@@ -60,7 +65,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
{hidePrompt
? <legend className="sr-only">{props.card.prompt}</legend>
: <legend>{props.card.prompt}</legend>}
{!hidePrompt && props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
{!hidePrompt && props.card.why_user ? (
<details className="rectification-choice-why-user">
<summary>为什么问这题</summary>
<p>{props.card.why_user}</p>
</details>
) : null}
<div className="birth-time-primary-choices">
{props.card.options.map((option) => (
<button
@@ -68,6 +78,10 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === option.key ? "true" : "false"}
onMouseEnter={() => setHoveredKey(option.key)}
onMouseLeave={() => setHoveredKey("")}
onFocus={() => setHoveredKey(option.key)}
onBlur={() => setHoveredKey("")}
onClick={() => select(option.key)}
>
<strong>{option.key}.</strong> {option.label}
@@ -84,6 +98,9 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
</button>
) : null}
</div>
{impactLine ? (
<p className="rectification-choice-impact" role="status">{impactLine}</p>
) : null}
</fieldset>
</section>
);