fix: bind rectification state to current declaration

This commit is contained in:
Jesse_Chen
2026-07-21 09:48:45 +08:00
parent bccfb60e41
commit 0edb36e26b
12 changed files with 616 additions and 90 deletions
+23 -39
View File
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server";
import {
parseRectificationPriceCredits,
type AccountRectificationCaseState,
} from "@/lib/birth-time-consultation-consent";
import { resolveAccountRectificationCase } from "@/lib/account-rectification-case";
import {
accountProfilePatchSchema,
applyAccountProfileConcurrencyGuards,
@@ -16,7 +16,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const rectificationStatuses = ["starting", "active", "paused", "confirming", "completed", "abandoned"] as const;
const unfinishedRectificationStatuses = ["starting", "active", "paused", "confirming"] as const;
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
@@ -26,28 +26,6 @@ function isMissingProfileColumn(error: { code?: string; message?: string } | nul
|| message.includes("column");
}
function projectRectificationCase(value: unknown): AccountRectificationCaseState | null {
if (value === null || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const status = rectificationStatuses.find((candidate) => candidate === row.status);
if (typeof row.id !== "string"
|| row.journey_protocol !== "conversational-evidence-v3"
|| !status
|| typeof row.turn_version !== "number"
|| !Number.isSafeInteger(row.turn_version)
|| row.turn_version < 0) {
throw new Error("invalid rectification case projection");
}
return Object.freeze({
caseId: row.id,
journeyProtocol: "conversational-evidence-v3",
status,
turnVersion: row.turn_version,
isRevision: typeof row.revision_of_case_id === "string",
preservesActiveTime: typeof row.baseline_active_time === "string",
});
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -61,29 +39,35 @@ export async function GET() {
const rectificationPriceCredits = parseRectificationPriceCredits(
process.env.RECTIFICATION_PRICE_CREDITS,
);
const admin = createAdminSupabaseClient();
const { data: rectificationCaseRows, error: rectificationCaseError } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at")
.eq("user_id", user.id)
.eq("journey_protocol", "conversational-evidence-v3")
.in("status", [...unfinishedRectificationStatuses])
.order("updated_at", { ascending: false })
.limit(50);
if (rectificationCaseError) {
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
}
// Read the profile after the case snapshot. If a declaration edit races
// with this request, matching uses the later profile and cannot resurrect
// an older case. A concurrently created case simply appears on refresh.
const { data: profile, error } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
.eq("id", userId)
.single();
if (error) {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
const admin = createAdminSupabaseClient();
const { data: rectificationCaseRow, error: rectificationCaseError } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,updated_at")
.eq("user_id", user.id)
.eq("journey_protocol", "conversational-evidence-v3")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (rectificationCaseError) {
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
}
const rectificationCase = projectRectificationCase(rectificationCaseRow);
const rectificationCase = resolveAccountRectificationCase(
profile,
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
);
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
+5 -2
View File
@@ -34,6 +34,7 @@ import {
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
import {
birthTimeConsultationOptionsCopy,
canUseUnverifiedBirthTime,
clearBirthTimeConsultationConsent,
createLatestAccountRequestGuard,
@@ -1584,10 +1585,12 @@ export default function Home() {
setProfileDraft(profileDraft);
if (declarationChanged) {
setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState());
setAccount((current) => current ? { ...current, rectificationCase: null } : current);
void refreshAccount();
}
setProfileNotice(profileDraft.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: "出生资料已保存。你可以先使用填报时间询问,也可以从首页卡片开始校正。");
: `出生资料已保存。${birthTimeConsultationOptionsCopy(profileDraft)}`);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
} finally {
@@ -2638,7 +2641,7 @@ export default function Home() {
{birthTimeDisplay ? (
<>
<div><dt>{birthTimeDisplay.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}</dt><dd>{birthTimeDisplay.activeTime}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.kind === "candidate" ? "未确认;咨询时仅可临时使用原始填报时间" : "已确认"}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.kind === "candidate" ? `未确认;${birthTimeConsultationOptionsCopy(profile)}` : "已确认"}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.reportedLabel}</dd></div>
</>
) : (
@@ -2,6 +2,7 @@
import { useId } from "react";
import { BirthDatePicker } from "@/components/birth-date-picker";
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
import {
birthTimeDisplayState,
birthTimePeriodOptions,
@@ -78,7 +79,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
</div>
</dl>
{displayState.kind === "candidate" && (
<p>使</p>
<p>{birthTimeConsultationOptionsCopy(value)}</p>
)}
</section>
)}
@@ -0,0 +1,191 @@
import { chinaLocations } from "../data/china-locations.ts";
import type { AccountRectificationCaseState } from "./birth-time-consultation-consent.ts";
import {
declaredBirthInputSchema,
type DeclaredBirthInput,
} from "./conversational-rectification/persistence-contracts.ts";
type RecordValue = Record<string, unknown>;
const unfinishedStatuses = new Set<AccountRectificationCaseState["status"]>([
"starting",
"active",
"paused",
"confirming",
]);
function record(value: unknown): RecordValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function clock(value: unknown): string | null {
const normalized = text(value)?.slice(0, 5) ?? null;
return normalized && /^([01]\d|2[0-3]):[0-5]\d$/.test(normalized)
? normalized
: null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function integer(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function currentDeclaration(value: unknown): DeclaredBirthInput | null {
const profile = record(value);
if (!profile) return null;
const birthDate = text(profile.birth_date);
const source = text(profile.birth_time_source);
const cityCode = text(profile.city_code);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
if (!birthDate || !source || !cityCode || latitude === null || longitude === null
|| timezoneOffset === null) return null;
const birthplace = {
...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}),
...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}),
cityCode,
...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}),
latitude,
longitude,
timezoneOffset,
};
const common = {
birthDate,
birthTimeClue: text(profile.birth_time_clue),
birthplace,
};
const reportedTime = clock(profile.reported_birth_time);
const reportedPeriod = text(profile.birth_time_period);
const uncertaintyBeforeMinutes = integer(profile.uncertainty_before_minutes);
const uncertaintyAfterMinutes = integer(profile.uncertainty_after_minutes);
let input: unknown;
switch (source) {
case "hospital_record":
case "family_exact":
case "approximate":
input = {
...common,
source,
reportedTime,
uncertaintyBeforeMinutes,
uncertaintyAfterMinutes,
};
break;
case "period_only":
input = { ...common, source, reportedPeriod };
break;
case "unknown":
input = { ...common, source };
break;
case "legacy_import":
input = {
...common,
source,
...(reportedTime ? { reportedTime } : {}),
...(reportedPeriod ? { reportedPeriod } : {}),
...(uncertaintyBeforeMinutes === null ? {} : { uncertaintyBeforeMinutes }),
...(uncertaintyAfterMinutes === null ? {} : { uncertaintyAfterMinutes }),
};
break;
default:
return null;
}
const parsed = declaredBirthInputSchema.safeParse(input);
return parsed.success ? parsed.data : null;
}
function canonicalPlaceLabel(input: DeclaredBirthInput): string | null {
const place = input.birthplace;
const country = chinaLocations.country;
if (place.countryCode !== country.code || !place.provinceCode || !place.cityCode) return null;
const province = country.provinces.find((candidate) => candidate.code === place.provinceCode);
const city = province?.cities.find((candidate) => candidate.code === place.cityCode);
const district = place.districtCode
? city?.districts.find((candidate) => candidate.code === place.districtCode)
: undefined;
if (!province || !city || (place.districtCode && !district)) return null;
return [country.name, province.name, city.name, district?.name]
.filter((label, index, labels) => Boolean(label) && labels.indexOf(label) === index)
.join(" · ");
}
function withoutOptionalPlaceLabel(input: DeclaredBirthInput): unknown {
const birthplace: Record<string, unknown> = { ...input.birthplace };
delete birthplace.city;
return { ...input, birthplace };
}
function sameJson(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length
&& left.every((value, index) => sameJson(value, right[index]));
}
const leftRecord = record(left);
const rightRecord = record(right);
if (!leftRecord || !rightRecord) return false;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) => key === rightKeys[index]
&& sameJson(leftRecord[key], rightRecord[key]));
}
function declarationMatches(current: DeclaredBirthInput, stored: DeclaredBirthInput) {
if (!sameJson(withoutOptionalPlaceLabel(current), withoutOptionalPlaceLabel(stored))) {
return false;
}
if (!stored.birthplace.city) return true;
return stored.birthplace.city === canonicalPlaceLabel(current);
}
function project(row: RecordValue): AccountRectificationCaseState | null {
const status = typeof row.status === "string"
&& unfinishedStatuses.has(row.status as AccountRectificationCaseState["status"])
? row.status as AccountRectificationCaseState["status"]
: null;
if (typeof row.id !== "string"
|| row.journey_protocol !== "conversational-evidence-v3"
|| !status
|| typeof row.turn_version !== "number"
|| !Number.isSafeInteger(row.turn_version)
|| row.turn_version < 0) return null;
return Object.freeze({
caseId: row.id,
journeyProtocol: "conversational-evidence-v3" as const,
status,
turnVersion: row.turn_version,
isRevision: typeof row.revision_of_case_id === "string",
preservesActiveTime: typeof row.baseline_active_time === "string",
});
}
/** Selects the latest loaded unfinished case that belongs to this declaration. */
export function resolveAccountRectificationCase(
profile: unknown,
rows: readonly unknown[],
): AccountRectificationCaseState | null {
const current = currentDeclaration(profile);
if (!current) return null;
for (const value of rows) {
const row = record(value);
if (!row) continue;
const projected = project(row);
if (!projected) continue;
const declared = declaredBirthInputSchema.safeParse(row.declared_birth_input);
if (declared.success && declarationMatches(current, declared.data)) return projected;
}
return null;
}
@@ -89,6 +89,12 @@ export function requiresBirthTimeConsent(profile: BirthTimeDraft): boolean {
return canUseUnverifiedBirthTime(profile);
}
export function birthTimeConsultationOptionsCopy(profile: BirthTimeDraft): string {
return canUseUnverifiedBirthTime(profile)
? "你可以在当前聊天临时使用原始填报时间询问,也可以先校正。"
: "你可以继续不依赖出生分钟的一般咨询,也可以先校正;系统不会替你生成具体出生分钟。";
}
export type BirthTimeConsultationRoute =
| Readonly<{ kind: "choice"; canUseUnverifiedTime: boolean }>
| Readonly<{
+78 -19
View File
@@ -30,53 +30,112 @@ function longestOpenerPrefixSuffix(value: string) {
/** Sends only visible prose through the output guard and preserves metadata bytes. */
function createVisibleTextTransformer(transform: (text: string) => string) {
let buffered = "";
let rawBuffer = "";
let visibleBuffer = "";
let hiddenBuffer = "";
let hiddenBlocks: Array<{ readonly offset: number; readonly text: string }> = [];
let hidden = false;
function consume(value: string, final: boolean) {
buffered += value;
let output = "";
while (buffered) {
function parse(value: string, final: boolean) {
rawBuffer += value;
while (rawBuffer) {
if (hidden) {
const closeIndex = buffered.indexOf("-->");
const closeIndex = rawBuffer.indexOf("-->");
if (closeIndex < 0) {
if (final) {
output += buffered;
buffered = "";
hiddenBlocks.push({
offset: visibleBuffer.length,
text: hiddenBuffer + rawBuffer,
});
hiddenBuffer = "";
rawBuffer = "";
hidden = false;
} else {
hiddenBuffer += rawBuffer;
rawBuffer = "";
}
break;
}
output += buffered.slice(0, closeIndex + 3);
buffered = buffered.slice(closeIndex + 3);
hiddenBuffer += rawBuffer.slice(0, closeIndex + 3);
rawBuffer = rawBuffer.slice(closeIndex + 3);
hiddenBlocks.push({ offset: visibleBuffer.length, text: hiddenBuffer });
hiddenBuffer = "";
hidden = false;
continue;
}
const openerIndex = hiddenBlockOpeners.reduce<number>((earliest, opener) => {
const index = buffered.indexOf(opener);
const index = rawBuffer.indexOf(opener);
return index >= 0 && (earliest < 0 || index < earliest) ? index : earliest;
}, -1);
if (openerIndex >= 0) {
if (openerIndex > 0) output += transform(buffered.slice(0, openerIndex));
buffered = buffered.slice(openerIndex);
visibleBuffer += rawBuffer.slice(0, openerIndex);
rawBuffer = rawBuffer.slice(openerIndex);
hiddenBuffer = "";
hidden = true;
continue;
}
if (final) {
output += transform(buffered);
buffered = "";
visibleBuffer += rawBuffer;
rawBuffer = "";
break;
}
const retainedLength = longestOpenerPrefixSuffix(buffered);
const visibleLength = buffered.length - retainedLength;
if (visibleLength > 0) output += transform(buffered.slice(0, visibleLength));
buffered = buffered.slice(visibleLength);
const retainedLength = longestOpenerPrefixSuffix(rawBuffer);
const visibleLength = rawBuffer.length - retainedLength;
if (visibleLength > 0) visibleBuffer += rawBuffer.slice(0, visibleLength);
rawBuffer = rawBuffer.slice(visibleLength);
break;
}
}
function renderVisiblePrefix(length: number) {
if (length === 0) return "";
const visible = visibleBuffer.slice(0, length);
const included = hiddenBlocks.filter((block) => block.offset <= length);
const remaining = hiddenBlocks
.filter((block) => block.offset > length)
.map((block) => ({ ...block, offset: block.offset - length }));
const transformed = transform(visible);
let output = "";
if (transformed === visible) {
let start = 0;
for (const block of included) {
output += visible.slice(start, block.offset) + block.text;
start = block.offset;
}
output += visible.slice(start);
} else {
// A refusal may replace the whole sentence, so an in-sentence byte offset
// no longer has meaning. Keep metadata exact and in order after the safe
// visible replacement; the frontend parser accepts metadata at any point.
output = transformed + included.map((block) => block.text).join("");
}
visibleBuffer = visibleBuffer.slice(length);
hiddenBlocks = remaining;
return output;
}
function lastCompleteClauseBoundary() {
let boundary = 0;
for (const match of visibleBuffer.matchAll(/[.!?\n]+/gu)) {
boundary = (match.index ?? 0) + match[0].length;
}
return boundary;
}
function consume(value: string, final: boolean) {
parse(value, final);
if (final) {
const output = renderVisiblePrefix(visibleBuffer.length);
if (hiddenBlocks.length === 0) return output;
const metadata = hiddenBlocks.map((block) => block.text).join("");
hiddenBlocks = [];
return output + metadata;
}
return renderVisiblePrefix(lastCompleteClauseBoundary());
}
return Object.freeze({
push: (value: string) => consume(value, false),
finish: (value: string) => consume(value, true),
+34 -19
View File
@@ -11,19 +11,32 @@ const guaranteeConclusionPatterns = [
/(?:^|[.?!\n])[^.?!\n]*\b(?:will definitely|guaranteed? to|certain to|without doubt)\b[^.?!\n]*/gi,
];
const personalChartClaimMarkers = [
String.raw`(?:基于|根据|从|结合)\s*(?:你|您)\s*(?:的\s*)?(?:个人\s*)?(?:星盘|命盘|出生盘|本命盘|盘)`,
String.raw`(?:你|您)\s*的\s*(?:(?:D\s*\d+)(?:\s*上升)?|上升(?:星座)?|月亮星座|太阳星座|太阳|月亮|火星|水星|木星|金星|土星|罗喉|凯图|Rahu|Ketu|第\s*[一二三四五六七八九十百0-9]+\s*宫|星盘|命盘|出生盘|本命盘|盘)`,
String.raw`(?:你|您)\s*(?:的\s*)?(?:(?:D\s*\d+)(?:\s*上升)?|上升(?:星座)?|月亮星座|太阳星座|太阳|月亮|火星|水星|木星|金星|土星|罗喉|凯图|Rahu|Ketu|第\s*[一二三四五六七八九十百0-9]+\s*宫|星盘|命盘|本命盘|盘)\s*(?:(?:一定|必然|肯定|必定|绝对)\s*)?(?:是|在|落(?:在|入)?|位于|显示|表明|说明|意味着|主宰)`,
String.raw`(?:你|您)\s*(?:的\s*)?(?:星盘|命盘|出生盘|本命盘|盘)\s*(?:中|里|内)`,
String.raw`(?:D\s*\d+|上升(?:星座)?)\s*(?:显示|表明|说明|意味着)\s*(?:你|您)`,
String.raw`(?:your|the user's)\s+(?:natal\s+|birth\s+)?(?:chart|ascendant|D\s*\d+|\d+(?:st|nd|rd|th)\s+house)`,
];
// The boundary is structural: first require a personalized subject, then a
// chart object and a placement/conclusion predicate. Planet names are a finite
// vocabulary supplement, not the primary detection mechanism.
const chineseChartObjectPattern = /(?:?|||||D\s*\d+|(?:)?|[\p{Script=Han}A-Za-z0-9]{0,8}||||||||||||Rahu|Ketu)/iu;
const chineseChartConclusionPattern = /(?:(?:||)?||||||||||?|?|||||[:])/iu;
const englishChartObjectPattern = /\b(?:natal\s+chart|birth\s+chart|chart|ascendant|rising\s+sign|D\s*\d+|(?:\d+(?:st|nd|rd|th)|[a-z]+)\s+house|house|planet|sun|moon|mars|mercury|jupiter|venus|saturn|rahu|ketu)\b/iu;
const englishChartConclusionPattern = /(?:\b(?:is|are|falls?|lands?|sits?|placed?|located?|shows?|indicates?|means?|rules?|strong|weak)\b|[:])/iu;
const chinesePossessiveChartSubjectPattern = /(?:|)\s*(?:|(?:)?)\s*(?:?|||||D\s*\d+|(?:)?|[\p{Script=Han}A-Za-z0-9]{0,8}||||||||||||Rahu|Ketu)/iu;
const chineseBareChartSubjectPattern = /(?:|)\s*(?:?|||||D\s*\d+|(?:)?|(?:\s*)?[0-9]+\s*||||||||||||Rahu|Ketu)/iu;
const chineseChartAddressesUserPattern = /(?:?|||||D\s*\d+|(?:)?)\s*(?:|||)\s*(?:|)/iu;
const englishPersonalChartSubjectPattern = /\b(?:your|the\s+user(?:'s)?)\s+(?:personal\s+)?(?:natal\s+chart|birth\s+chart|chart|ascendant|rising\s+sign|D\s*\d+|(?:\d+(?:st|nd|rd|th)|[a-z]+)\s+house|house|planet|sun|moon|mars|mercury|jupiter|venus|saturn|rahu|ketu)\b/iu;
const englishChartAddressesUserPattern = /\b(?:the\s+)?(?:natal\s+|birth\s+)?chart\s+(?:shows?|indicates?|means?)\s+(?:that\s+)?you\b/iu;
const personalChartClaimPatterns = personalChartClaimMarkers.map((marker) => new RegExp(
String.raw`(^|[。!?.!?\n])[^。!?.!?\n]*${marker}[^。!?.!?\n]*`,
"giu",
));
function isPersonalChartConclusion(clause: string) {
const normalized = clause.normalize("NFKC");
const chineseStructure = (chinesePossessiveChartSubjectPattern.test(normalized)
|| chineseBareChartSubjectPattern.test(normalized)
|| chineseChartAddressesUserPattern.test(normalized))
&& chineseChartObjectPattern.test(normalized)
&& chineseChartConclusionPattern.test(normalized);
const englishStructure = (englishPersonalChartSubjectPattern.test(normalized)
|| englishChartAddressesUserPattern.test(normalized))
&& englishChartObjectPattern.test(normalized)
&& englishChartConclusionPattern.test(normalized);
return chineseStructure || englishStructure;
}
export const GENERAL_NO_BIRTH_TIME_REFUSAL =
"当前一般咨询模式不能生成个人星盘结论;你可以改问一般知识,或先完成生时校正";
@@ -44,11 +57,13 @@ export function guardPreciseTimingOutput(text: string) {
/** A deterministic post-model boundary for the zero-chart general mode. */
export function guardGeneralNoBirthTimeOutput(text: string) {
let guarded = guardPreciseTimingOutput(text);
for (const pattern of personalChartClaimPatterns) {
guarded = guarded.replace(pattern, (_sentence, prefix: string) => (
`${prefix}${GENERAL_NO_BIRTH_TIME_REFUSAL}`
));
}
return guarded;
const guarded = guardPreciseTimingOutput(text);
return guarded
.split(/([.!?\n]+)/u)
.map((part, index) => (
index % 2 === 0 && isPersonalChartConclusion(part)
? GENERAL_NO_BIRTH_TIME_REFUSAL
: part
))
.join("");
}