merge rectification explicit open into staging (issue #103, BUG-1421 renumbered to BUG-1423)
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
|
||||
|
||||
Reference in New Issue
Block a user