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
+7 -27
View File
@@ -14,20 +14,13 @@ import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { HomeBootstrapError } from "@/components/home-bootstrap-error";
import { HomeOnboardingShellSlot } from "@/components/home-onboarding-shell-slot";
import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal";
import {
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "@/lib/rectification-entry";
import {
composerLocksAsRectification,
ordinaryComposerPlaceholder,
rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
RECTIFICATION_OPENING_LABEL,
} from "@/lib/rectification-surface-state";
import { declaredBirthTime } from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
type ChatMessageFeedback,
@@ -51,7 +44,6 @@ import { StarterEntries, StarterGreeting } from "@/components/starter-home";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
@@ -294,6 +286,7 @@ export default function Home() {
onCopy() {},
onRegenerate() {},
onFollowUp() {},
onStartRectification() {},
});
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const sessionDetailInFlight = useRef(new Set<string>());
@@ -379,17 +372,6 @@ export default function Home() {
: activeReplyOutcome?.phase ?? "idle";
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
const accountId = account?.user.id;
const rectificationCardAction = resolveRectificationEntryAction(
rectificationEntrySummary ?? {
hasResumableCase: false,
hasTerminalCaseWithTime: false,
latestResumable: null,
latestTerminal: null,
},
);
const rectificationCardLabel = rectificationLoading
? RECTIFICATION_OPENING_LABEL
: rectificationEntryLabels[rectificationCardAction];
const rectificationDeclaration = { declaredTime: declaredBirthTime(profile), birthDate: profile.date || null };
const rectificationErrorMessage = rectificationError === "profile_incomplete"
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
@@ -432,7 +414,7 @@ export default function Home() {
const {
setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
openRectificationFromHomepage, openRectificationSession, reopenRectificationSession,
openRectificationFromChat, openRectificationSession, reopenRectificationSession,
} = useRectificationSurface({
account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectification, setRectification,
@@ -753,7 +735,7 @@ export default function Home() {
startNewChat,
continueInNewChat,
refreshAccount,
openAccountDialog, openRectificationFromHomepage, openRectificationSession,
openAccountDialog, openRectificationSession,
onboardingPaywallShown, setOnboardingPaywallOpen,
});
const jumpToLatestVisible = !rectificationSurfaceOpen
@@ -1035,6 +1017,9 @@ export default function Home() {
onFollowUp(question) {
void send(question, activeSession?.theme);
},
onStartRectification(sourceSessionId) {
void openRectificationFromChat(sourceSessionId);
},
};
});
@@ -1320,11 +1305,6 @@ export default function Home() {
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
productEntrypointsDisabled={productEntrypointsDisabled}
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
rectificationCardLabel={rectificationCardLabel}
rectificationLoading={rectificationLoading}
rectificationMutationPending={rectificationMutationPending}
openRectificationFromHomepage={openRectificationFromHomepage}
rectificationEntrySummary={rectificationEntrySummary}
/>
)}
</div>}
+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);
+49
View File
@@ -6463,3 +6463,52 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.settings-modal { height: 100dvh; max-height: 100dvh; }
}
}
/* Rectification reached from a consultation (BUG-1248/1250). The offer card
sits under the answer that offered it, at the same inset as the follow-ups;
the return divider is a quiet centred line, not a message; answers written
before the birth time changed get one caption above them. */
.rectification-offer-card {
display: grid;
gap: var(--space-2);
justify-items: start;
margin: var(--space-2) 0 var(--space-3) var(--assistant-content-inset);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas-muted);
}
.rectification-offer-card p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.6;
}
/* The divider's entry carries no actions row, so it needs no trailing gap of its own. */
.message-entry--divider {
padding: 0;
}
.rectification-return-divider {
display: grid;
gap: var(--space-2);
justify-items: center;
margin: var(--space-4) 0;
text-align: center;
}
.rectification-return-divider__line {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.5;
}
.message-before-change {
margin: 0 0 var(--space-1) var(--assistant-content-inset);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-return-row {
display: flex;
justify-content: flex-start;
padding-bottom: var(--space-2);
}
+35 -2
View File
@@ -28,12 +28,20 @@ import {
noteUnsplitListRender,
} from "@/lib/home-streaming-render-probe";
import { memo, useEffect, useState, type MutableRefObject } from "react";
import {
BeforeBirthTimeChangeLabel,
RectificationOfferCard,
RectificationReturnDivider,
} from "@/components/rectification-in-chat-cards";
import { lastBirthTimeChangeIndex } from "@/lib/rectification-in-chat";
export type ChatTranscriptActions = Readonly<{
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
onCopy: (feedbackKey: string, text: string) => void;
onRegenerate: (renderKey: string) => void;
onFollowUp: (question: string) => void;
/** The offer card under an answer starts rectification for this consultation (BUG-1248). */
onStartRectification: (sourceSessionId: string) => void;
}>;
export type ChatTranscriptProps = Readonly<{
@@ -89,15 +97,18 @@ function MessageEntry({
productEntrypointsDisabled,
actionsRef,
}: MessageEntryProps) {
// The return divider (BUG-1250) is a marker, not an answer: no row, no actions.
const returnRecord = message.rectificationReturn;
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
&& Boolean(message.text)
&& !returnRecord;
const feedbackKey = `${sessionId}:${message.renderKey}`;
// 不满意原因 opens right after a 👎 in this view; stored 👎 ratings do not reopen it.
const [reasonOpen, setReasonOpen] = useState(false);
const latestRegeneratableKey = !loading && !cancellationPending
? [...views].reverse().find((item) => (
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
item.role === "assistant" && item.state === "settled" && Boolean(item.text) && !item.rectificationReturn
))?.renderKey
: undefined;
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
@@ -112,8 +123,23 @@ function MessageEntry({
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
})
: [];
if (returnRecord) {
return (
<div className="message-entry message-entry--divider">
<RectificationReturnDivider
record={returnRecord}
disabled={productEntrypointsDisabled || loading || cancellationPending}
onRevisit={(question) => actionsRef.current.onFollowUp(question)}
/>
</div>
);
}
// P2: answers written before the birth time changed keep their text and get one line above.
const beforeBirthTimeChange = message.role === "assistant" && lastBirthTimeChangeIndex(views) > index;
const offer = sessionType === "consultation" && message.state === "settled" ? message.rectificationOffer : undefined;
return (
<div className="message-entry">
{beforeBirthTimeChange ? <BeforeBirthTimeChangeLabel /> : null}
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
@@ -134,6 +160,13 @@ function MessageEntry({
{showActions && reasonOpen && (
<ReplyDownReason feedbackKey={feedbackKey} onClose={() => setReasonOpen(false)} />
)}
{offer ? (
<RectificationOfferCard
offer={offer}
disabled={productEntrypointsDisabled || loading || cancellationPending}
onStart={() => actionsRef.current.onStartRectification(sessionId)}
/>
) : null}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
@@ -40,7 +40,8 @@ export type RectificationChatPanel = Readonly<{
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
pendingConsultationQuestion?: string | null;
canReturnToSource?: boolean;
onReturnToSource?: () => void;
headerSlot: HTMLElement | null;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
@@ -66,6 +66,9 @@ import {
repairRectificationQuestion,
} from "@/lib/rectification-chat-question-repair";
import { deriveRectificationChatView } from "@/lib/rectification-chat-view";
import { useRectificationAutoReturn } from "@/hooks/use-rectification-return";
import { RECTIFICATION_RETURN_BUTTON_LABEL } from "@/lib/rectification-in-chat";
import { isTerminalRectificationStatus } from "@/lib/rectification-entry";
/** Same ceiling as the main chat composer, so both surfaces count down the same way. */
const RECTIFICATION_COMPOSER_MAX_LENGTH = 500;
@@ -92,7 +95,9 @@ type RectificationAgenticChatProps = Readonly<{
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
/** This case was started from a consultation that it can go back to (BUG-1250). */
canReturnToSource?: boolean;
onReturnToSource?: () => void;
onRestart?: () => void;
headerSlot: HTMLElement | null;
}>;
@@ -124,7 +129,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onOpenBilling,
onSaved,
onOpeningConsumed,
pendingConsultationQuestion,
canReturnToSource = false,
onReturnToSource,
onRestart,
headerSlot,
} = props;
@@ -437,6 +443,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionRetryAttempts,
savedTime,
});
useRectificationAutoReturn({
enabled: canReturnToSource,
ready: questionGap === "verified_idle" || isTerminalRectificationStatus(caseStatus ?? ""),
busy,
onReturn: onReturnToSource,
});
// Same render-time store update as seededTurns: the lock must be current
// before this paint (BUG-620). A ref write here trips react-hooks/refs.
if (nextOfferLock !== selectionOfferLock) {
@@ -552,11 +564,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{resultNotice && canRecompare && !historyReadonly && (
<Button type="button" variant="outline" disabled={busy} onClick={() => void recompareHistoricalResult({ canRecompare, historyReadonly, busy, send })}>重新比较</Button>
)}
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
</p>
)}
{conversationState === "empty" && (
<div className="rectification-empty-state">
<p>{RECTIFICATION_EMPTY_COPY}</p>
@@ -645,6 +652,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{conversationAnchor.latestBelowFold && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{canReturnToSource && (
<div className="rectification-return-row">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onReturnToSource}>
{RECTIFICATION_RETURN_BUTTON_LABEL}
</Button>
</div>
)}
<ChatComposer
inputRef={composer}
value={draft}
@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import {
RECTIFICATION_BEFORE_CHANGE_LABEL,
RECTIFICATION_OFFER_CONTINUE_LABEL,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
rectificationOfferCardCopy,
rectificationReturnDividerText,
rectificationRevisitLabel,
type RectificationOfferRecord,
type RectificationReturnRecord,
} from "@/lib/rectification-in-chat";
/**
* The card under a consultation answer that offered birth-time rectification
* (BUG-1248). The server decided it may be shown and wrote the price; the
* click opens the case (the charge comes with the first answered question).
* A card whose case is open says so and offers to go back in; a card whose
* case has returned is gone (the divider below says what happened).
*/
export function RectificationOfferCard({
offer,
disabled,
onStart,
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
if (offer.status === "returned") return null;
const opened = offer.status === "opened";
return (
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
<p>{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}</p>
<Button type="button" variant={opened ? "outline" : "default"} size="sm" disabled={disabled} onClick={onStart}>
{opened ? RECTIFICATION_OFFER_CONTINUE_LABEL : RECTIFICATION_OFFER_START_LABEL}
</Button>
</div>
);
}
/**
* Where the reader came back from rectification (BUG-1250). Re-asking with the
* new time is a button and an ordinary consultation turn: nothing is sent, and
* nothing is charged, until it is clicked.
*/
export function RectificationReturnDivider({
record,
disabled,
onRevisit,
}: Readonly<{ record: RectificationReturnRecord; disabled: boolean; onRevisit: (question: string) => void }>) {
return (
<div className="rectification-return-divider" role="note">
<p className="rectification-return-divider__line">{rectificationReturnDividerText(record)}</p>
{record.adopted ? (
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => onRevisit(record.sourceQuestion)}>
{rectificationRevisitLabel(record.sourceQuestion)}
</Button>
) : null}
</div>
);
}
/** The small line above an answer written with the birth time before the change (P2). */
export function BeforeBirthTimeChangeLabel() {
return <p className="message-before-change">{RECTIFICATION_BEFORE_CHANGE_LABEL}</p>;
}
+4 -37
View File
@@ -1,8 +1,6 @@
"use client";
import { Clock3, MoonStar } from "lucide-react";
import { useCurrentSubject } from "@/lib/current-subject";
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
import { MoonStar } from "lucide-react";
/**
* The empty state is split across two slots on purpose.
@@ -46,26 +44,14 @@ export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrend
);
}
/* The line under the pills only appears when there is something to do about
it: a non-self subject cannot start a rectification. A resumable case says
nothing here any more (2026-09-30, product: 「这个提示可以去掉」); the case
is still in the history list. */
export function rectificationEntryHint(rectificationForSelf: boolean): string | null {
if (!rectificationForSelf) return "生时校正暂时只支持本人。";
return null;
}
/* The homepage has no 生时校正 pill any more (2026-10-07, BUG-1247): a
rectification starts from a consultation, when the agent offers it under an
answer. Old cases stay in the history list. */
export type StarterEntriesProps = {
readonly natalMinuteAvailable: boolean;
readonly dailyStarlanguageQuestion: string;
readonly productEntrypointsDisabled: boolean;
readonly startDailyStarlanguageConsultation: () => void;
readonly rectificationCardLabel: string;
readonly rectificationLoading: boolean;
readonly rectificationMutationPending: boolean;
readonly openRectificationFromHomepage: () => void;
/** Still passed by the page; the hint no longer reads it (2026-09-30). */
readonly rectificationEntrySummary: RectificationEntrySummary | null;
};
export function StarterEntries({
@@ -73,15 +59,8 @@ export function StarterEntries({
dailyStarlanguageQuestion,
productEntrypointsDisabled,
startDailyStarlanguageConsultation,
rectificationCardLabel,
rectificationLoading,
rectificationMutationPending,
openRectificationFromHomepage,
}: StarterEntriesProps) {
const subjectId = useCurrentSubject();
const rectificationForSelf = subjectId === "self";
const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势";
const rectificationHint = rectificationEntryHint(rectificationForSelf);
return (
<div className="starter-entries">
@@ -96,19 +75,7 @@ export function StarterEntries({
<MoonStar aria-hidden="true" />
<span>{dailyLabel}</span>
</button>
<button
className="starter-entry"
type="button"
aria-label={rectificationCardLabel}
data-opening={rectificationLoading ? "true" : undefined}
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending || !rectificationForSelf}
onClick={() => void openRectificationFromHomepage()}
>
<Clock3 aria-hidden="true" />
<span>生时校正</span>
</button>
</div>
{rectificationHint && <p className="starter-entry-hint">{rectificationHint}</p>}
{!natalMinuteAvailable && (
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
)}
+9 -35
View File
@@ -15,7 +15,6 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import {
@@ -72,6 +71,7 @@ import {
SESSION_NOT_CONSULTATION_CODE,
} from "@/lib/rectification-session-composer-guard";
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
import type { RectificationOfferRecord } from "@/lib/rectification-in-chat";
import type {
Account,
AccountDialog,
@@ -154,7 +154,6 @@ export type ConsultationRunParams = {
/** Set once the redeem paywall has been shown on this page (TASK-birth-sky-polish-20260928 P4). */
onboardingPaywallShown?: MutableRefObject<boolean>;
setOnboardingPaywallOpen?: (open: boolean) => void;
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null, sourceSessionId?: string | null) => Promise<void>;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
};
@@ -215,7 +214,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
openAccountDialog,
onboardingPaywallShown,
setOnboardingPaywallOpen,
openRectificationFromHomepage,
openRectificationSession,
} = params;
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
@@ -523,12 +521,12 @@ export function useConsultationRun(params: ConsultationRunParams) {
): Promise<boolean> {
const originalQuestion = text;
const question = text.trim();
// No text decides rectification any more (BUG-1247, C1): the agent offers
// it under an answer and the user starts it from that card.
const consultEntrypoint = entrypoint
?? (isRectificationHandoffQuestion(question)
? "birth_time_rectification" as const
: isGeneralDailyFortuneQuestion(question)
? "daily_starlanguage" as const
: null);
?? (isGeneralDailyFortuneQuestion(question)
? "daily_starlanguage" as const
: null);
const resumeRequestId = options.resumeRequestId;
const resuming = Boolean(resumeRequestId);
if (!resuming && subjectDeleted) {
@@ -588,33 +586,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
return false;
}
if (consultEntrypoint === "birth_time_rectification") {
const previousUserQuestion = [...currentSession.messages]
.reverse()
.find((message) => message.role === "user")
?.text
.trim() ?? null;
const pendingQuestion = previousUserQuestion && !isRectificationHandoffQuestion(previousUserQuestion)
? previousUserQuestion
: null;
if (isUnsavedEmptyConsultation(currentSession)) {
try {
await persistSession(currentSession, "create");
} catch (caught) {
setRequestError({
sessionId: currentSession.id,
message: caught instanceof Error ? caught.message : "来源对话未能保存到云端。",
});
return false;
}
}
await openRectificationFromHomepage(pendingQuestion, currentSession.id);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
return false;
}
const birthPlace = selectedBirthPlace(profile);
if (!birthPlace) return false;
@@ -902,6 +873,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
.filter((item) => item && item !== "none"),
};
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
let rectificationOffer: RectificationOfferRecord | undefined;
let runCompleted = false;
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
const reader = response.body.getReader();
@@ -980,6 +952,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
}
if (event.type === "run.completed") {
runCompleted = true;
rectificationOffer = event.rectificationOffer;
if (event.responseKind === "smalltalk") {
responseKind = "smalltalk";
} else if (event.receipt) {
@@ -1087,6 +1060,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
...(rectificationOffer ? { rectificationOffer } : {}),
}),
}],
updatedAt: timestamp(),
@@ -0,0 +1,39 @@
"use client";
import { useEffect, useRef } from "react";
/** How long the last line stays on screen before going back (BUG-1250). */
export const RECTIFICATION_AUTO_RETURN_DELAY_MS = 2_500;
/**
* C5 (TASK-rectification-in-chat-step1-20261006, BUG-1250): a case started
* from a consultation goes back there by itself once it is done — the
* post-adopt check finished, or the case ended. Only a case the reader worked
* on in this view returns by itself: opening a finished case from history
* keeps it on screen (the reader came to look at it), and the manual
* 「回到刚才的对话」 button is always there while a source exists.
*/
export function useRectificationAutoReturn(input: {
enabled: boolean;
ready: boolean;
busy: boolean;
onReturn?: () => void;
}) {
const actedInView = useRef(false);
const fired = useRef(false);
const onReturn = useRef(input.onReturn);
useEffect(() => {
onReturn.current = input.onReturn;
});
useEffect(() => {
if (input.busy) actedInView.current = true;
}, [input.busy]);
useEffect(() => {
if (!input.enabled || !input.ready || input.busy || !actedInView.current || fired.current) return;
const timer = window.setTimeout(() => {
fired.current = true;
onReturn.current?.();
}, RECTIFICATION_AUTO_RETURN_DELAY_MS);
return () => window.clearTimeout(timer);
}, [input.enabled, input.ready, input.busy]);
}
+64 -13
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { resolveSessionTitle } from "@/lib/agent-reply";
import { fetchSessionDetail } from "@/lib/home-cloud-sync";
import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeSessionUrl } from "@/lib/chat-session-url";
@@ -44,6 +44,7 @@ import {
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { notifyError } from "@/lib/notify";
import { bindOfferToCase } from "@/lib/rectification-in-chat";
export type RectificationShellState = {
sessionId: string | null;
@@ -167,7 +168,9 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId");
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
// The consultation this case was started from (BUG-1249/1250); null for
// cases started from history or before 2026-10-07.
const [rectificationSource, setRectificationSource] = useState<{ caseId: string; sessionId: string } | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
@@ -204,7 +207,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
async function openRectificationCase(
intent: "homepage" | "session" | "new",
exactSessionId: string | null,
pendingConsultationQuestion: string | null,
sourceSessionId: string | null = null,
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
@@ -217,7 +219,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
sessionSelectionSource.current = "user";
setRectificationSessionId(null);
setRectificationCaseId(null);
setRectificationPendingQuestion(null);
setRectificationSource(null);
setOnboardingStep(missingStep);
setComposerNotice("请先完成出生资料,再开始生时校正。");
return null;
@@ -312,11 +314,22 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
// the reader never sees an empty transcript or an empty question area
// while they load. Past the deadline the surface opens with what arrived
// and its own retry path fills in the rest.
// The source lookup (BUG-1249) runs alongside the hydration, not after it.
const sourceRead = readRectificationSource(opened.caseId);
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
});
const source = await sourceRead;
if (sourceSessionId) {
// The server bound the offer card to this case; mirror it so the card
// under that answer stops offering a new start.
updateSession(sourceSessionId, (session) => {
const bound = bindOfferToCase(session.messages, { caseId: opened.caseId, rectificationSessionId: opened.sessionId });
return bound ? { ...session, messages: bound as Message[] } : session;
});
}
setRectificationPendingQuestion(pendingConsultationQuestion);
setRectificationSource(source ?? (sourceSessionId ? { caseId: opened.caseId, sessionId: sourceSessionId } : null));
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
@@ -354,18 +367,55 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
return true;
}
async function openRectificationFromHomepage(
pendingConsultationQuestion: string | null = null,
sourceSessionId: string | null = null,
) {
/** 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, pendingConsultationQuestion, sourceSessionId);
await openRectificationCase("homepage", null, sourceSessionId);
}
async function openRectificationSession(exactSessionId: string) {
await openRectificationCase("session", exactSessionId, null);
}
async function readRectificationSource(caseId: string): Promise<{ caseId: string; sessionId: string } | null> {
try {
const response = await fetch(`/api/rectification/cases/${caseId}/source`, { cache: "no-store" });
if (!response.ok) return null;
const payload = await response.json().catch(() => null) as { source?: { sessionId?: unknown } | null } | null;
const sessionId = payload?.source?.sessionId;
return typeof sessionId === "string" && sessionId ? { caseId, sessionId } : null;
} catch {
return null;
}
}
/**
* C5/C6 (BUG-1250): the server appends the return divider to the source
* consultation once; then that consultation is reloaded from the server and
* made active, which closes the rectification surface.
*/
async function returnToRectificationSource() {
const source = rectificationSource;
if (!source || !modelCatalog) return;
try {
const response = await fetch(`/api/rectification/cases/${source.caseId}/source`, { method: "POST" });
const payload = await response.json().catch(() => null) as { sessionId?: unknown; error?: unknown } | null;
if (!response.ok || typeof payload?.sessionId !== "string") {
notifyError(typeof payload?.error === "string" ? payload.error : "暂时没能回到刚才的对话,请稍后再试");
return;
}
const sessionId = payload.sessionId;
const detailed = await fetchSessionDetail(sessionId, modelCatalog).catch(() => null);
if (detailed) setSessions((current) => mergeHydratedSession(current, detailed));
void refreshAccount();
activeSessionIdRef.current = sessionId;
setActiveSessionId(sessionId);
if (!uiPreview.current) writeSessionUrl(sessionId, "push");
} catch {
notifyError("暂时没能回到刚才的对话,请稍后再试");
}
}
async function reopenRectificationSession(exactSessionId: string) {
setRectificationError("");
setRectificationErrorSessionId(null);
@@ -381,7 +431,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationError("profile_incomplete");
setRectificationSessionId(null);
setRectificationCaseId(null);
setRectificationPendingQuestion(null);
setRectificationSource(null);
const missingStep = missingProfileStep(profile);
if (missingStep) {
setOnboardingStep(missingStep);
@@ -418,7 +468,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
shouldStartOpening: rectificationShouldStartOpening,
initialTurns: rectificationTurns,
initialSnapshot: rectificationSnapshot,
pendingConsultationQuestion: rectificationPendingQuestion,
canReturnToSource: Boolean(rectificationSource && rectificationSource.caseId === rectificationCaseId),
onReturnToSource: () => void returnToRectificationSource(),
headerSlot: rectificationHeaderSlot,
onMessagesChange: handleRectificationMessagesChange,
onCompleted: () => {
@@ -441,7 +492,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
refreshRectificationEntrySummary,
refreshRectificationCase,
openRectificationCase,
openRectificationFromHomepage,
openRectificationFromChat,
openRectificationSession,
reopenRectificationSession,
startNewRectification,
+1 -2
View File
@@ -3,7 +3,6 @@ import { consultationDomainDefinition } from "./consultation-domain-registry.ts"
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
import {
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
} from "./consultation-entrypoint.ts";
import { shanghaiDateParts } from "./session-shanghai-clock.ts";
@@ -100,7 +99,7 @@ export function resolveSessionTitle(
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
return datedSessionTitle(at, "今日节奏");
}
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
if (options.entrypoint === "birth_time_rectification") {
return "生时校正";
}
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
+6 -1
View File
@@ -1,6 +1,7 @@
import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
import type { RectificationOfferRecord, RectificationReturnRecord } from "./rectification-in-chat.ts";
import {
consultationTimelineFromSettled,
type ConsultationTimelineRow,
@@ -62,6 +63,10 @@ export type ChatMessage = {
readonly techniqueTruth?: string;
readonly agentExecutionReceipt?: AgentExecutionReceipt;
readonly workflowReceipt?: WorkflowReceipt;
/** This answer offered birth-time rectification; the card hangs under it (BUG-1248). */
readonly rectificationOffer?: RectificationOfferRecord;
/** A divider written when the user came back from rectification (BUG-1250); never a model turn. */
readonly rectificationReturn?: RectificationReturnRecord;
};
export type ChatMessageView = ChatMessage & {
@@ -85,7 +90,7 @@ export function settledChatMessageViews(
...message,
renderKey: `message-${index}`,
state: "settled" as const,
...(message.role === "assistant" && message.responseKind !== "smalltalk"
...(message.role === "assistant" && message.responseKind !== "smalltalk" && !message.rectificationReturn
? { timeline: consultationTimelineFromSettled(message) }
: {}),
}));
@@ -1,6 +1,7 @@
import { z } from "zod";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
import { publicThinkingSectionSchema } from "./consultation-thinking-plan.ts";
import { rectificationOfferRecordSchema } from "./rectification-in-chat.ts";
export const publicActivityPhaseSchema = z.enum([
// The first byte of a consultation stream, before the turn is classified or
@@ -142,6 +143,8 @@ const runCompletedSchema = z.object({
type: z.literal("run.completed"),
receipt: agentExecutionReceiptSchema.optional(),
responseKind: z.literal("smalltalk").optional(),
/** The answer offered birth-time rectification (BUG-1248); the card hangs under it. */
rectificationOffer: rectificationOfferRecordSchema.optional(),
}).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
@@ -35,11 +35,6 @@ export function isGeneralDailyFortuneQuestion(question: string): boolean {
return /^(?:请帮我看一下)?今天的运势/.test(text) || /^今日运势/.test(text);
}
export function isRectificationHandoffQuestion(question: string): boolean {
const text = compactQuestion(question);
return /^(?:先完成)?(?:出生时间校正|生时校正|生时矫正)(?:\s*[((]生时(?:校正|矫正)[))])?$/.test(text);
}
export function shouldLoadGeneralDailyPanchanga(input: Readonly<{
consultationMode?: ConsultationQuestionInput["consultationMode"];
entrypoint?: ConsultationEntrypoint;
@@ -1,3 +1,5 @@
import { BIRTH_TIME_CHANGED_HISTORY_NOTE, isRectificationReturnMessage, lastBirthTimeChangeIndex } from "./rectification-in-chat.ts";
export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
@@ -108,8 +110,12 @@ export function storedConsultationTurns(
): StoredTurn[] {
if (!Array.isArray(messages)) return [];
const rows: StoredTurn[] = [];
// C7 (BUG-1251): answers written before the birth time changed are read
// with a note, and the return divider itself is never a model turn.
const changedAt = lastBirthTimeChangeIndex(messages);
messages.forEach((message, index) => {
if (!message || typeof message !== "object") return;
if (isRectificationReturnMessage(message)) return;
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown; responseKind?: unknown };
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return;
if (stored.role !== "user" && stored.role !== "assistant") return;
@@ -117,7 +123,9 @@ export function storedConsultationTurns(
rows.push({
index,
role: stored.role,
text: stored.text,
text: stored.role === "assistant" && index < changedAt
? `${BIRTH_TIME_CHANGED_HISTORY_NOTE}\n${stored.text}`
: stored.text,
requestId: typeof stored.requestId === "string" ? stored.requestId : null,
...(stored.responseKind === "smalltalk" ? { responseKind: "smalltalk" as const } : {}),
});
@@ -170,7 +178,15 @@ export function consultationHistoryWindow(
droppedCount += 1;
}
const summaryText = summary?.text.trim() || null;
return { tail: kept, summaryText, droppedCount };
// The summary only covers turns up to its index; when the birth time changed
// after that, its conclusions came from the earlier time too (C7).
const summaryBeforeChange = summary && Array.isArray(messages)
&& summary.throughMessageIndex < lastBirthTimeChangeIndex(messages);
return {
tail: kept,
summaryText: summaryText && summaryBeforeChange ? `${BIRTH_TIME_CHANGED_HISTORY_NOTE}\n${summaryText}` : summaryText,
droppedCount,
};
}
export function consultationHistoryFromStoredMessages(
@@ -444,6 +444,7 @@ export function natalAnswerShapeBody(): string {
"正文写成段落,段落之间空一行(单个换行在界面上不分段)。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用加粗标签加一句的格式。",
"首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。",
PLAIN_SPEECH_RULE,
RECTIFICATION_OFFER_RULE,
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。",
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
].join("");
@@ -453,6 +454,13 @@ export function natalAnswerShapeInstruction(): string {
return `${ANSWER_FROM_EVIDENCE}${natalAnswerShapeBody()}`;
}
/**
* C2 (TASK-rectification-in-chat-step1-20261006, BUG-1248): when "推得不准"
* may be the birth time, the agent offers rectification through the offer
* tool; it is never decided from keywords. Defined here once.
*/
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、或明确想核对出生时间,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
@@ -470,6 +478,7 @@ export function followUpAnswerShapeInstruction(): string {
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;读心按人设里的读心三条:答完再说、用「可能」「多半」、一句话,不把用户的问题改写成需求,不替用户总结他想要什么。",
PLAIN_SPEECH_RULE,
FOLLOW_UP_CORRECTION_RULE,
RECTIFICATION_OFFER_RULE,
CAREER_FOLLOW_UP_RULE,
"只有用户明确换了领域或要求完整看一下,才回到首轮形状:几段人话,多人就分段落、段首点名,不写二级标题。",
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
+13
View File
@@ -1,3 +1,4 @@
import { parseRectificationOfferRecord, parseRectificationReturnRecord } from "@/lib/rectification-in-chat";
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
import { BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, withBootFallback } from "@/lib/boot-early-reads";
import { writeChatSession } from "@/lib/chat-session-write-contract";
@@ -254,6 +255,17 @@ export function readSessionListPage(value: unknown): SessionListPage {
};
}
/** The offer card / return divider of rectification reached from a consultation (BUG-1248/1250). */
function rectificationLinkFields(stored: Message): Partial<Pick<Message, "rectificationOffer" | "rectificationReturn">> {
if (stored.role !== "assistant") return {};
const offer = parseRectificationOfferRecord(stored.rectificationOffer);
const returned = parseRectificationReturnRecord(stored.rectificationReturn);
return {
...(offer ? { rectificationOffer: offer } : {}),
...(returned ? { rectificationReturn: returned } : {}),
};
}
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
const fallbackSessionIds: string[] = [];
@@ -291,6 +303,7 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}),
...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}),
...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}),
...rectificationLinkFields(stored),
}];
})
: [];
-14
View File
@@ -20,20 +20,6 @@ export type RectificationEntrySummary = Readonly<{
}> | null;
}>;
export type RectificationCardAction = "start" | "restart";
export const rectificationEntryLabels: Readonly<Record<RectificationCardAction, string>> = {
start: "开始新的生时校正",
restart: "再次校正",
};
export function resolveRectificationEntryAction(
summary: RectificationEntrySummary,
): RectificationCardAction {
if (summary.hasTerminalCaseWithTime) return "restart";
return "start";
}
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
export type OpenRectificationCaseResponse = Readonly<{
@@ -0,0 +1,134 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import { resolveFeaturePricing } from "@/lib/feature-pricing";
import { isProductEnabled } from "@/lib/product-access";
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service";
import {
appendRectificationReturn,
bindOfferToCase,
decideRectificationOfferWith,
offerForCase,
sourceFromSessionRows,
type RectificationOfferDecision,
type RectificationReturnRecord,
type RectificationSource,
} from "@/lib/rectification-in-chat";
/**
* Server side of rectification reached from a consultation (BUG-1247~1251).
* Reads and writes the consultation session's messages with the caller's own
* client (owner RLS; `messages` is an owner-updatable column), never the
* admin client.
*/
/** C3: whether the offer card may be shown, decided by the server and never by the model. */
export async function decideRectificationOffer(input: {
accounting: SupabaseClient;
userId: string;
subjectRole: "self" | "other";
modelId: string;
}): Promise<RectificationOfferDecision> {
return decideRectificationOfferWith({
subjectRole: input.subjectRole,
productEnabled: () => isProductEnabled("rectification"),
hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase,
creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost,
});
}
type SessionRow = { id: string; messages: unknown; session_type?: string | null };
async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise<SessionRow | null> {
const { data, error } = await client
.from("chat_sessions")
.select("id,messages,session_type")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
if (error || !data) return null;
const row = data as SessionRow;
return (row.session_type ?? "consultation") === "consultation" ? row : null;
}
async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
const { error } = await client
.from("chat_sessions")
.update({ messages })
.eq("id", sessionId)
.eq("user_id", userId);
return !error;
}
/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */
export async function bindRectificationSource(input: {
client: SupabaseClient;
userId: string;
sourceSessionId: string;
caseId: string;
rectificationSessionId: string;
}): Promise<boolean> {
const row = await readConsultationSession(input.client, input.userId, input.sourceSessionId);
if (!row) return false;
const next = bindOfferToCase(row.messages, { caseId: input.caseId, rectificationSessionId: input.rectificationSessionId });
if (!next) return Boolean(offerForCase(row.messages, input.caseId));
return writeMessages(input.client, input.userId, row.id, next);
}
/** How many recent consultations are searched for the session that started a case. */
const SOURCE_SEARCH_LIMIT = 20;
/**
* The consultation that started this case. Binding writes that session, so it
* is among the most recently updated ones; a case started from the homepage or
* from history has none.
*/
export async function findRectificationSource(
client: SupabaseClient,
userId: string,
caseId: string,
): Promise<RectificationSource | null> {
const { data, error } = await client
.from("chat_sessions")
.select("id,messages,session_type")
.eq("user_id", userId)
.eq("session_type", "consultation")
.order("updated_at", { ascending: false })
.limit(SOURCE_SEARCH_LIMIT);
if (error || !Array.isArray(data)) return null;
return sourceFromSessionRows(data as SessionRow[], caseId);
}
/**
* C5/C6: going back appends one divider to the consultation (idempotent) and
* marks the offer returned. Whether the time changed is read from the case,
* never from the browser.
*/
export async function returnToRectificationSource(input: {
client: SupabaseClient;
accounting: SupabaseClient;
userId: string;
caseId: string;
now?: () => Date;
}): Promise<(RectificationSource & { record: RectificationReturnRecord }) | null> {
const source = await findRectificationSource(input.client, input.userId, input.caseId);
if (!source) return null;
if (source.returned) return { ...source, record: source.returned };
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const accepted = dossier.case.acceptedTime?.slice(0, 5) ?? null;
const row = await readConsultationSession(input.client, input.userId, source.sessionId);
if (!row) return null;
const appended = appendRectificationReturn(row.messages, {
caseId: input.caseId,
adopted: Boolean(accepted),
clock: accepted,
returnedAt: (input.now?.() ?? new Date()).toISOString(),
});
if (!appended) {
const again = await findRectificationSource(input.client, input.userId, input.caseId);
return again?.returned ? { ...again, record: again.returned } : null;
}
if (!await writeMessages(input.client, input.userId, row.id, appended.messages)) return null;
return { ...source, returned: appended.record, record: appended.record };
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Birth-time rectification reached from an ordinary consultation
* (TASK-rectification-in-chat-step1-20261006, BUG-1247~1251).
*
* The link between a consultation and the rectification it started lives in
* the consultation session's own messages, so no table changes:
* - the assistant answer that offered rectification carries `rectificationOffer`
* (written with the answer by `complete_consultation_response`), which the
* server moves to `opened` (with the case id) when the user starts it and to
* `returned` when the user comes back;
* - coming back appends one divider message carrying `rectificationReturn`.
* The divider is never a model turn; earlier answers get a note when the birth
* time changed (C7). Shared by the server and the browser; no server imports.
*/
import { z } from "zod";
export const RECTIFICATION_OFFER_TOOL_ID = "offer-birth-time-rectification";
/** Rough number of questions a case asks before it can deliver (offline replay averaged 7.8). */
export const RECTIFICATION_OFFER_QUESTION_ESTIMATE = 8;
export const rectificationOfferRecordSchema = z.object({
status: z.enum(["offered", "opened", "returned"]),
creditCost: z.number().int().positive().max(1000),
questionEstimate: z.number().int().positive().max(50),
/** The question to look at again with the new time. */
sourceQuestion: z.string().trim().min(1).max(500),
offeredAt: z.string().min(1).max(40),
caseId: z.string().uuid().optional(),
rectificationSessionId: z.string().uuid().optional(),
}).strict();
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
export const rectificationReturnRecordSchema = z.object({
caseId: z.string().uuid(),
adopted: z.boolean(),
/** HH:MM when adopted, otherwise null. */
clock: z.string().regex(/^\d{2}:\d{2}$/).nullable(),
sourceQuestion: z.string().trim().min(1).max(500),
returnedAt: z.string().min(1).max(40),
}).strict();
export type RectificationReturnRecord = z.infer<typeof rectificationReturnRecordSchema>;
export function parseRectificationOfferRecord(value: unknown): RectificationOfferRecord | null {
const parsed = rectificationOfferRecordSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function parseRectificationReturnRecord(value: unknown): RectificationReturnRecord | null {
const parsed = rectificationReturnRecordSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
/** C3: the server's answer to "may the offer card be shown". */
export type RectificationOfferDecision =
| Readonly<{ ok: true; creditCost: number; questionEstimate: number }>
| Readonly<{ ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }>;
// ---- copy (VOICE.md「生时校正并入对话」) ----
export function rectificationOfferCardCopy(offer: Pick<RectificationOfferRecord, "creditCost" | "questionEstimate">): string {
return `可以用几道过去经历的题,核对一下你的出生时间。大约 ${offer.questionEstimate} 题,会用掉 ${offer.creditCost} 点;开始答第一题时才扣。`;
}
export const RECTIFICATION_OFFER_START_LABEL = "开始核对出生时间";
export const RECTIFICATION_OFFER_CONTINUE_LABEL = "继续核对出生时间";
export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始。";
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
return record.adopted && record.clock
? `出生时间改为 ${record.clock}(生时校正)`
: "出生时间没有改";
}
export function rectificationRevisitLabel(question: string): string {
const clipped = question.length > 24 ? `${question.slice(0, 24)}…` : question;
return `按新时间重新看:「${clipped}」`;
}
/** C7: the note the model reads in front of answers written with the earlier birth time. */
export const BIRTH_TIME_CHANGED_HISTORY_NOTE = "(以下回答按校正前的出生时间,不要沿用其中的盘面判断)";
// ---- pure helpers over a stored messages array ----
type StoredMessage = Record<string, unknown>;
function asMessages(value: unknown): StoredMessage[] | null {
if (!Array.isArray(value)) return null;
return value.map((item) => (item && typeof item === "object" && !Array.isArray(item) ? item as StoredMessage : {}));
}
export function isRectificationReturnMessage(message: unknown): boolean {
return Boolean(message && typeof message === "object"
&& parseRectificationReturnRecord((message as StoredMessage).rectificationReturn));
}
/** Any offer, in any state, in this session (C2: offer at most once unless the user asks). */
export function sessionHasRectificationOffer(messages: unknown): boolean {
return (asMessages(messages) ?? []).some((message) => parseRectificationOfferRecord(message.rectificationOffer));
}
/**
* The question to look at again: the latest user question before the turn
* that offered rectification, or that turn's own question when it is the
* first one. `excludeRequestId` skips the in-flight turn's stored question.
*/
export function revisitQuestionForOffer(messages: unknown, currentQuestion: string, excludeRequestId?: string): string {
const stored = asMessages(messages) ?? [];
for (let index = stored.length - 1; index >= 0; index -= 1) {
const message = stored[index]!;
if (excludeRequestId && message.requestId === excludeRequestId) continue;
if (message.role === "user" && typeof message.text === "string" && message.text.trim()) {
return message.text.trim().slice(0, 500);
}
}
return currentQuestion.trim().slice(0, 500);
}
/**
* Marks the latest still-offered card as opened for this case. Returns the
* new array, or null when there is nothing to bind (no offer, or this case is
* already bound).
*/
export function bindOfferToCase(
messages: unknown,
binding: { caseId: string; rectificationSessionId: string },
): StoredMessage[] | null {
const stored = asMessages(messages);
if (!stored) return null;
if (stored.some((message) => parseRectificationOfferRecord(message.rectificationOffer)?.caseId === binding.caseId)) {
return null;
}
for (let index = stored.length - 1; index >= 0; index -= 1) {
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
if (!offer || offer.status !== "offered") continue;
const next = [...stored];
next[index] = {
...stored[index],
rectificationOffer: { ...offer, status: "opened", caseId: binding.caseId, rectificationSessionId: binding.rectificationSessionId },
};
return next;
}
return null;
}
/** The offer bound to this case, if this session holds one. */
export function offerForCase(messages: unknown, caseId: string): RectificationOfferRecord | null {
for (const message of asMessages(messages) ?? []) {
const offer = parseRectificationOfferRecord(message.rectificationOffer);
if (offer?.caseId === caseId) return offer;
}
return null;
}
/**
* Appends the return divider once per case and marks its offer returned.
* Returns null when the divider is already there (idempotent) or the session
* does not hold an offer for this case.
*/
export function appendRectificationReturn(
messages: unknown,
input: { caseId: string; adopted: boolean; clock: string | null; returnedAt: string },
): { messages: StoredMessage[]; record: RectificationReturnRecord } | null {
const stored = asMessages(messages);
if (!stored) return null;
const offer = offerForCase(stored, input.caseId);
if (!offer) return null;
if (stored.some((message) => parseRectificationReturnRecord(message.rectificationReturn)?.caseId === input.caseId)) {
return null;
}
const record: RectificationReturnRecord = {
caseId: input.caseId,
adopted: input.adopted && Boolean(input.clock),
clock: input.adopted ? input.clock : null,
sourceQuestion: offer.sourceQuestion,
returnedAt: input.returnedAt,
};
const marked = stored.map((message) => {
const current = parseRectificationOfferRecord(message.rectificationOffer);
return current?.caseId === input.caseId
? { ...message, rectificationOffer: { ...current, status: "returned" } }
: message;
});
return {
messages: [...marked, { role: "assistant", text: rectificationReturnDividerText(record), rectificationReturn: record }],
record,
};
}
/** Index of the last divider that changed the birth time, or -1. */
export function lastBirthTimeChangeIndex(messages: readonly unknown[]): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const record = message && typeof message === "object"
? parseRectificationReturnRecord((message as StoredMessage).rectificationReturn)
: null;
if (record?.adopted) return index;
}
return -1;
}
/**
* C3: the offer decision with its reads injected (the server wires the real
* product switch, entry summary and feature pricing). Order matters: a
* non-self subject never reaches the database.
*/
export async function decideRectificationOfferWith(input: {
subjectRole: "self" | "other";
productEnabled: () => Promise<boolean>;
hasResumableCase: () => Promise<boolean>;
creditCost: () => Promise<number>;
questionEstimate?: number;
}): Promise<RectificationOfferDecision> {
if (input.subjectRole !== "self") return { ok: false, reason: "not_self" };
if (!await input.productEnabled().catch(() => false)) return { ok: false, reason: "product_disabled" };
// An unreadable summary does not block the offer: opening the case re-checks
// for a conflict and says so.
if (await input.hasResumableCase().catch(() => false)) return { ok: false, reason: "case_in_progress" };
try {
const creditCost = await input.creditCost();
if (!Number.isInteger(creditCost) || creditCost <= 0) return { ok: false, reason: "pricing_unavailable" };
return { ok: true, creditCost, questionEstimate: input.questionEstimate ?? RECTIFICATION_OFFER_QUESTION_ESTIMATE };
} catch {
return { ok: false, reason: "pricing_unavailable" };
}
}
export type RectificationSource = Readonly<{
sessionId: string;
question: string;
returned: RectificationReturnRecord | null;
}>;
/** The consultation (among the given rows) whose offer is bound to this case. */
export function sourceFromSessionRows(
rows: ReadonlyArray<{ id: string; messages: unknown }>,
caseId: string,
): RectificationSource | null {
for (const row of rows) {
const offer = offerForCase(row.messages, caseId);
if (!offer) continue;
const returned = (asMessages(row.messages) ?? [])
.map((message) => parseRectificationReturnRecord(message.rectificationReturn))
.find((record) => record?.caseId === caseId) ?? null;
return { sessionId: row.id, question: offer.sourceQuestion, returned };
}
return null;
}
@@ -27,7 +27,6 @@ import {
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
/** Question recovery: one immediate refetch after a turn, then this many timed retries. */
+10 -2
View File
@@ -313,7 +313,11 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
}
}
flushPlan();
events.push({ type: "run.completed", receipt: agentExecutionReceiptSchema.parse(options.receipt()) });
events.push({
type: "run.completed",
receipt: agentExecutionReceiptSchema.parse(options.receipt()),
...(options.state?.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
});
return events.map((event) => consultationAgentPublicEventSchema.parse(event));
}
@@ -1106,7 +1110,11 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
settled = true;
settling = false;
await sideEvent;
send(controller, { type: "run.completed", receipt });
send(controller, {
type: "run.completed",
receipt,
...(options.state.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
});
if (!disconnected) controller.close();
} catch (error) {
if (settled) return;
+68 -1
View File
@@ -1,3 +1,8 @@
import {
RECTIFICATION_OFFER_TOOL_ID,
type RectificationOfferDecision,
type RectificationOfferRecord,
} from "../lib/rectification-in-chat.ts";
import { isDeepStrictEqual } from "node:util";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
@@ -269,6 +274,8 @@ export type ConsultationRuntimeState = {
* still has to state which method a receipt describes.
*/
jyotishSkillBound: boolean;
/** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */
rectificationOffer?: RectificationOfferRecord;
skillReferenceReadCount: number;
methodologySectionCount: number;
consultationToolStarted: boolean;
@@ -439,6 +446,20 @@ export function consultationStepBudgetReceipt(state: ConsultationRuntimeState) {
};
}
/**
* C2/C3 (TASK-rectification-in-chat-step1-20261006, BUG-1248): what the offer
* tool needs from the route. `decide` is the server check (self, product on,
* no case in progress, price); `alreadyOffered` comes from this session's
* stored messages; `revisitQuestion` is the question to look at again with
* the new time.
*/
export type RectificationOfferHook = Readonly<{
decide: () => Promise<RectificationOfferDecision>;
alreadyOffered: boolean;
revisitQuestion: string;
now?: () => Date;
}>;
export type ConsultationAgentContext = Readonly<{
userId: string;
sessionId: string;
@@ -455,6 +476,7 @@ export type ConsultationAgentContext = Readonly<{
serverChart: ServerChartConsultation;
abortSignal?: AbortSignal;
state: ConsultationRuntimeState;
rectificationOffer?: RectificationOfferHook;
runWorkflow?: typeof runConsultationWorkflow;
// The domain loop budgets itself against this clock, so tests can drive the
// deadline without waiting for it.
@@ -475,6 +497,7 @@ export type WindowConsultationAgentContext = Readonly<{
declaredWindow: DeclaredBirthWindowConsultation;
abortSignal?: AbortSignal;
state: ConsultationRuntimeState;
rectificationOffer?: RectificationOfferHook;
now?: () => number;
fetchWindowChart?: typeof fetchDeclaredWindowChart;
runRangeReading?: typeof runV9RangeReading;
@@ -1015,6 +1038,46 @@ const evidenceLookupInputSchema = z.object({
section: z.enum(EVIDENCE_LOOKUP_SECTIONS),
}).strict();
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
not_self: "问的不是用户本人的盘,生时校正暂时只支持本人。正文用一句话照实说明(例:「给家人校正暂时还不支持」),不出卡。",
product_disabled: "生时校正暂时没有开放。正文不要提校正。",
case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
pricing_unavailable: "生时校正这会儿提供不了。正文不要提校正。",
};
/**
* The offer card tool (C3, BUG-1248). It only records the offer for this turn;
* the server decides whether a card may be shown and the card itself carries
* the question count and price. Never calculates, never opens a case.
*/
function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
return createTool({
id: RECTIFICATION_OFFER_TOOL_ID,
description: "Offer birth-time rectification under this answer. Call it before writing the answer, only when the user says a concrete past event does not fit the chart, says the birth time is an estimate or unsure, or asks to check the birth time; never for a disagreement with an interpretation. Pass userAsked=true only when the user asked for it in this turn. The result says whether a card will be shown and what to say in one sentence.",
inputSchema: z.object({ userAsked: z.boolean().optional() }).strict(),
execute: async (input) => {
const hook = ctx.rectificationOffer;
if (!hook) return { offered: false as const, reason: "unavailable", note: RECTIFICATION_OFFER_REFUSAL_NOTES.product_disabled };
if (ctx.state.rectificationOffer) {
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
}
if (hook.alreadyOffered && input.userAsked !== true) {
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
}
const decision = await hook.decide();
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
ctx.state.rectificationOffer = {
status: "offered",
creditCost: decision.creditCost,
questionEstimate: decision.questionEstimate,
sourceQuestion: hook.revisitQuestion,
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
};
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
},
});
}
export function createConsultationTools(ctx: ConsultationAgentContext) {
let calculation: Promise<ConsultationModelEvidenceView> | null = null;
// Request-scoped: filled when this request's calculation finishes, read by
@@ -1216,6 +1279,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
return {
"run-jyotish-consultation": consultationTool,
[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID]: lookupTool,
...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}),
};
}
@@ -1375,7 +1439,10 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex
}).strict(),
execute: async (input, context) => executeWindowConsultation(ctx, input, context as WindowToolExecuteContext),
});
return { "run-jyotish-window-consultation": windowTool };
return {
"run-jyotish-window-consultation": windowTool,
...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}),
};
}
/**