Files
Jyotisha/frontend/src/lib/rectification-in-chat.ts
T
jesseandClaude Opus 5.5 dfd3208979
Independent Staging Quality Gate / validate (push) Failing after 13m25s
Independent Staging Quality Gate / publish (push) Skipped
feat: standalone rectification entry at /rectification (issue #145)
- 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>
2026-10-28 16:50:55 +08:00

315 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}