fix(consult): mark rectification opened only after the case opens (BUG-1306)

This commit is contained in:
jesse-ux
2026-10-12 14:41:54 +08:00
parent b8bdc003c3
commit 51a7772128
14 changed files with 228 additions and 27 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
不新增等待动画;打开校正沿用既有一次揭幕。
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。开案还没成功时,同一张提议卡仍是普通提议(不说「已经开始」,按钮仍是「开始核对出生时间」)。开案成功并绑定来源后才换成已开始的文案。打开失败时按钮仍可点。
## 用量页分段计时(2026-10-05,在途)
+2 -2
View File
@@ -26,10 +26,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
## 生时校正服务器固定句(2026-10-07,BUG-1258)
@@ -26,6 +26,8 @@ export function RectificationOfferCard({
onStart,
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
if (offer.status === "returned") return null;
// 「已经开始」 / 继续核对 only after the case is bound. autoOpen still shows
// the ordinary offer and 「开始核对出生时间」 until status is opened.
const opened = offer.status === "opened";
return (
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
+8 -2
View File
@@ -156,7 +156,7 @@ export type ConsultationRunParams = {
setOnboardingPaywallOpen?: (open: boolean) => void;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
openRectificationFromChat: (sourceSessionId: string) => Promise<void> | void;
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
};
export function useConsultationRun(params: ConsultationRunParams) {
@@ -1077,7 +1077,13 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
} else if (rectificationDirectOpen?.mode === "new") {
void openRectificationFromChat(rectificationDirectOpen.sourceSessionId);
// Stored offer stays offered. bindOfferToCase marks opened only after
// the case opens. A failed open must not mark it opened here.
void Promise.resolve(openRectificationFromChat(rectificationDirectOpen.sourceSessionId)).then((opened) => {
if (opened) return;
}, () => {
// The opener threw. Leave the stored offer offered.
});
}
try {
await persistSession(completedSession);
@@ -369,8 +369,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
/** C4: the offer card under a consultation answer starts (or resumes) the case. */
async function openRectificationFromChat(sourceSessionId: string) {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("homepage", null, sourceSessionId);
if (rectificationLimitedToSelf()) return null;
return openRectificationCase("homepage", null, sourceSessionId);
}
async function openRectificationSession(exactSessionId: string) {
@@ -64,9 +64,11 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
}
/**
* After a free direct-entry reply is appended, hang the opened offer on that
* message. Idempotent: an offer already on the message (including one a later
* bind gave a case id) is left as it is. Does not touch consultation_requests.
* After a free direct-entry reply is appended, hang the offer on that message.
* The record is not opened yet (`offered` + `autoOpen`); bindOfferToCase sets
* opened after the case actually opens. Idempotent: an offer already on the
* message (including one a later bind gave a case id) is left as it is.
* Does not touch consultation_requests.
*/
export async function persistOpenedDirectOffer(input: {
client: SupabaseClient;
+11 -3
View File
@@ -28,6 +28,12 @@ export const rectificationOfferRecordSchema = z.object({
offeredAt: z.string().min(1).max(40),
caseId: z.string().uuid().optional(),
rectificationSessionId: z.string().uuid().optional(),
/**
* Present only when this turn should open the case without a click.
* Writers omit it unless it is true. Older stored offers omit it too.
* The card stays an ordinary offer until status is opened (BUG-1306 F3).
*/
autoOpen: z.boolean().optional(),
}).strict();
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
@@ -149,8 +155,9 @@ export function bindOfferToCase(
for (let index = stored.length - 1; index >= 0; index -= 1) {
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
if (!offer || offer.caseId) continue;
// A direct open records `opened` before the case id exists (BUG-1306).
// An offer already bound to another case is left alone.
// Offered cards, including a direct open that is still offered (autoOpen).
// An older direct-open row stored as opened before it had a case id still binds.
// Returned rows, and a card already bound to another case, are left alone.
if (offer.status !== "offered" && offer.status !== "opened") continue;
const next = [...stored];
next[index] = {
@@ -164,7 +171,8 @@ export function bindOfferToCase(
/**
* After `complete_consultation_free` appends the one-sentence reply, hang the
* opened offer on that message so a reload can still find the source. Returns
* offer on that message so a reload can still find the source. The record is
* not opened yet (still `offered`, with `autoOpen` on a direct open). Returns
* the next array, "present" when that message already has an offer (do not
* overwrite a later case id), or "missing" when the free reply is not there yet.
*/
+7 -3
View File
@@ -1073,6 +1073,7 @@ function offerRecord(
hook: RectificationOfferHook,
decision: Extract<RectificationOfferDecision, { ok: true }>,
status: RectificationOfferRecord["status"],
autoOpen = false,
): RectificationOfferRecord {
return {
status,
@@ -1080,13 +1081,16 @@ function offerRecord(
questionEstimate: decision.questionEstimate,
sourceQuestion: hook.revisitQuestion,
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
// Only when true. Ordinary offers and refusals omit the key.
...(autoOpen ? { autoOpen: true } : {}),
};
}
/**
* Explicit request (BUG-1306): the server already decided. A pass records an
* opened offer with no case id yet and tells the stream to open. A case
* already in progress resumes that case. Anything else is one sentence.
* offered card with autoOpen and tells the stream to open. Status stays
* offered until the case is bound. A case already in progress resumes that
* case. Anything else is one sentence and writes no offer.
*/
async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState,
@@ -1094,7 +1098,7 @@ async function finishExplicitRectificationRequest(
decision: RectificationOfferDecision,
) {
if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "opened");
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
state.rectificationDirectEntry = {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
@@ -4,7 +4,10 @@ import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { RectificationOfferCard } from "../src/components/rectification-in-chat-cards.tsx";
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
import {
@@ -13,10 +16,14 @@ import {
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
RECTIFICATION_OFFER_TOOL_ID,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
offerForCase,
parseRectificationOfferRecord,
rectificationOfferCardCopy,
sourceFromSessionRows,
type RectificationOfferRecord,
} from "../src/lib/rectification-in-chat.ts";
@@ -179,13 +186,15 @@ test("the free reply can carry the opened offer once, without clobbering a case
assert.equal(withCase, "present");
});
test("explicit pass opens once, records an opened offer, and does not open again", async () => {
test("explicit pass opens once, records an offered auto-open offer, and does not open again", async () => {
const fresh = offerTool();
const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true });
assert.equal(result.offered, true);
assert.equal(result.directEntry, "new");
assert.match(String(result.note), /好,我们开始核对出生时间/);
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
assert.equal(fresh.state.rectificationOffer?.status, "offered");
assert.equal(fresh.state.rectificationOffer?.autoOpen, true);
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
assert.deepEqual(fresh.state.rectificationDirectEntry, {
kind: "new",
@@ -199,14 +208,15 @@ test("explicit pass opens once, records an opened offer, and does not open again
assert.match(String(again.note), /已经在打开/);
assert.match(String(again.note), /不要再写解读/);
assert.equal(fresh.decisions(), 1);
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
});
test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => {
const direct = offerTool({ alreadyOffered: true });
const opened = await direct.tool.execute({ explicitRequest: true });
assert.equal(opened.directEntry, "new");
assert.equal(direct.state.rectificationOffer?.status, "opened");
assert.equal(direct.state.rectificationOffer?.status, "offered");
assert.equal(direct.state.rectificationOffer?.autoOpen, true);
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
const card = offerTool({ alreadyOffered: true });
@@ -375,7 +385,7 @@ test("natal and window direct entry or explicit refusal skips the runtime-contra
sourceSessionId: SOURCE_SESSION,
...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}),
};
if (kind === "new") state.rectificationOffer = offer("opened");
if (kind === "new") state.rectificationOffer = offer("offered", { autoOpen: true });
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
yield STOP_FINISH;
})(),
@@ -405,7 +415,8 @@ test("natal and window direct entry or explicit refusal skips the runtime-contra
assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk");
assert.equal(done.type === "run.completed" && done.receipt, undefined);
if (kind === "new") {
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "opened");
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "offered");
assert.equal(done.type === "run.completed" && done.rectificationOffer?.autoOpen, true);
assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined);
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
sourceSessionId: SOURCE_SESSION,
@@ -450,7 +461,7 @@ test("a reading that already streamed is replaced by the direct-entry sentence",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: SOURCE_SESSION,
};
state.rectificationOffer = offer("opened");
state.rectificationOffer = offer("offered", { autoOpen: true });
yield STOP_FINISH;
})(),
retry: async () => {
@@ -568,6 +579,9 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
assert.match(directOpen, /if \(opened\) return/);
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
const page = src("../src/app/(app)/page.tsx");
@@ -586,4 +600,85 @@ test("the offer rule is defined once, and direct entry settles with the free com
rectificationOffer: offer("opened"),
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
});
consultationAgentPublicEventSchema.parse({
type: "run.completed",
responseKind: "smalltalk",
rectificationOffer: offer("offered", { autoOpen: true }),
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
});
const surface = src("../src/hooks/use-rectification-surface.ts");
const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession"));
assert.match(fromChat, /return null/);
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/);
});
function cardHtml(record: RectificationOfferRecord) {
return renderToString(createElement(RectificationOfferCard, { offer: record, disabled: false, onStart() {} }));
}
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
const pending = offer("offered", { autoOpen: true });
const html = cardHtml(pending);
assert.match(html, /大约 8 题,会用掉 3 点/);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.equal(html.includes("继续核对出生时间"), false);
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
const opened = bound![0]!.rectificationOffer as RectificationOfferRecord;
assert.equal(opened.status, "opened");
assert.equal(opened.autoOpen, true);
assert.equal(opened.caseId, CASE_ID);
const openedHtml = cardHtml(opened);
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(openedHtml, /继续核对出生时间/);
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
});
test("a failed direct open leaves the offer offered and clickable", () => {
const pending = offer("offered", { autoOpen: true });
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const opened = null;
const next = opened ? bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) : null;
assert.equal(next, null);
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const html = cardHtml(pending);
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes("disabled=\"\""), false);
assert.equal(html.includes("disabled=\"disabled\""), false);
});
test("stored offers offered, opened, and returned still parse; returned hides the card", () => {
for (const status of ["offered", "opened", "returned"] as const) {
const parsed = parseRectificationOfferRecord(offer(status));
assert.equal(parsed?.status, status);
assert.equal(parsed?.autoOpen, undefined);
assert.equal("autoOpen" in (parsed ?? {}), false);
}
const flagged = parseRectificationOfferRecord(offer("offered", { autoOpen: true }));
assert.equal(flagged?.status, "offered");
assert.equal(flagged?.autoOpen, true);
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
assert.equal(cardHtml(offer("returned")), "");
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
});
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
// The record is not opened yet. The name of the attacher is historical.
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
const card = offer("offered", { autoOpen: true });
const attached = attachOpenedOfferToFreeMessage(
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
{ sentence, offer: card },
);
assert.ok(Array.isArray(attached));
assert.deepEqual(attached[1]!.rectificationOffer, card);
assert.equal((attached[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
});
@@ -279,6 +279,53 @@ test("the open route binds the offer only for a consultation source; the source
assert.doesNotMatch(server, /createAdminSupabaseClient/);
});
test("an auto-open offer shows the ordinary card until the case is bound, and a reload keeps autoOpen", async () => {
const pending = offer("offered", { autoOpen: true });
const waiting = [
{ role: "user" as const, text: "请帮我核对出生时间" },
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
];
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
assert.match(html, /开始核对出生时间/);
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
assert.doesNotMatch(html, /出生时间核对已经开始/);
assert.doesNotMatch(html, /继续核对出生时间/);
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
assert.match(openedHtml, /出生时间核对已经开始/);
assert.match(openedHtml, /继续核对出生时间/);
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const failed = waiting;
assert.equal((failed[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
const reloaded = readSessions([{
id: SOURCE_SESSION,
title: "虚构",
theme: "marriage",
messages: [{ role: "assistant", text: "还没打开", rectificationOffer: pending }],
}], null);
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.status, "offered");
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, true);
const old = readSessions([{
id: SOURCE_SESSION,
title: "虚构",
theme: "marriage",
messages: [
{ role: "assistant", text: "a", rectificationOffer: offer("offered") },
{ role: "assistant", text: "b", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
{ role: "assistant", text: "c", rectificationOffer: offer("returned", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
],
}], null);
assert.deepEqual(old.sessions[0]!.messages.map((message) => message.rectificationOffer?.status), ["offered", "opened", "returned"]);
assert.equal(old.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, undefined);
const returnedHtml = renderToString(createElement(ChatTranscript, transcriptProps(old.sessions[0]!.messages.slice(2) as ChatMessage[], [])));
assert.doesNotMatch(returnedHtml, /rectification-offer-card/);
});
test("stored sessions keep the offer and the divider when the browser reads them back (BUG-1248/1250)", async () => {
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;