merge rectification explicit open into staging (issue #103, BUG-1421 renumbered to BUG-1423)
Independent Staging Quality Gate / validate (push) Successful in 21m55s
Independent Staging Quality Gate / publish (push) Successful in 4m46s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
Jesse_Chen
2026-10-23 11:30:11 +08:00
co-authored by Claude Opus 5.5
16 changed files with 354 additions and 63 deletions
+2 -15
View File
@@ -33,7 +33,7 @@ import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server";
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
import { loadResumableRectificationSessionId } from "@/lib/rectification-resume-session";
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
import {
ConsultationPlanValidationError,
@@ -1025,20 +1025,7 @@ export async function POST(request: Request) {
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
sourceSessionId: sessionId,
loadResumableSessionId: async () => {
const summary = await getRectificationEntrySummary(accounting, userId);
const caseId = summary.latestResumable?.caseId;
if (!caseId) return null;
const { data, error } = await accounting
.from("agentic_rectification_cases")
.select("session_id")
.eq("user_id", userId)
.eq("id", caseId)
.maybeSingle();
if (error || !data) return null;
const resumableSessionId = (data as { session_id?: unknown }).session_id;
return typeof resumableSessionId === "string" && resumableSessionId ? resumableSessionId : null;
},
loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId),
};
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
@@ -61,13 +61,6 @@ export function evidenceWritesAllowed(
return isResumableStatus(status);
}
/**
* The one-resumable-case-per-user invariant, mirrored from the database
* partial unique index. The database is the enforcement point; this is the
* contract the service layer relies on.
*/
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
@@ -9,6 +9,7 @@ import {
appendRectificationReturn,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
blocksNewRectification,
decideRectificationOfferWith,
offerForCase,
sourceFromSessionRows,
@@ -35,7 +36,10 @@ export async function decideRectificationOffer(input: {
return decideRectificationOfferWith({
subjectRole: input.subjectRole,
productEnabled: () => isProductEnabled("rectification"),
hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase,
hasResumableCase: async () => {
const summary = await getRectificationEntrySummary(input.accounting, input.userId);
return blocksNewRectification(summary.latestResumable?.status ?? "");
},
creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost,
});
}
+17 -1
View File
@@ -86,7 +86,23 @@ export const RECTIFICATION_DIRECT_NEW_COPY = "好,我们开始核对出生时
export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。";
export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。";
export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持";
export const RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY = "可在历史对话里接着做。";
/**
* Chat entry only. An accepted case is a finished stop, so it must not block
* a new explicit open or the ordinary offer card. The shared resumable set
* still includes that status for old sessions, skill upgrade and rebaseline.
*/
const ENTRY_BLOCKING_RECTIFICATION_STATUSES = new Set<string>([
"draft",
"collecting_evidence",
"candidate_ready",
"needs_rebaseline",
"paused",
]);
export function blocksNewRectification(status: string): boolean {
return ENTRY_BLOCKING_RECTIFICATION_STATUSES.has(status);
}
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
return record.adopted && record.clock
@@ -0,0 +1,50 @@
import type { DataClient } from "@/lib/db/data-client";
import {
RectificationCaseServiceError,
getRectificationCase,
getRectificationEntrySummary,
} from "@/lib/rectification-agentic/v9/case-service";
import { blocksNewRectification } from "@/lib/rectification-in-chat";
/**
* Raised when an in-progress case cannot be resumed. The message is the
* category only, so a log of this error cannot carry a profile or a quote.
*/
export class RectificationResumeLookupError extends Error {
readonly errorCategory: string;
readonly hasCaseId: boolean;
constructor(errorCategory: string, hasCaseId: boolean) {
super(errorCategory);
this.name = "RectificationResumeLookupError";
this.errorCategory = errorCategory;
this.hasCaseId = hasCaseId;
}
}
function safeCategory(value: string): string {
return /^[a-z0-9_]+$/.test(value) ? value : "lookup_failed";
}
/**
* Session of the latest case that still blocks a new chat entry.
* Accepted cases do not block. The read goes through the case RPC: the
* service role cannot select the cases table.
*/
export async function loadResumableRectificationSessionId(
accounting: DataClient,
userId: string,
): Promise<string | null> {
const summary = await getRectificationEntrySummary(accounting, userId);
const latest = summary.latestResumable;
if (!latest?.caseId || !blocksNewRectification(latest.status)) return null;
try {
const view = await getRectificationCase(accounting, userId, latest.caseId);
if (!view.sessionId) throw new RectificationResumeLookupError("missing_session", true);
return view.sessionId;
} catch (error) {
if (error instanceof RectificationResumeLookupError) throw error;
const category = error instanceof RectificationCaseServiceError ? error.code : "lookup_failed";
throw new RectificationResumeLookupError(safeCategory(category), true);
}
}
+39 -9
View File
@@ -2,7 +2,6 @@ import {
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID,
type RectificationOfferDecision,
@@ -1155,7 +1154,7 @@ const evidenceLookupInputSchema = z.object({
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`,
product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`,
case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
case_in_progress: "用户已经有一次没做完的生时校正。这次不出提议卡,正文照常回答这一轮的问题。",
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
};
@@ -1176,16 +1175,33 @@ function offerRecord(
};
}
function resumeFailureLog(error: unknown): { errorCategory: string; hasCaseId: boolean } {
if (error && typeof error === "object" && "errorCategory" in error && "hasCaseId" in error) {
const record = error as { errorCategory?: unknown; hasCaseId?: unknown };
const errorCategory = typeof record.errorCategory === "string" && /^[a-z0-9_]+$/.test(record.errorCategory)
? record.errorCategory
: "lookup_failed";
return { errorCategory, hasCaseId: record.hasCaseId === true };
}
return { errorCategory: "lookup_failed", hasCaseId: false };
}
function logResumeLookupFailed(requestId: string, errorCategory: string, hasCaseId: boolean) {
console.warn("rectification_resume_lookup_failed", { requestId, errorCategory, hasCaseId });
}
/**
* Explicit request (BUG-1306): the server already decided. A pass records an
* 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.
* case. If that session cannot be read, open a new one instead of a dead end.
* Anything else is one sentence and writes no offer.
*/
async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState,
hook: RectificationOfferHook,
decision: RectificationOfferDecision,
requestId: string,
) {
if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
@@ -1202,9 +1218,13 @@ async function finishExplicitRectificationRequest(
}
if (decision.reason === "case_in_progress") {
let resumeSessionId: string | null = null;
let lookupFailed = false;
try {
resumeSessionId = await hook.loadResumableSessionId();
} catch {
} catch (error) {
lookupFailed = true;
const failure = resumeFailureLog(error);
logResumeLookupFailed(requestId, failure.errorCategory, failure.hasCaseId);
resumeSessionId = null;
}
if (resumeSessionId) {
@@ -1221,12 +1241,18 @@ async function finishExplicitRectificationRequest(
note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`,
};
}
if (!lookupFailed) logResumeLookupFailed(requestId, "missing_session", false);
state.rectificationDirectEntry = {
kind: "refused",
sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: hook.sourceSessionId,
};
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress };
return {
offered: false as const,
directEntry: "new" as const,
reason: decision.reason,
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
};
}
const sentence = decision.reason === "not_self"
? RECTIFICATION_DIRECT_NOT_SELF_COPY
@@ -1244,7 +1270,11 @@ async function finishExplicitRectificationRequest(
* whether a card may be shown or this turn opens rectification. Never calculates.
* The rule text lives only in RECTIFICATION_OFFER_RULE.
*/
function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
function createRectificationOfferTool(ctx: Readonly<{
state: ConsultationRuntimeState;
requestId: string;
rectificationOffer?: RectificationOfferHook;
}>) {
return createTool({
id: RECTIFICATION_OFFER_TOOL_ID,
description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.",
@@ -1274,7 +1304,7 @@ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntime
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
}
const decision = await hook.decide();
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision);
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision, ctx.requestId);
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
ctx.state.rectificationOffer = offerRecord(hook, decision, "offered");
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };