Files
Jyotisha/frontend/src/lib/agent-reply.ts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 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
2026-10-07 12:58:58 +08:00

113 lines
4.4 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.
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
import {
isGeneralDailyFortuneQuestion,
} from "./consultation-entrypoint.ts";
import { shanghaiDateParts } from "./session-shanghai-clock.ts";
export type ReplyTheme = ConsultationDomain;
import type { ConsultationReplyMetadata } from "./consultation-reply-metadata.ts";
const GENERIC_SESSION_TITLES = new Set([
"新对话",
"深入看今日",
"从今日问起",
"查看今日运势",
"生时校正",
"再次校正",
"一般占星咨询",
"深入看今日主题咨询",
]);
function readTitle(value: string): string | undefined {
const title = value.replace(/\s+/g, " ").trim();
if (!title || /[\d\p{P}\p{S}]/u.test(title)) return undefined;
if (/\p{Script=Han}/u.test(title)) {
const length = Array.from(title.replace(/\s/g, "")).length;
return length >= 6 && length <= 14 ? title : undefined;
}
const words = title.split(" ").filter(Boolean);
return words.length >= 3 && words.length <= 7 && title.length <= 64 ? title : undefined;
}
// AYANAM_SUGGESTIONS blocks are no longer produced anywhere, but stored answers from
// before the follow-up chips were removed still carry them, so they stay strippable
// rather than leaking a raw HTML comment into a replayed transcript.
function stripAgentReplyMetadata(value: string) {
let title: string | undefined;
const text = value
.replace(/<!--AYANAM_SUGGESTIONS:\[[\s\S]*?\]-->/g, "")
.replace(/<!--AYANAM_TITLE:([\s\S]*?)-->/g, (_, rawTitle: string) => {
title = readTitle(rawTitle);
return "";
})
.replace(/<!--AYANAM_[\s\S]*$/, "")
.trim();
return { text, title };
}
export function parseAgentReply(value: string, metadata?: ConsultationReplyMetadata) {
const parsed = stripAgentReplyMetadata(value);
return { text: parsed.text, title: metadata?.title ?? parsed.title };
}
export type SessionTitleOptions = {
readonly entrypoint?: ConsultationEntrypoint | null;
readonly theme?: ConsultationDomain;
/** Wall clock. Only for minting a brand-new session. Re-titling an existing
* session must pass that session's created time; the default `new Date()`
* would stamp today's date onto old chats (BUG-699). */
readonly at?: Date;
readonly existingTitles?: readonly string[];
};
const DATED_CATEGORY_PREFIX = /^(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日/;
const DATED_CATEGORY_SUFFIX = /^\d{1,2}月\d{1,2}日\s*·\s*(?:生时校正|今日节奏)(?:\s+\d{2}:\d{2})?$/;
export function isGenericSessionTitle(title: string): boolean {
const text = title.replace(/\s+/g, " ").trim();
if (!text) return true;
if (GENERIC_SESSION_TITLES.has(text)) return true;
if (DATED_CATEGORY_PREFIX.test(text) || DATED_CATEGORY_SUFFIX.test(text)) return true;
return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text);
}
function clipTitle(value: string, maxChars = 14): string {
const characters = Array.from(value);
return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value;
}
// Daily rhythm only. Rectification titles are the result time, written by
// public.rectification_session_title when the case result changes (BUG-1001 / D3).
function datedSessionTitle(at: Date, category: string): string {
const { month, day } = shanghaiDateParts(at);
return `${category} · ${month}月${day}日`;
}
export function resolveSessionTitle(
question: string,
modelTitle?: string,
options: SessionTitleOptions = {},
): string {
const at = options.at ?? new Date(); // new sessions only; see SessionTitleOptions.at
if (modelTitle && !isGenericSessionTitle(modelTitle)) {
return clipTitle(modelTitle);
}
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
return datedSessionTitle(at, "今日节奏");
}
if (options.entrypoint === "birth_time_rectification") {
return "生时校正";
}
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
if (!normalized) return "新对话";
if (options.theme && options.theme !== "general") {
const label = consultationDomainDefinition(options.theme).label;
return `${label} · ${clipTitle(normalized, 10)}`;
}
return clipTitle(normalized);
}