fix: bind rectification state to current declaration
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user