- Sidebar 「生时校正」 under 星盘, behind a new account `rectification` flag. - /rectification page: status card from entry-summary (not started / in progress with window and confirmed event count / adopted time), a short data check, then the home rectification surface (?rectify=1, or ?rectify=new to start over; ?c=<id> to continue or view). No price on the page. - entry-summary gains session id, candidate range, confirmed evidence count and adopted times (add-only migration 20261028170000). - The offer card under a consultation answer becomes one line and a link to /rectification; an explicit request in chat also goes there. No case is opened from a conversation any more. - Starter home shows a rectification prompt for self charts with an estimated time; onboarding checklist gains 做一次生时校正 for them. - Chart-page link points to /rectification. Shell registers its router so home hooks can navigate client-side without useRouter in Home(). - Docs: VOICE, DESIGN, CHANGELOG, device checklist. Self profile only; other people are issue #160. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
/**
|
||
* 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(),
|
||
/**
|
||
* Present only when this turn should open the case without a click.
|
||
* Writers omit it unless it is true. Older stored offers omit it too.
|
||
* The card stays an ordinary offer until status is opened (BUG-1306 F3).
|
||
*/
|
||
autoOpen: z.boolean().optional(),
|
||
}).strict();
|
||
|
||
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
|
||
|
||
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「生时校正并入对话」) ----
|
||
|
||
/**
|
||
* Issue #145: the card under an answer is a hint that links to /rectification.
|
||
* It no longer opens a case from this conversation, and it carries no price.
|
||
*/
|
||
export const RECTIFICATION_OFFER_HINT_COPY = "出生时间还不确定。";
|
||
export const RECTIFICATION_OFFER_LINK_LABEL = "去做生时校正";
|
||
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
|
||
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
|
||
|
||
/**
|
||
* User-visible sentences for a turn that enters rectification directly
|
||
* (BUG-1306). `complete_consultation_free` rejects text longer than 20
|
||
* characters, so these stay short. Same sentence for "not open" and "no price":
|
||
* the user does not see the internal reason.
|
||
*/
|
||
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 = "给家人校正暂时还不支持";
|
||
|
||
/**
|
||
* 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
|
||
? `出生时间改为 ${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.caseId) continue;
|
||
// Offered cards, including a direct open that is still offered (autoOpen).
|
||
// An older direct-open row stored as opened before it had a case id still binds.
|
||
// Returned rows, and a card already bound to another case, are left alone.
|
||
if (offer.status !== "offered" && offer.status !== "opened") continue;
|
||
const next = [...stored];
|
||
next[index] = {
|
||
...stored[index],
|
||
rectificationOffer: { ...offer, status: "opened", caseId: binding.caseId, rectificationSessionId: binding.rectificationSessionId },
|
||
};
|
||
return next;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* After `complete_consultation_free` appends the one-sentence reply, hang the
|
||
* offer on that message so a reload can still find the source. The record is
|
||
* not opened yet (still `offered`, with `autoOpen` on a direct open). Returns
|
||
* the next array, "present" when that message already has an offer (do not
|
||
* overwrite a later case id), or "missing" when the free reply is not there yet.
|
||
*/
|
||
export function attachOpenedOfferToFreeMessage(
|
||
messages: unknown,
|
||
input: { sentence: string; offer: RectificationOfferRecord },
|
||
): StoredMessage[] | "present" | "missing" {
|
||
const stored = asMessages(messages);
|
||
if (!stored) return "missing";
|
||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||
const message = stored[index]!;
|
||
if (message.role !== "assistant" || message.text !== input.sentence || message.responseKind !== "smalltalk") continue;
|
||
if (parseRectificationOfferRecord(message.rectificationOffer)) return "present";
|
||
const next = [...stored];
|
||
next[index] = { ...message, rectificationOffer: input.offer };
|
||
return next;
|
||
}
|
||
return "missing";
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|