diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index a995e2ff..74c81471 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -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; - 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 }, diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 30a97e3b..ca8a8763 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 ? ( <>
{birthTimeDisplay.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}
{birthTimeDisplay.activeTime}
-
结果状态
{birthTimeDisplay.kind === "candidate" ? "未确认;咨询时仅可临时使用原始填报时间" : "已确认"}
+
结果状态
{birthTimeDisplay.kind === "candidate" ? `未确认;${birthTimeConsultationOptionsCopy(profile)}` : "已确认"}
原始填报
{birthTimeDisplay.reportedLabel}
) : ( diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 0a3aac12..78bd910e 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -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) {displayState.kind === "candidate" && ( -

这仍是未确认候选,不会自动成为出生分钟;普通咨询只能临时使用上面的原始填报时间,或先继续校正。

+

这仍是未确认候选,不会自动成为出生分钟;{birthTimeConsultationOptionsCopy(value)}

)} )} diff --git a/frontend/src/lib/account-rectification-case.ts b/frontend/src/lib/account-rectification-case.ts new file mode 100644 index 00000000..a6a8e513 --- /dev/null +++ b/frontend/src/lib/account-rectification-case.ts @@ -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; + +const unfinishedStatuses = new Set([ + "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 = { ...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; +} diff --git a/frontend/src/lib/birth-time-consultation-consent.ts b/frontend/src/lib/birth-time-consultation-consent.ts index 778a60e7..3259836d 100644 --- a/frontend/src/lib/birth-time-consultation-consent.ts +++ b/frontend/src/lib/birth-time-consultation-consent.ts @@ -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<{ diff --git a/frontend/src/lib/stream-text-response.ts b/frontend/src/lib/stream-text-response.ts index 5e6dd0c8..907a1bed 100644 --- a/frontend/src/lib/stream-text-response.ts +++ b/frontend/src/lib/stream-text-response.ts @@ -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((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), diff --git a/frontend/src/lib/timing-output-guard.ts b/frontend/src/lib/timing-output-guard.ts index 2d64dca0..b967dbef 100644 --- a/frontend/src/lib/timing-output-guard.ts +++ b/frontend/src/lib/timing-output-guard.ts @@ -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(""); } diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 176ab069..d1043aaf 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -5,9 +5,13 @@ import { accountProfilePatchSchema, resolveAccountBirthTimeApplicationPatch, } from "../src/lib/account-profile-patch.ts"; +import { + resolveAccountRectificationCase, +} from "../src/lib/account-rectification-case.ts"; const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); const patchSource = readFileSync(new URL("../src/lib/account-profile-patch.ts", import.meta.url), "utf8"); +const caseServiceSource = readFileSync(new URL("../src/lib/account-rectification-case.ts", import.meta.url), "utf8"); test("account API reads and returns the server-configured rectification price", () => { assert.match(source, /parseRectificationPriceCredits\(\s*process\.env\.RECTIFICATION_PRICE_CREDITS,?\s*\)/); @@ -16,24 +20,197 @@ test("account API reads and returns the server-configured rectification price", }); test("account API projects only the minimum case state needed by the homepage", () => { - const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? ""; + const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? ""; - assert.match(caseSelect, /id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,updated_at/); + assert.match(caseSelect, /id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at/); assert.doesNotMatch(caseSelect, /candidate_scan|event_evidence|validation_receipt|pending_consultation_question|journey_snapshot|turn_state/); - assert.match(source, /caseId:/); - assert.match(source, /journeyProtocol:/); - assert.match(source, /turnVersion:/); - assert.match(source, /preservesActiveTime:/); + assert.match(caseServiceSource, /caseId:/); + assert.match(caseServiceSource, /journeyProtocol:/); + assert.match(caseServiceSource, /turnVersion:/); + assert.match(caseServiceSource, /preservesActiveTime:/); }); test("account API scopes the service-role case lookup to the authenticated account", () => { - const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? ""; + const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? ""; assert.match(caseSelect, /\.eq\("user_id", user\.id\)/); assert.match(caseSelect, /\.eq\("journey_protocol", "conversational-evidence-v3"\)/); assert.match(caseSelect, /\.order\("updated_at", \{ ascending: false \}\)/); }); +const currentDeclaredProfile = Object.freeze({ + credits: 7, + active_birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + birth_date: "1997-08-08", + reported_birth_time: "05:30:00", + birth_time_source: "approximate", + birth_time_period: null, + birth_time_clue: "家人记得天亮前后", + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + country_code: "CN", + province_code: "130000", + city_code: "130400", + district_code: "130406", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, +}); + +const currentDeclaredInput = Object.freeze({ + source: "approximate", + birthDate: "1997-08-08", + reportedTime: "05:30", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: "家人记得天亮前后", + birthplace: { + countryCode: "CN", + provinceCode: "130000", + cityCode: "130400", + districtCode: "130406", + latitude: 36.420487, + longitude: 114.209936, + timezoneOffset: 8, + }, +}); + +function unfinishedCase( + declaredBirthInput: unknown = currentDeclaredInput, + overrides: Record = {}, +) { + return { + id: "11111111-1111-4111-8111-111111111111", + journey_protocol: "conversational-evidence-v3", + status: "paused", + turn_version: 4, + revision_of_case_id: null, + baseline_active_time: null, + declared_birth_input: declaredBirthInput, + private_candidate: { calculationVersion: "must-not-leak" }, + pending_consultation_question: "must-not-leak", + ...overrides, + }; +} + +test("account case projection resumes only an unfinished v3 case matching the current declaration", () => { + const projected = resolveAccountRectificationCase( + currentDeclaredProfile, + [unfinishedCase()], + ); + + assert.deepEqual(projected, { + caseId: "11111111-1111-4111-8111-111111111111", + journeyProtocol: "conversational-evidence-v3", + status: "paused", + turnVersion: 4, + isRevision: false, + preservesActiveTime: false, + }); + assert.doesNotMatch( + JSON.stringify(projected), + /declared|private_candidate|pending_consultation_question|must-not-leak/, + ); + assert.equal(currentDeclaredProfile.rectification_case_id, null); +}); + +test("edited declaration fields make old unfinished cases non-resumable without deleting audit rows", () => { + const declarationMismatches = [ + { ...currentDeclaredInput, birthDate: "1997-08-09" }, + { ...currentDeclaredInput, reportedTime: "05:31" }, + { ...currentDeclaredInput, birthTimeClue: "另一条线索" }, + { ...currentDeclaredInput, uncertaintyBeforeMinutes: 60, uncertaintyAfterMinutes: 60 }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, countryCode: "TW" } }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, provinceCode: "140000" } }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, cityCode: "130500" } }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, districtCode: "130407" } }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, latitude: 36.420488 } }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, longitude: 114.209937 } }, + { ...currentDeclaredInput, birthplace: { ...currentDeclaredInput.birthplace, timezoneOffset: 9 } }, + ]; + + for (const declared of declarationMismatches) { + const row = unfinishedCase(declared); + assert.equal(resolveAccountRectificationCase(currentDeclaredProfile, [row]), null); + assert.equal(row.declared_birth_input, declared, "matching must not mutate or delete audit data"); + } + + const periodProfile = { + ...currentDeclaredProfile, + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "early_morning", + birth_time_clue: null, + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + }; + assert.equal(resolveAccountRectificationCase(periodProfile, [unfinishedCase()]), null); + const periodDeclaration = { + source: "period_only", + birthDate: currentDeclaredInput.birthDate, + reportedPeriod: "early_morning", + birthTimeClue: null, + birthplace: currentDeclaredInput.birthplace, + }; + assert.ok(resolveAccountRectificationCase(periodProfile, [unfinishedCase(periodDeclaration)])); + assert.equal(resolveAccountRectificationCase(periodProfile, [unfinishedCase({ + ...periodDeclaration, + reportedPeriod: "morning", + })]), null); + assert.equal(resolveAccountRectificationCase(currentDeclaredProfile, [ + unfinishedCase(currentDeclaredInput, { status: "completed" }), + ]), null); +}); + +test("account case matching validates optional canonical place labels and can find a later matching row", () => { + const wrongLabel = unfinishedCase({ + ...currentDeclaredInput, + birthplace: { ...currentDeclaredInput.birthplace, city: "错误地点" }, + }); + const matching = unfinishedCase(currentDeclaredInput, { + id: "22222222-2222-4222-8222-222222222222", + status: "active", + turn_version: 1, + }); + const correctlyLabelled = unfinishedCase({ + ...currentDeclaredInput, + birthplace: { + ...currentDeclaredInput.birthplace, + city: "中国 · 河北省 · 邯郸市 · 峰峰矿区", + }, + }); + + assert.ok(resolveAccountRectificationCase(currentDeclaredProfile, [correctlyLabelled])); + + assert.deepEqual( + resolveAccountRectificationCase(currentDeclaredProfile, [wrongLabel, matching]), + { + caseId: "22222222-2222-4222-8222-222222222222", + journeyProtocol: "conversational-evidence-v3", + status: "active", + turnVersion: 1, + isRevision: false, + preservesActiveTime: false, + }, + ); +}); + +test("account route reads declaration truth only for server matching and does not key resume to profile case id", () => { + const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? ""; + const responseStart = source.indexOf("return NextResponse.json({\n user:"); + const responseProjection = source.slice(responseStart, source.indexOf(" } catch", responseStart)); + + assert.match(source, /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/); + assert.match(caseSelect, /declared_birth_input/); + assert.match(caseSelect, /\.eq\("user_id", user\.id\)/); + assert.match(caseSelect, /\.in\("status",/); + assert.doesNotMatch(caseSelect, /rectification_case_id/); + assert.match(source, /resolveAccountRectificationCase/); + assert.doesNotMatch(responseProjection, /declared_birth_input|private_candidate|pending_consultation_question/); +}); + test("profile patch schema validates calendar, clock, source requirements, and location bounds", () => { const valid = { name: "岳辰", diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index 67d29100..6648023b 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { canUseUnverifiedBirthTime, + birthTimeConsultationOptionsCopy, consultationModeForSession, createLatestAccountRequestGuard, createBirthTimeConsultationConsentState, @@ -60,6 +61,11 @@ test("period-only and unknown declarations never pretend to provide an unverifie assert.equal(canUseUnverifiedBirthTime(unknown), false); assert.equal(requiresBirthTimeConsent(periodOnly), false); assert.equal(requiresBirthTimeConsent(unknown), false); + assert.match(birthTimeConsultationOptionsCopy(periodOnly), /一般咨询.*校正/); + assert.match(birthTimeConsultationOptionsCopy(unknown), /一般咨询.*校正/); + assert.doesNotMatch(birthTimeConsultationOptionsCopy(periodOnly), /使用.*原始填报时间|具体原始时间/); + assert.doesNotMatch(birthTimeConsultationOptionsCopy(unknown), /使用.*原始填报时间|具体原始时间/); + assert.match(birthTimeConsultationOptionsCopy(reportedExactTime), /原始填报时间.*校正/); }); test("the current reported minute wins over an old candidate and never falls back to it", () => { @@ -198,3 +204,23 @@ test("soft choice announces itself and locks every action while rectification op assert.match(source, /继续不依赖出生分钟的一般咨询/); assert.ok((source.match(/disabled=\{pending\}/g) ?? []).length >= 3); }); + +test("homepage and profile result copy use the source-aware consultation options", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); + + assert.match(page, /birthTimeConsultationOptionsCopy\(profileDraft\)/); + assert.match(page, /birthTimeConsultationOptionsCopy\(profile\)/); + assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/); +}); + +test("a saved declaration edit cannot leave the old resumable case in local account state", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const saveProfile = page.slice( + page.indexOf("async function saveProfile"), + page.indexOf("async function saveOnboardingName"), + ); + + assert.match(saveProfile, /declarationChanged[\s\S]*setAccount\(\(current\)[\s\S]*rectificationCase:\s*null/); + assert.match(saveProfile, /declarationChanged[\s\S]*void refreshAccount\(\)/); +}); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 867a7af9..465650e2 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -150,7 +150,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); assert.match(candidateResultSource, /作为当前排盘时间<\/span>并进入对话;原始填报<\/span>和本次候选结果<\/span>仍会保留<\/span>。/); - assert.match(pageSource, /未确认;咨询时仅可临时使用原始填报时间/); + assert.match(pageSource, /未确认;\$\{birthTimeConsultationOptionsCopy\(profile\)\}/); assert.match(pageSource, / { + const unsafeClaims = [ + "你的七宫落入摩羯。", + "盘面显示你的事业宫很强。", + "你的金星落第七宫。", + "你的上升落在巨蟹座。", + "你的 D9 显示婚姻会晚一些。", + "Your Venus is in the 7th house.", + "Your ascendant falls in Cancer.", + "Your D9 chart shows a strong marriage house.", + ]; + + for (const claim of unsafeClaims) { + const guarded = guardGeneralNoBirthTimeOutput(claim); + assert.equal(guarded.includes(GENERAL_NO_BIRTH_TIME_REFUSAL), true, claim); + assert.equal(guarded.includes(claim.replace(/[。.]$/, "")), false, claim); + } + + assert.equal( + guardGeneralNoBirthTimeOutput("第七宫在占星概念中常与关系相关。"), + "第七宫在占星概念中常与关系相关。", + ); + assert.equal( + guardGeneralNoBirthTimeOutput("Venus is generally associated with relating and values."), + "Venus is generally associated with relating and values.", + ); + assert.equal( + guardGeneralNoBirthTimeOutput("你问的第七宫,在占星概念中常与关系相关。"), + "你问的第七宫,在占星概念中常与关系相关。", + ); +}); + +test("hidden AYANAM comments cannot split a personalized claim around the guard", async () => { + const title = ""; + const suggestions = ''; + async function* reply() { + yield "一般知识可以说明概念。你的金星落"; + yield "第七宫。\n'; + } + + const response = streamTextResponse(reply(), { + mode: "mastra", + requestId: "00000000-0000-4000-8000-000000000097", + transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false), + }); + const text = await response.text(); + const parsed = parseAgentReply(text, "general"); + + assert.match(text, /一般知识可以说明概念/); + assert.match(text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); + assert.doesNotMatch(text, /你的\s*金星落第七宫/); + assert.equal(text.includes(title), true); + assert.equal(text.includes(suggestions), true); + assert.equal(parsed.title, "一般占星咨询"); + assert.deepEqual(parsed.suggestions, ["了解第七宫的一般概念", "先完成生时校正", "改问一般知识"]); +});