fix(chat): keep rectification sessions findable and keep the delivery card
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user