feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)

TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-07 12:58:58 +08:00
co-authored by Claude Opus 5.5
parent 328f919d5d
commit 6ad0bea320
51 changed files with 1487 additions and 243 deletions
+20 -1
View File
@@ -14,6 +14,8 @@ import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/l
import { recordModerationEvent } from "@/lib/moderation/log";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
import { decideRectificationOffer } from "@/lib/rectification-in-chat-server";
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
import {
ConsultationPlanValidationError,
@@ -55,6 +57,7 @@ import {
natalUserTurnShape,
natalConsultationThinkingPlan,
type PublicThinkingSection,
RECTIFICATION_OFFER_RULE,
} from "@/lib/consultation-thinking-plan";
import {
AGENT_MAX_STEPS,
@@ -66,6 +69,7 @@ import {
consultationNatalPrepareStep,
consultationWindowPrepareStep,
createConsultationAgentContext,
type RectificationOfferHook,
createWindowConsultationAgentContext,
precomputeWindowConsultation,
windowPrecomputedPacketMessage,
@@ -240,7 +244,7 @@ function generalNoMinuteInstruction(hasPublicDaily: boolean) {
}
function declaredWindowInstruction() {
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。";
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。" + RECTIFICATION_OFFER_RULE;
}
function usesPublicDailyGeneralAgent(
@@ -867,6 +871,8 @@ export async function POST(request: Request) {
techniqueTruth,
workflowReceipt,
...(agentExecutionReceipt ? { agentExecutionReceipt } : {}),
// BUG-1248: the offer card hangs under this answer, also after a reload.
...(latencyState?.rectificationOffer ? { rectificationOffer: latencyState.rectificationOffer } : {}),
};
const actualUsage = await usagePayload(usage);
const completion = await retryDetachedSettlement(async () => {
@@ -950,6 +956,17 @@ export async function POST(request: Request) {
// evidence of having done so.
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
latencyState = state;
// C2/C3 (BUG-1248): the offer tool asks the server, never decides itself.
const rectificationOffer: RectificationOfferHook = {
decide: () => decideRectificationOffer({
accounting,
userId,
subjectRole: prepared.subject?.role === "other" ? "other" : "self",
modelId: selectedModel.id,
}),
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
};
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
const agentStartedAt = Date.now();
@@ -1272,6 +1289,7 @@ export async function POST(request: Request) {
declaredWindow: prepared.declaredWindow,
abortSignal: agentAbortSignal,
state,
rectificationOffer,
});
const agent = getWindowJyotishAgent(selectedModel, agentContext);
const retry = async () => {
@@ -1409,6 +1427,7 @@ export async function POST(request: Request) {
serverChart: prepared.serverChart,
abortSignal: agentAbortSignal,
state,
rectificationOffer,
});
const agent = getJyotishAgent(selectedModel, agentContext);
const result = await streamWithOverflowRetry(agent, natalStreamOptions);
@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { findRectificationSource, returnToRectificationSource } from "@/lib/rectification-in-chat-server";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ caseId: string }> };
/**
* GET /api/rectification/cases/[caseId]/source — the consultation this case
* was started from, or { source: null } (homepage / history cases).
* POST /api/rectification/cases/[caseId]/source — go back: appends the return
* divider to that consultation once and answers where to go.
* (TASK-rectification-in-chat-step1-20261006 C4/C5, BUG-1249/1250)
*/
async function clients(label: string) {
try {
return { supabase: await createServerSupabaseClient(), accounting: createAdminSupabaseClient() };
} catch (error) {
return { failure: jsonForSupabaseSetupFailure(error, label) };
}
}
async function caller(supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>) {
const { data: { user }, error } = await supabase.auth.getUser();
return error || !user ? null : user;
}
export async function GET(_request: Request, context: RouteContext) {
const opened = await clients("GET /api/rectification/cases/[caseId]/source");
if ("failure" in opened) return opened.failure;
const user = await caller(opened.supabase);
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
const source = await findRectificationSource(opened.supabase, user.id, caseId).catch(() => null);
return NextResponse.json({
source: source ? { sessionId: source.sessionId, question: source.question, returned: Boolean(source.returned) } : null,
});
}
export async function POST(_request: Request, context: RouteContext) {
const opened = await clients("POST /api/rectification/cases/[caseId]/source");
if ("failure" in opened) return opened.failure;
const user = await caller(opened.supabase);
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
try {
const result = await returnToRectificationSource({
client: opened.supabase,
accounting: opened.accounting,
userId: user.id,
caseId,
});
if (!result) {
return NextResponse.json({ error: "找不到这次校正对应的对话", code: "source_not_found" }, { status: 404 });
}
return NextResponse.json({ sessionId: result.sessionId, record: result.record });
} catch {
return NextResponse.json({ error: "暂时没能回到刚才的对话,请稍后再试", code: "return_failed" }, { status: 500 });
}
}
@@ -12,6 +12,7 @@ import {
import { parseOpenRectificationCaseRequest } from "@/lib/rectification-agentic/v9/open-request";
import { prepareCaseSegmentChecks } from "@/lib/rectification-agentic/v9/segment-checks";
import { resolveRectificationProductDomain } from "@/lib/rectification-agentic/v9/product-domain";
import { bindRectificationSource } from "@/lib/rectification-in-chat-server";
export const runtime = "nodejs";
@@ -78,6 +79,18 @@ export async function POST(request: Request) {
if (response.shouldStartOpening) {
await prepareCaseSegmentChecks({ accounting: accounting as never, userId: user.id, caseId: response.caseId });
}
// C4 (BUG-1249): a case started from a consultation's offer card is bound
// to that card, so the case can go back there. Best effort: the case is
// already open, and a failed bind only loses the way back.
if (parsed.intent === "homepage" && parsed.sourceSessionId) {
await bindRectificationSource({
client: supabase,
userId: user.id,
sourceSessionId: parsed.sourceSessionId,
caseId: response.caseId,
rectificationSessionId: response.sessionId,
}).catch(() => false);
}
return NextResponse.json(response);
} catch (error) {
return serviceErrorResponse(error);