fix(chat): keep rectification sessions findable and keep the delivery card
Independent Staging Quality Gate / validate (push) Failing after 6m45s
Independent Staging Quality Gate / publish (push) Skipped

Rectification answers now advance chat_sessions.updated_at (BUG-704).
A ?c= id missing from the loaded page is fetched before anyone may call
it deleted (BUG-705). The range card no longer has a post-card tie-break
button; live questions leave the card visible with adopt locked
(BUG-706/708). Spoken copy bans 相对支持度 (BUG-709).

Task docs assigned 700-704; qizheng already took 700-703.
This commit is contained in:
jesse-ux
2026-09-15 17:10:53 +08:00
parent d3a2c48ba5
commit f51e494c5a
37 changed files with 799 additions and 177 deletions
-18
View File
@@ -3326,24 +3326,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-range-delivery__accept:disabled {
cursor: default;
}
.rectification-range-delivery__tie-break {
justify-self: start;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-ink-secondary);
background: var(--color-canvas);
font: inherit;
cursor: pointer;
}
.rectification-range-delivery__tie-break:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.rectification-range-delivery__tie-break:disabled {
cursor: default;
}
.rectification-range-delivery__invite {
margin: 0;
color: var(--color-ink);
+14 -1
View File
@@ -210,6 +210,7 @@ import {
LoginRedirectError,
mergeHydratedSession,
patchSessionModel,
resolveLookupBootstrap,
payloadCode,
payloadMessage,
readSessions,
@@ -993,12 +994,22 @@ export default function Home() {
}
if (controller.signal.aborted) return;
const bootstrapSelection = resolveBootstrapSessionSelection({
const listedSelection = resolveBootstrapSessionSelection({
listedIds: nextSessions.map((session) => session.id),
defaultSessionId: nextSessions[0].id,
search: window.location.search,
storedReturnId: readLoginSessionReturn(),
});
const lookedUp = await resolveLookupBootstrap({
selection: listedSelection,
sessions: nextSessions,
defaultSessionId: nextSessions[0].id,
catalog: nextModelCatalog,
signal: controller.signal,
});
if (controller.signal.aborted) return;
nextSessions = lookedUp.sessions;
const bootstrapSelection = lookedUp.selection;
let landingSessionId = resolveStarterHomeLandingSessionId(
nextSessions,
bootstrapSelection.sessionId,
@@ -1061,6 +1072,8 @@ export default function Home() {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
} else if (lookedUp.notice) {
setComposerNotice(lookedUp.notice);
} else if (bootstrapSelection.missing) {
setComposerNotice(SESSION_MISSING_NOTICE);
}
@@ -97,7 +97,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY, postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -1343,45 +1343,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
const requestTieBreak = useCallback(async () => {
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
if (!candidateResult.rangeDelivery?.tie_break_available) return;
setError("");
setPending(true);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/tie-break`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (payload?.code === "tie_break_unavailable") {
await loadCaseSnapshot();
return;
}
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
}
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法开始参考题");
} finally {
setPending(false);
}
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, readonly, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
try {
await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question));
@@ -1509,15 +1470,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const keepSelectionCardsWhileBusy = Boolean(
acceptingCandidateId || candidateResult?.selectedTime,
);
const liveInterviewBlockingAdopt = Boolean(
candidateResult
&& !candidateResult.selectedTime
&& messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
)),
);
const showSelectionCards = Boolean(
candidateResult
&& caseSnapshotLoaded
&& (!busy || keepSelectionCardsWhileBusy)
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime))
&& !messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime))
)),
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
@@ -1843,7 +1808,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || regeneratingMessageKey !== null || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onTieBreak={() => void requestTieBreak()}
adoptLocked={liveInterviewBlockingAdopt}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
@@ -21,19 +21,19 @@ export function RectificationRangeDelivery({
acceptingCandidateId,
readonly,
onAccept,
onTieBreak,
adoptLocked,
}: Readonly<{
result: RectificationCandidateResult;
acceptingCandidateId: string | null;
readonly: boolean;
onAccept: (candidateId: string) => void;
onTieBreak?: () => void;
adoptLocked?: boolean;
}>) {
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
const range = delivery?.range ?? result.credibleRange;
const eventCount = delivery?.event_count ?? 0;
const selected = result.selectedTime;
const busy = Boolean(acceptingCandidateId) || readonly;
const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked;
const columns = delivery?.columns ?? [];
const title = range
? eventCount > 0
@@ -68,15 +68,10 @@ export function RectificationRangeDelivery({
{delivery?.provenance_line ? (
<p className="rectification-range-delivery__provenance">{delivery.provenance_line}</p>
) : null}
{delivery?.tie_break_available && onTieBreak && !readonly ? (
<button
type="button"
className="rectification-range-delivery__tie-break"
disabled={busy}
onClick={onTieBreak}
>
{RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry}
</button>
{adoptLocked ? (
<p className="rectification-range-delivery__narrow">
{RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked}
</p>
) : null}
{delivery?.tie_break_note ? (
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
+26 -1
View File
@@ -6,6 +6,7 @@ import { useRef } from "react";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import {
SESSION_LOOKUP_FAILED_NOTICE,
SESSION_MISSING_NOTICE,
parseSessionUrlQuery,
writeSessionUrl,
@@ -21,6 +22,7 @@ import {
fetchSessionDetail,
fetchSessions,
LoginRedirectError,
lookupSessionById,
mergeHydratedSession,
patchSessionModel,
readSessions,
@@ -405,7 +407,7 @@ export function useSessionManagement(params: SessionManagementParams) {
const query = parseSessionUrlQuery(search);
const fallbackId = listed[0]?.id ?? "";
const requestedId = query.sessionId;
if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) {
if (query.present && !requestedId) {
writeSessionUrl(null, "replace");
sessionSelectionSource.current = "history";
if (fallbackId) selectSession(fallbackId);
@@ -413,6 +415,29 @@ export function useSessionManagement(params: SessionManagementParams) {
setComposerNotice(SESSION_MISSING_NOTICE);
return;
}
if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) {
void lookupSessionById(requestedId, modelCatalog).then((looked) => {
if (looked.status === "found") {
setSessions((current) => mergeHydratedSession(current, looked.session));
sessionSelectionSource.current = "history";
selectSession(looked.session.id);
return;
}
if (looked.status === "missing") {
writeSessionUrl(null, "replace");
sessionSelectionSource.current = "history";
if (fallbackId) selectSession(fallbackId);
else setActiveSessionId("");
setComposerNotice(SESSION_MISSING_NOTICE);
return;
}
setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE);
}).catch((caught) => {
if (caught instanceof LoginRedirectError) return;
setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE);
});
return;
}
if (!requestedId) {
if (fallbackId) {
sessionSelectionSource.current = "history";
+43 -5
View File
@@ -1,6 +1,7 @@
export const SESSION_URL_QUERY_KEY = "c";
export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return";
export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除";
export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。";
export const SESSION_URL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type SessionUrlQuery = {
@@ -10,7 +11,7 @@ export type SessionUrlQuery = {
export type BootstrapSessionSelection = {
readonly sessionId: string;
readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none";
readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup";
readonly missing: boolean;
readonly clearStoredReturn: boolean;
};
@@ -88,6 +89,14 @@ export function resolveBootstrapSessionSelection(input: {
clearStoredReturn: true,
};
}
if (query.sessionId) {
return {
sessionId: query.sessionId,
urlAction: "lookup",
missing: false,
clearStoredReturn: false,
};
}
return {
sessionId: input.defaultSessionId,
urlAction: "replace-clear",
@@ -105,10 +114,10 @@ export function resolveBootstrapSessionSelection(input: {
};
}
return {
sessionId: input.defaultSessionId,
urlAction: "replace-clear",
missing: true,
clearStoredReturn: true,
sessionId: input.storedReturnId,
urlAction: "lookup",
missing: false,
clearStoredReturn: false,
};
}
return {
@@ -118,3 +127,32 @@ export function resolveBootstrapSessionSelection(input: {
clearStoredReturn: false,
};
}
export function bootstrapSelectionFromLookup(
status: "found" | "missing" | "unavailable",
requestedId: string,
defaultSessionId: string,
): BootstrapSessionSelection {
if (status === "found") {
return {
sessionId: requestedId,
urlAction: "keep",
missing: false,
clearStoredReturn: true,
};
}
if (status === "missing") {
return {
sessionId: defaultSessionId,
urlAction: "replace-clear",
missing: true,
clearStoredReturn: true,
};
}
return {
sessionId: defaultSessionId,
urlAction: "none",
missing: false,
clearStoredReturn: false,
};
}
+5 -3
View File
@@ -18,7 +18,7 @@ export type LandingSession = Readonly<{
messages: readonly unknown[];
}>;
export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none";
export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup";
/**
* A rectification Case is opened only when the address bar names that session
@@ -43,7 +43,9 @@ export function resolveStarterHomeLandingSessionId(
selectedId: string,
urlAction: LandingUrlAction,
): string {
if (urlAction === "keep" || urlAction === "replace-selected") return selectedId;
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") {
return selectedId;
}
const selected = sessions.find((session) => session.id === selectedId);
if (selected?.sessionType !== "birth_time_rectification") return selectedId;
const emptyConsultation = sessions.find(
@@ -59,7 +61,7 @@ export function starterHomeLandingNeedsConsultation(
landingId: string,
urlAction: LandingUrlAction,
): boolean {
if (urlAction === "keep" || urlAction === "replace-selected") return false;
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") return false;
const landing = sessions.find((session) => session.id === landingId);
return !landing || landing.sessionType === "birth_time_rectification";
}
+68 -1
View File
@@ -1,5 +1,10 @@
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { persistLoginSessionReturn } from "@/lib/chat-session-url";
import {
bootstrapSelectionFromLookup,
persistLoginSessionReturn,
SESSION_LOOKUP_FAILED_NOTICE,
type BootstrapSessionSelection,
} from "@/lib/chat-session-url";
import { parsePublicThinkingSections } from "@/lib/consultation-thinking-plan";
import {
parsePublicModelCatalog,
@@ -465,6 +470,68 @@ export async function fetchSessionDetail(
return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null;
}
export type SessionLookupResult =
| { status: "found"; session: ChatSession }
| { status: "missing" }
| { status: "unavailable" };
export async function lookupSessionById(
sessionId: string,
catalog: PublicLanguageModelCatalog | null,
signal?: AbortSignal,
): Promise<SessionLookupResult> {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
signal,
cache: "no-store",
});
if (response.status === 401) redirectToLogin();
if (response.status === 404) return { status: "missing" };
const payload = await response.json().catch(() => null);
if (!response.ok) return { status: "unavailable" };
const sessionValue = payload && typeof payload === "object"
? (payload as { session?: unknown }).session
: null;
const session = readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0];
if (!session) return { status: "missing" };
return { status: "found", session };
}
export async function resolveLookupBootstrap(input: {
selection: BootstrapSessionSelection;
sessions: ChatSession[];
defaultSessionId: string;
catalog: PublicLanguageModelCatalog | null;
signal?: AbortSignal;
}): Promise<{
selection: BootstrapSessionSelection;
sessions: ChatSession[];
notice: string | null;
}> {
if (input.selection.urlAction !== "lookup") {
return { selection: input.selection, sessions: input.sessions, notice: null };
}
const looked = await lookupSessionById(input.selection.sessionId, input.catalog, input.signal);
if (looked.status === "found") {
return {
selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId),
sessions: mergeHydratedSession(input.sessions, looked.session),
notice: null,
};
}
if (looked.status === "missing") {
return {
selection: bootstrapSelectionFromLookup("missing", input.selection.sessionId, input.defaultSessionId),
sessions: input.sessions,
notice: null,
};
}
return {
selection: bootstrapSelectionFromLookup("unavailable", input.selection.sessionId, input.defaultSessionId),
sessions: input.sessions,
notice: SESSION_LOOKUP_FAILED_NOTICE,
};
}
export function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus {
if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效");
const status = payload as Partial<ConsultationStatus>;
@@ -201,6 +201,7 @@ export const RECTIFICATION_USER_COPY = {
rangeDeliveryTieBreakEntry: "再答两道参考题微调排序",
rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。",
rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。",
rangeDeliveryAdoptLocked: "先答完上面这道,再选时间。",
verificationReportSummary: "查看验证报告",
} as const;
@@ -586,6 +587,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked,
rangeDeliveryCollectClosed(2),
rangeDeliveryCollectClosed(4),
rangeDeliveryCollectClosed(),
@@ -22,7 +22,9 @@ export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
2 4
post_adopt_verification
使
使
stop_facts
`;
export type AdoptNarrationOutcome =
@@ -126,7 +126,7 @@ function stopFactsFromDropped(
if (yearless > 0) {
facts.push({
kind: "yearless_varga",
label: "没有年份的分盘题不再问",
label: "没有年份的分盘题按现有信息分不开",
count: yearless,
});
}
@@ -277,6 +277,7 @@ export function validateAdoptNarration(
return { ok: false, reason: "question" };
}
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
if (/相对支持度|概率|置信度/.test(trimmed)) return { ok: false, reason: "machine_voice" };
if (
facts.post_adopt_verification.length === 0
&& /会拿[\s\S]{0,40}核对/.test(trimmed)
@@ -26,4 +26,5 @@ export const MACHINE_VOICE_LEXICON = [
"有关联",
"弱关联",
"以你说的为准",
"相对支持度",
] as const;
@@ -73,6 +73,7 @@ import {
type V9CaseDossier,
} from "./tool-service";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card";
import { vargaStyleFollowupRenderable } from "./probe-question-contract";
import { clusterScoreDeltas } from "./probe-explain.ts";
import {
adoptDeliveryFacts,
@@ -1326,6 +1327,21 @@ async function persistFocusAfterChoice(input: {
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
askedTurnId?: string | null;
}) {
const followupKind = input.followup?.choice_kind
?? input.followup?.choice_frame?.choice_kind
?? null;
const followupStyles = input.followup?.style_options ?? [];
if (followupKind === "varga_style" && followupStyles.length > 0 && !vargaStyleFollowupRenderable({
choiceKind: followupKind,
styleOptions: followupStyles,
})) {
return {
status: "skipped" as const,
focus: null,
questionId: null,
prompt: null,
};
}
let persisted;
try {
persisted = await persistServerOwnedFocus({
@@ -143,6 +143,7 @@ import {
isEventQualityProbe,
sameYearProbeAsked,
SEMANTIC_YEAR_KEY,
vargaStyleFollowupRenderable,
type DroppedProbe,
type ProbeStyleOption,
} from "./probe-question-contract.ts";
@@ -3070,6 +3071,14 @@ export function tieBreakPersonalityFollowup(
if (kind !== "varga_style") return null;
const key = followup.semantic_key ?? "";
if (!key.startsWith("varga.d9") && !key.startsWith("varga.d10")) return null;
const styleOptions = followup.style_options ?? [];
if (styleOptions.length > 0 && !vargaStyleFollowupRenderable({
choiceKind: kind,
styleOptions,
})) {
return null;
}
if (styleOptions.length === 0 && !followup.choice_frame) return null;
return { ...followup, tie_break_round: true };
}
@@ -266,6 +266,22 @@ export function canRenderYearlessChoice(input: {
return !optionCopyPresupposesPeriod(input.styleOptions);
}
/** Same gate as buildChoiceCard: followup exists is not enough. */
export function vargaStyleFollowupRenderable(input: {
choiceKind?: string | null;
styleOptions?: readonly unknown[] | null;
}): boolean {
const styles = completeStyleOptions({
choiceKind: input.choiceKind,
styleOptions: input.styleOptions,
});
if (!styles.ok) return false;
return canRenderYearlessChoice({
choiceKind: input.choiceKind,
styleOptions: styles.options,
});
}
export function isRenderableProbe(input: {
informationGain?: number | null;
candidateIds?: readonly string[] | null;
@@ -167,19 +167,12 @@ export function applyLiveCandidateOffer<T extends CandidateOfferAnchor>(
));
}
if (!input.canOffer) return [...messages];
const liveInterview = messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
));
if (liveInterview) {
return messages.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
));
}
const owner = [...messages].reverse().find((message) => (
isSettledAssistant(message)
&& !interviewQuestionBlocksAdoptOffer(message.question, false)
));
if (!owner) {
if (messages.some((message) => message.candidateOffer)) return [...messages];
return messages.map((message) => (
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
));
@@ -382,9 +382,8 @@ export function interviewChoiceCardUnavailable(input: Readonly<{
// A choice question without a GET card is a dead tap target, not a
// collect-wait. The repair-exit path must take over.
if (input.questionKind === "choice" && !input.hasChoiceCard) return true;
// Spoken targeted existence is the same dead end: the stem is a card
// question stored as collect_spoken. Year-stage targeted questions stay spoken.
const questionId = (input.questionId ?? "").replace(/:(?:next|next2|next3)$/, "");
if (!input.hasChoiceCard && /^varga\./.test(questionId)) return true;
if (
input.questionKind === "collect_spoken"
&& !input.hasChoiceCard