From ee085624711d145a104d42440ed433d7d5a660a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:26:04 +0800 Subject: [PATCH 1/2] feat(rectification): rectify other people in the profile list (issue #160) Product 2026-11-01: 1A 2A. - Cases record whose chart (chart_profile_id, null = owner); add-only migration 20261101010000_rectification_chart_subject.sql, old cases stay the owner's. - Adoption / confirmation / segment adoption / VedAstro refresh read and write that person's row through two helpers; the owner's profile and cases are untouched. Owner edits rebaseline only owner cases, person edits only that person's; a new declaration drops the adopted minute. - Open cases are counted per person (entry summary v2, in-chat offer, resume lookup); open_agentic_rectification_case_v3 binds a new case and its conversation to the person. - delete_chart_subject deletes the person's cases and their conversations first (no refund). - /rectification follows the current person (switcher beside the title), shows name and relation; self-only limits removed (hook, chat offer, chart ascendant link, starter hint). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y --- frontend/src/app/(app)/page.tsx | 8 +- frontend/src/app/api/consult/route.ts | 6 +- .../cases/entry-summary/route.ts | 20 +- frontend/src/app/globals.css | 31 +- .../components/chart-page/chart-page-view.tsx | 6 +- .../rectification-entry-page.tsx | 150 +- frontend/src/components/starter-questions.tsx | 2 +- .../src/hooks/use-rectification-surface.ts | 124 +- frontend/src/lib/chart-subject-write.ts | 13 +- frontend/src/lib/chat-session-url.ts | 21 +- .../src/lib/contracts/rectification-entry.ts | 178 +- frontend/src/lib/lagna-precheck.ts | 15 +- .../v9/block-scan-answer.ts | 2 +- .../rectification-agentic/v9/case-service.ts | 82 +- .../rectification-agentic/v9/open-request.ts | 13 +- .../rectification-agentic/v9/tool-service.ts | 28 +- frontend/src/lib/rectification-entry.ts | 20 +- .../src/lib/rectification-in-chat-server.ts | 11 +- frontend/src/lib/rectification-in-chat.ts | 10 +- frontend/src/lib/rectification-page.ts | 53 +- .../src/lib/rectification-resume-session.ts | 6 +- frontend/src/mastra/consultation-tools.ts | 6 +- ...1101010000_rectification_chart_subject.sql | 1783 +++++++++++++++++ frontend/tests/chart-page-view.test.tsx | 12 +- .../tests/consultation-entrypoint.test.ts | 7 +- ...ctification-chart-subject-20261101.test.ts | 240 +++ frontend/tests/lagna-precheck.test.ts | 15 +- .../tests/rectification-agentic-entry.test.ts | 5 +- ...ctification-chart-subject-20261101.test.ts | 415 ++++ ...tion-engine-version-cross-midnight.test.ts | 5 +- ...rectification-entry-page-20261028.test.tsx | 15 +- ...tification-explicit-entry-20261012.test.ts | 37 +- .../rectification-in-chat-20261007.test.ts | 33 +- ...rectification-product-domain-route.test.ts | 11 +- .../rectification-v9-case-service.test.ts | 9 +- .../tests/rectification-v9-test-support.ts | 11 + 36 files changed, 3092 insertions(+), 311 deletions(-) create mode 100644 frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql create mode 100644 frontend/tests/database-rectification-chart-subject-20261101.test.ts create mode 100644 frontend/tests/rectification-chart-subject-20261101.test.ts diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index fbde4805..59257d95 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -26,7 +26,6 @@ import { rectificationOrdinaryComposerReopen, } from "@/lib/rectification-session-composer-guard"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification-lazy"; -import { declaredBirthTime } from "@/lib/rectification-surface-state"; import { toggleChatMessageFeedback } from "@/components/chat-message-actions"; import type { ChatTranscriptActions } from "@/components/chat-transcript"; import { ChatStreamingTranscript, useStreamingReplyHasText } from "@/components/chat-streaming-transcript"; @@ -431,7 +430,6 @@ export default function Home() { : activeReplyOutcome?.phase ?? "idle"; const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0); const accountId = account?.user.id; - const rectificationDeclaration = { declaredTime: declaredBirthTime(profile), birthDate: profile.date || null }; const rectificationErrorMessage = rectificationError === "profile_incomplete" ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" : rectificationError; @@ -466,7 +464,7 @@ export default function Home() { }); const { - setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession, + setRectificationHeaderSlot, rectificationPanel, rectificationDeclaration, handleRectificationSaved, resumeRectificationSession, openRectificationSession, reopenRectificationSession, } = useRectificationSurface({ account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog, @@ -1339,7 +1337,7 @@ export default function Home() { selectedModelId={activeSession?.modelId ?? ""} onSelectModel={(modelId) => void selectSessionModel(modelId)} onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })} - onSaved={() => void refreshAccount()} + onSaved={handleRectificationSaved} panel={rectificationPanel} /> )} @@ -1355,7 +1353,7 @@ export default function Home() { chartSignsFallback={dailyStarlanguage.kind === "unavailable"} onAsk={(text) => void startSuggestedConsultation(text)} onNudge={nudgeComposer} - rectifyHint={currentSubjectId === "self" && account.rectification === true && birthTimeNeedsRectification(profile)} + rectifyHint={account.rectification === true && Boolean(dailyProfile) && birthTimeNeedsRectification(dailyProfile ?? profile)} /> ) : null} {jumpToLatestVisible && ( diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 60b9d985..851eee74 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -1035,17 +1035,19 @@ export async function POST(request: Request) { const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS }); latencyState = state; // C2/C3 (BUG-1248): the offer tool asks the server, never decides itself. + // Issue #160: the person this conversation is about, owner or not. + const rectificationSubjectId = prepared.subject?.role === "other" ? prepared.subject.chartProfileId : null; const rectificationOffer: RectificationOfferHook = { decide: () => decideRectificationOffer({ accounting, userId, - subjectRole: prepared.subject?.role === "other" ? "other" : "self", + subjectId: rectificationSubjectId, modelId: selectedModel.id, }), alreadyOffered: sessionHasRectificationOffer(chatSession?.messages), revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId), sourceSessionId: sessionId, - loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId), + loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId, rectificationSubjectId), }; const hooks = createConsultationRuntimeHooks(state); const usages: Promise[] = []; diff --git a/frontend/src/app/api/rectification/cases/entry-summary/route.ts b/frontend/src/app/api/rectification/cases/entry-summary/route.ts index a5e62016..39468cf8 100644 --- a/frontend/src/app/api/rectification/cases/entry-summary/route.ts +++ b/frontend/src/app/api/rectification/cases/entry-summary/route.ts @@ -7,15 +7,18 @@ import { getRectificationEntrySummary, mapRectificationRpcError, } from "@/lib/rectification-agentic/v9/case-service"; +import { SELF_SUBJECT_ID, isChartSubjectId, subjectIdFromQuery } from "@/lib/subject-birth"; export const runtime = "nodejs"; /** - * GET /api/rectification/cases/entry-summary + * GET /api/rectification/cases/entry-summary[?subject=] * - * Server-truth context for homepage copy; the primary homepage action always creates. + * Server-truth context for the /rectification card, one person at a time + * (issue #160). No `subject` or `self` is the account owner; an id that is not + * this user's reads as "nothing on record" (the RPC filters by user). */ -export async function GET() { +export async function GET(request: Request) { let supabase; let accounting; try { @@ -32,8 +35,17 @@ export async function GET() { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } + const subject = subjectIdFromQuery(new URL(request.url).searchParams.get("subject")); + if (subject !== SELF_SUBJECT_ID && !isChartSubjectId(subject)) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_subject" }, { status: 400 }); + } + try { - return NextResponse.json(await getRectificationEntrySummary(accounting, user.id)); + return NextResponse.json(await getRectificationEntrySummary( + accounting, + user.id, + subject === SELF_SUBJECT_ID ? null : subject, + )); } catch (error) { if (error instanceof RectificationCaseServiceError) { const view = mapRectificationRpcError( diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 432652e0..27a0e2be 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -7091,6 +7091,32 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } color: var(--color-ink-tertiary); font-size: var(--type-body-sm); } +/* Whose chart (issue #160): the name, then the relation as a quiet tag. + One line; a long name is cut, the tag never wraps under it. */ +.rectification-page-person { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + margin: 0 0 var(--space-1); + white-space: nowrap; +} +.rectification-page-person-name { + overflow: hidden; + color: var(--color-ink); + font-size: var(--type-body-md); + font-weight: 500; + text-overflow: ellipsis; +} +.rectification-page-person-relation { + flex: none; + padding: 1px var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.5; +} .rectification-page-label--done { color: var(--color-success); } @@ -7197,8 +7223,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } } } -/* Onboarding prompt under the starter questions (issue #145): self chart with an - estimated time only. A quiet row, not a card; the link goes to /rectification. */ +/* Onboarding prompt under the starter questions (issue #145): the current + person's time is an estimate (anyone in the list since #160). A quiet row, + not a card; the link goes to /rectification. */ .starter-rectify-hint { display: flex; flex-wrap: wrap; diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index f32804c9..9ab7c350 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -84,8 +84,8 @@ export function ChartPageRoute() { ); } -function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck; self: boolean }) { - const line = lagnaPrecheckLine(value, { self }); +function ChartLagnaPrecheckNote({ value }: { value: ChartViewLagnaPrecheck }) { + const line = lagnaPrecheckLine(value); return (

{line.before} @@ -354,7 +354,7 @@ export function ChartPageView({

+ {person.name} + {person.relation} +

+ ); +} + export function RectificationEntryPage() { const list = useSessionList(); const signedIn = Boolean(list.account) && !list.signedOut; const enabled = list.account?.rectification; - const profile = readProfile(list.account?.profile); - const selfComplete = selfChartComplete(profile); - const [read, setRead] = useState({ status: "loading" }); - const [checking, setChecking] = useState(false); - const needsSummary = signedIn && enabled === true && selfComplete; + const selfProfile = readProfile(list.account?.profile); + // Issue #160: the page is for the person chosen beside the title. + const subjectId = useCurrentSubject(); + const forOther = subjectId !== "self"; + const catalogReady = useCurrentSubjectReady(); + const otherProfile = useCurrentSubjectProfile(selfProfile); + const profile: Profile | null = forOther ? otherProfile : selfProfile; + const profileComplete = profile + ? (forOther ? missingOtherProfileStep(profile) === null : selfChartComplete(profile)) + : false; + const person = rectificationPersonFacts(subjectId, profile); + const [read, setRead] = useState({ status: "loading", subjectId }); + const [checking, setChecking] = useState(null); + const needsSummary = signedIn && enabled === true && profileComplete; + // Signed out there is no one to choose. + const beside = signedIn ? undefined : ; const load = useCallback(() => { - setRead({ status: "loading" }); - void readEntrySummary().then(setRead); - }, []); + setRead({ status: "loading", subjectId }); + void readEntrySummary(subjectId).then(setRead); + }, [subjectId]); useEffect(() => { if (!needsSummary) return; let live = true; - void readEntrySummary().then((next) => { + void readEntrySummary(subjectId).then((next) => { if (live) setRead(next); }); return () => { live = false; }; - }, [needsSummary]); + }, [needsSummary, subjectId]); - if (!list.settled && !list.signedOut) { - return } waiting={COPY.waiting} />; + if ((!list.settled && !list.signedOut) || (signedIn && forOther && !catalogReady)) { + return ; } - if (needsSummary && read.status === "loading") { - return } waiting={COPY.waiting} />; + // A summary read for the previous person never stands in for this one. + const current: SummaryRead = read.subjectId === subjectId ? read : { status: "loading", subjectId }; + if (needsSummary && current.status === "loading") { + return ; } const state: RectificationPageState = rectificationPageState({ signedOut: !signedIn, enabled, - selfComplete, - summary: read.status === "ready" ? read.summary : EMPTY_SUMMARY, + profileComplete, + forOther, + summary: current.status === "ready" ? current.summary : EMPTY_SUMMARY, }); + const checkProfile = checking === subjectId && (state.kind === "none" || state.kind === "done") ? profile : null; return ( - }> +
- {checking && (state.kind === "none" || state.kind === "done") ? ( + {checkProfile ? (
- @@ -111,24 +149,40 @@ export function RectificationEntryPage() { {COPY.checkTitle}
+
+
{COPY.personName}
+
{person.name}
+
+
+
{COPY.personRelation}
+
{person.relation}
+
{COPY.birthTime}
-
{formatBirthMoment(profile)}
+
{formatBirthMoment(checkProfile)}
{COPY.birthPlace}
-
{profilePlaceLabel(profile)}
+
{profilePlaceLabel(checkProfile)}
- {COPY.confirmStart} - {COPY.editProfile} + {COPY.confirmStart} + {COPY.editProfile}
) : ( <>

{COPY.intro}

- setChecking(true)} /> + setChecking(subjectId)} + /> )}
@@ -138,22 +192,29 @@ export function RectificationEntryPage() { function StatusCard({ state, + subjectId, + profile, + person, failed, onRetry, onStart, }: { readonly state: RectificationPageState; + readonly subjectId: string; + readonly profile: Profile | null; + readonly person: PersonFacts | null; readonly failed: boolean; readonly onRetry: () => void; readonly onStart: () => void; }) { - const list = useSessionList(); - const profile = readProfile(list.account?.profile); const enter = { "data-onboarding-enter": "rectification-status", "data-onboarding-index": 1 } as const; + const who = person ? : null; + const currentTimeLabel = subjectId === "self" ? COPY.currentTime : COPY.otherCurrentTime; if (failed) { return (
+ {who}

{COPY.loadFailed}

@@ -177,17 +238,20 @@ function StatusCard({
); case "self-missing": + case "other-missing": return (
-

{COPY.selfMissing}

-
{COPY.fillSelf}
+ {who} +

{state.kind === "self-missing" ? COPY.selfMissing : COPY.otherMissing}

+
{COPY.fillSelf}
); case "none": return ( -
-

{COPY.currentTime}

-

{formatBirthMoment(profile)}

+
+ {who} +

{currentTimeLabel}

+

{profile ? formatBirthMoment(profile) : null}

@@ -196,6 +260,7 @@ function StatusCard({ case "active": return (
+ {who}

{COPY.activeLabel}

{state.range ? ( @@ -212,18 +277,19 @@ function StatusCard({ ) : null}
- {COPY.resume} + {COPY.resume}
); case "done": return (
+ {who}

{COPY.doneLabel}

{state.time}

{COPY.doneNote}

- {state.sessionId ? {COPY.viewResult} : null} + {state.sessionId ? {COPY.viewResult} : null}
diff --git a/frontend/src/components/starter-questions.tsx b/frontend/src/components/starter-questions.tsx index ab1689c0..ce7cb83d 100644 --- a/frontend/src/components/starter-questions.tsx +++ b/frontend/src/components/starter-questions.tsx @@ -32,7 +32,7 @@ export function StarterQuestions({ readonly chartSignsFallback: boolean; readonly onAsk: (question: string) => void; readonly onNudge: () => void; - /** Self chart with an estimated time (issue #145): one line and a link to /rectification. */ + /** The current person's time is an estimate (issue #145; anyone in the list since #160): one line and a link to /rectification. */ readonly rectifyHint?: boolean; }) { const questions = starterQuestions(audience, signs); diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index 17b83259..7deaeebb 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -6,10 +6,16 @@ import { resolveSessionTitle } from "@/lib/agent-reply"; import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync"; import { rectificationOpenIdentity } from "@/lib/rectification-session-open"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; -import { RECTIFICATION_ENTRY_QUERY_KEY, rectificationEntryIntent, writeSessionUrl } from "@/lib/chat-session-url"; +import { + RECTIFICATION_ENTRY_QUERY_KEY, + RECTIFICATION_SUBJECT_QUERY_KEY, + rectificationEntryIntent, + rectificationEntrySubject, + writeSessionUrl, +} from "@/lib/chat-session-url"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; -import { readCurrentSubjectId } from "@/lib/current-subject"; -import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile"; +import { peekSubjectCatalog, readCurrentSubjectId, refreshSubjectCatalog } from "@/lib/current-subject"; +import { chartSnapshotForSession, missingOtherProfileStep, missingProfileStep } from "@/lib/home-profile"; import { timestamp, type Account, @@ -35,6 +41,7 @@ import type { } from "@/components/conversational-birth-time-rectification"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { + declaredBirthTime, hydrateRectificationCase, parsePersistedRectificationTurns, RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE, @@ -46,6 +53,7 @@ import { import { notifyError } from "@/lib/notify"; import { bindOfferToCase } from "@/lib/rectification-in-chat"; import { loadRectificationSurface } from "@/lib/rectification-surface-loader"; +import { invalidateChartPage, invalidateEphemerisPage } from "@/lib/secondary-page-data"; export type RectificationShellState = { sessionId: string | null; @@ -127,9 +135,25 @@ export type RectificationSurfaceParams = { rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise>; }; -/** The live openers. `/?rectify=1` queues one homepage open, `/?rectify=new` one new case, after the hook is ready. */ -let latestChartRectifyStart: () => Promise = async () => undefined; -let latestNewRectifyStart: () => Promise = async () => undefined; +/** + * The live openers. `/?rectify=1` queues one homepage open, `/?rectify=new` one + * new case, after the hook is ready; `&rectifyFor=` names the person + * (issue #160), otherwise it is the account owner. + */ +let latestChartRectifyStart: (subjectId: string) => Promise = async () => undefined; +let latestNewRectifyStart: (subjectId: string) => Promise = async () => undefined; + +const OTHER_PROFILE_INCOMPLETE_NOTICE = "请先把 TA 的出生资料填完整,再开始生时校正。"; + +/** + * The home chart library, plus the shared people catalog when the library has + * not loaded this person yet (a queued `/?rectify=1&rectifyFor=` open can run + * before it does). + */ +function libraryWith(chartLibrary: readonly ChartLibraryRecord[], subjectId: string): readonly ChartLibraryRecord[] { + if (subjectId === "self" || chartLibrary.some((record) => record.id === subjectId)) return chartLibrary; + return [...chartLibrary, ...(peekSubjectCatalog() ?? []).filter((record) => record.id === subjectId)]; +} export function useRectificationSurface(params: RectificationSurfaceParams) { const { @@ -177,6 +201,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { // cases started from history or before 2026-10-07. const [rectificationSource, setRectificationSource] = useState<{ caseId: string; sessionId: string } | null>(null); const [rectificationReadonly, setRectificationReadonly] = useState(false); + // Whose chart the open case is (issue #160): "self" or a chart_profiles id. + const [rectificationSubjectId, setRectificationSubjectId] = useState("self"); const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false); const [rectificationTurns, setRectificationTurns] = useState([]); const [rectificationSnapshot, setRectificationSnapshot] = useState(null); @@ -214,14 +240,27 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { intent: "homepage" | "session" | "new", exactSessionId: string | null, sourceSessionId: string | null = null, + subjectId = "self", ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current || rectificationMutationPending) { sessionSelectionSource.current = "user"; return null; } + // Another person (issue #160): a known incomplete record stops here. The + // server reads that person itself and answers profile_incomplete otherwise. + const forOther = intent !== "session" && subjectId !== "self"; + const otherRecord = forOther + ? libraryWith(chartLibrary, subjectId).find((record) => record.id === subjectId && record.role === "other") + : undefined; + if (otherRecord && missingOtherProfileStep(otherRecord.profile) !== null) { + sessionSelectionSource.current = "user"; + setComposerNotice(OTHER_PROFILE_INCOMPLETE_NOTICE); + return null; + } + // The owner's onboarding gate; another person's data was checked above. const missingStep = missingProfileStep(profile, chartLibrary); - if (missingStep) { + if (missingStep && !forOther) { sessionSelectionSource.current = "user"; setRectificationSessionId(null); setRectificationCaseId(null); @@ -253,12 +292,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { const response = await fetch("/api/rectification/cases/open", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId, sourceSessionId)), + body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId, sourceSessionId, subjectId)), }); const payload = await response.json().catch(() => null); if (!response.ok) { const code = payload?.code; - if (code === "profile_incomplete") { + if (code === "profile_incomplete" && forOther) { + assignOpenError(OTHER_PROFILE_INCOMPLETE_NOTICE); + } else if (code === "profile_incomplete") { assignOpenError("profile_incomplete"); handleRectificationProfileIncomplete(); } else if (code === "invalid_open_request") { @@ -282,6 +323,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { // Merge the server-created session into the local list. The browser // never generates a Case id; it only mirrors the returned binding. const existing = sessions.find((session) => session.id === opened.sessionId); + // The server says whose case it is; an older server only answers for the owner. + const caseSubjectId = opened.subjectId ?? (intent === "session" ? "self" : subjectId); const openIdentity = rectificationOpenIdentity(existing, { title: resolveSessionTitle("生时校正", undefined, { entrypoint: "birth_time_rectification", @@ -302,7 +345,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { pinned: existing?.pinned ?? false, archivedAt: existing?.archivedAt ?? null, messagesHydrated: true, - ...chartSnapshotForSession("self", chartLibrary, profile), + ...chartSnapshotForSession(caseSubjectId, libraryWith(chartLibrary, caseSubjectId), profile), }; setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); void persistSession(merged).catch(() => {}); @@ -344,6 +387,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { setDraftEntrypoint(null); setRectificationSessionId(opened.sessionId); setRectificationCaseId(opened.caseId); + setRectificationSubjectId(caseSubjectId); setRectificationShouldStartOpening(opened.shouldStartOpening); setRectificationReadonly( opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status), @@ -369,17 +413,13 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { } } - function rectificationLimitedToSelf(): boolean { - if (readCurrentSubjectId() === "self") return false; - notifyError("生时校正暂时只支持本人。"); - setRectificationError("生时校正暂时只支持本人。"); - return true; - } - - /** C4: the offer card under a consultation answer starts (or resumes) the case. */ + /** + * C4: the offer card under a consultation answer starts (or resumes) the + * case, for the person the conversation is about (issue #160: anyone in the + * profile list, not only the owner). + */ async function openRectificationFromChat(sourceSessionId: string) { - if (rectificationLimitedToSelf()) return null; - return openRectificationCase("homepage", null, sourceSessionId); + return openRectificationCase("homepage", null, sourceSessionId, readCurrentSubjectId()); } async function openRectificationSession(exactSessionId: string) { @@ -431,15 +471,15 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { await openRectificationSession(exactSessionId); } - async function startNewRectification() { - if (rectificationLimitedToSelf()) return; - await openRectificationCase("new", null, null); + /** 再校正一次: a fresh case for the same person as the one on screen, else the given one. */ + async function startNewRectification(subjectId?: string) { + const target = subjectId ?? (rectificationCaseId ? rectificationSubjectId : readCurrentSubjectId()); + await openRectificationCase("new", null, null, target); } - /** Chart page link. Homepage resumes a case already open; it does not raise the new-case conflict. */ - async function openChartPageRectification() { - if (rectificationLimitedToSelf()) return; - await openRectificationCase("homepage", null, null); + /** `/?rectify=1` link. Homepage resumes a case already open; it does not raise the new-case conflict. */ + async function openChartPageRectification(subjectId = "self") { + await openRectificationCase("homepage", null, null, subjectId); } function handleRectificationProfileIncomplete() { @@ -482,16 +522,40 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { const search = globalThis.location?.search ?? ""; const intent = rectificationEntryIntent(search); if (!intent) return; + const subjectId = rectificationEntrySubject(search); chartRectifyConsumed.current = true; const url = new URL(globalThis.location.href); url.searchParams.delete(RECTIFICATION_ENTRY_QUERY_KEY); + url.searchParams.delete(RECTIFICATION_SUBJECT_QUERY_KEY); globalThis.history.replaceState(null, "", `${url.pathname}${url.search}`); sessionSelectionSource.current = "user"; queueMicrotask(() => { - void (intent === "new" ? latestNewRectifyStart() : latestChartRectifyStart()); + void (intent === "new" ? latestNewRectifyStart(subjectId) : latestChartRectifyStart(subjectId)); }); }); + /** + * After an adoption or a finished case. The owner's time lives on the + * account; another person's adopted time was written to that person's own + * row (issue #160), so the people list and the chart caches are re-read. + */ + function handleRectificationSaved() { + void refreshAccount(); + if (rectificationSubjectId === "self") return; + void refreshSubjectCatalog().catch(() => undefined); + invalidateChartPage(); + invalidateEphemerisPage(); + } + + // The surface's declared time and date are the case's person's, not always the owner's (issue #160). + const rectificationPerson = rectificationSubjectId === "self" + ? profile + : libraryWith(chartLibrary, rectificationSubjectId).find((record) => record.id === rectificationSubjectId)?.profile ?? profile; + const rectificationDeclaration = { + declaredTime: declaredBirthTime(rectificationPerson), + birthDate: rectificationPerson.date || null, + }; + const rectificationPanel: RectificationChatPanel = { caseId: rectificationCaseId ?? "", sessionId: rectificationSessionId ?? "", @@ -504,7 +568,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { headerSlot: rectificationHeaderSlot, onMessagesChange: handleRectificationMessagesChange, onCompleted: () => { - void refreshAccount(); + handleRectificationSaved(); if (rectificationCaseId && rectificationSessionId) { void refreshRectificationCase(rectificationCaseId, rectificationSessionId); } @@ -519,6 +583,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { rectificationHeaderSlot, setRectificationHeaderSlot, rectificationPanel, + rectificationDeclaration, + handleRectificationSaved, resumeRectificationSession, refreshRectificationEntrySummary, refreshRectificationCase, diff --git a/frontend/src/lib/chart-subject-write.ts b/frontend/src/lib/chart-subject-write.ts index d99c7a0b..c634c18a 100644 --- a/frontend/src/lib/chart-subject-write.ts +++ b/frontend/src/lib/chart-subject-write.ts @@ -61,9 +61,12 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart return { ok: false, error: "不能直接写入排盘采用时间" }; } const statusRaw = text(profile.birthTimeStatus ?? profile.birth_time_status, 40); - if (statusRaw === "accepted" || statusRaw === "confirmed") { - return { ok: false, error: "不能把出生时间标成已采用" }; - } + // Issue #160: an adopted or confirmed status is written only by the + // rectification RPC. A form that echoes it back (editing someone whose time + // was adopted) leaves the stored status alone instead of failing the save; + // a new declaration still drops it in the database + // (chart_profiles_zz_drop_adopted_on_declaration). + const adoptedEcho = statusRaw === "accepted" || statusRaw === "confirmed"; const name = text(profile.name, 80); if (!name) return { ok: false, error: "请填写称呼" }; const birthDate = date(profile.date ?? profile.birth_date); @@ -81,7 +84,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart const timezoneOffset = finite(profile.timezoneOffset ?? profile.timezone_offset); const timezoneId = text(profile.timezoneId ?? profile.timezone_id, 80); const label = text(profile.birthPlaceLabel ?? profile.birth_place_label, 160); - const status = oneOf(statusRaw, STATUSES) ?? (reported ? "reported" : null); + const status = adoptedEcho ? null : oneOf(statusRaw, STATUSES) ?? (reported ? "reported" : null); const ayanamsa = isAyanamsaName(profile.ayanamsa) ? profile.ayanamsa : resolveAyanamsa(profile); // Optional gender: written only when the client sends the key, so a caller // that does not know the field never clears it. null / "" clear it. @@ -115,7 +118,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart birth_place_provider: text(profile.birthPlaceProvider ?? profile.birth_place_provider, 40) || null, birth_place_provider_id: text(profile.birthPlaceProviderId ?? profile.birth_place_provider_id, 120) || null, ayanamsa, - birth_time_status: status, + ...(adoptedEcho ? {} : { birth_time_status: status }), ...(genderSent ? { gender } : {}), ...(relationSent ? { relation } : {}), }; diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts index c3b691df..b6e83972 100644 --- a/frontend/src/lib/chat-session-url.ts +++ b/frontend/src/lib/chat-session-url.ts @@ -2,6 +2,8 @@ export const SESSION_URL_QUERY_KEY = "c"; export const NEW_CHAT_QUERY_KEY = "new"; /** Chart-page ascendant note. Value must be exactly "1". Home opens the self rectification case. */ export const RECTIFICATION_ENTRY_QUERY_KEY = "rectify"; +/** Whose chart the queued open is for: a chart_profiles id. Absent = the account owner (issue #160). */ +export const RECTIFICATION_SUBJECT_QUERY_KEY = "rectifyFor"; /** * Legacy key only. Until BUG-1052 a 401 or a secondary-page link stashed the * current `?c=` here and a bare `/` reopened it. Nothing writes it any more; @@ -43,11 +45,22 @@ export const RECTIFICATION_PAGE_HREF = "/rectification"; /** * Home URL that opens the rectification surface. `homepage` resumes an open - * case or creates one; `new` starts a fresh case ("再校正一次"). The case is - * still the self profile only. + * case or creates one; `new` starts a fresh case ("再校正一次"). `subjectId` + * is the person the case is for; "self" or nothing is the account owner + * (issue #160). */ -export function rectificationEntryHref(intent: "homepage" | "new" = "homepage"): string { - return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=${intent === "new" ? "new" : "1"}`; +export function rectificationEntryHref(intent: "homepage" | "new" = "homepage", subjectId = "self"): string { + const base = `/?${RECTIFICATION_ENTRY_QUERY_KEY}=${intent === "new" ? "new" : "1"}`; + return SESSION_URL_ID_PATTERN.test(subjectId) + ? `${base}&${RECTIFICATION_SUBJECT_QUERY_KEY}=${encodeURIComponent(subjectId)}` + : base; +} + +/** The person a queued `?rectify=` open is for: a chart_profiles id, or "self". */ +export function rectificationEntrySubject(search: string): string { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + const value = params.get(RECTIFICATION_SUBJECT_QUERY_KEY) ?? ""; + return SESSION_URL_ID_PATTERN.test(value) ? value : "self"; } export function rectificationEntryIntent(search: string): "homepage" | "new" | null { diff --git a/frontend/src/lib/contracts/rectification-entry.ts b/frontend/src/lib/contracts/rectification-entry.ts index 813349af..b931b731 100644 --- a/frontend/src/lib/contracts/rectification-entry.ts +++ b/frontend/src/lib/contracts/rectification-entry.ts @@ -1,88 +1,90 @@ -export const RECTIFICATION_CASE_STATUSES = [ - "draft", - "collecting_evidence", - "candidate_ready", - "candidate_accepted", - "needs_rebaseline", - "paused", - "confirmed", - "closed", - "abandoned", - "superseded", -] as const; - -export type RectificationCaseStatus = (typeof RECTIFICATION_CASE_STATUSES)[number]; - -/** Statuses that may be resumed by the user. */ -export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [ - "draft", - "collecting_evidence", - "candidate_ready", - "candidate_accepted", - "needs_rebaseline", - "paused", -]; - -/** Statuses that only allow read-only history access. */ -export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [ - "confirmed", - "closed", - "abandoned", - "superseded", -]; - -export type OpenRectificationDisposition = "created" | "resumed" | "readonly"; - -/** Wire shape. Status stays a string so cached and older payloads still parse. */ -export type OpenRectificationCaseResponseWire = Readonly<{ - disposition: OpenRectificationDisposition; - caseId: string; - sessionId: string; - status: string; - shouldStartOpening: boolean; - skillVersion: string; -}>; - -export type OpenRectificationCaseResponse = Omit & { - readonly status: RectificationCaseStatus; -}; - -/** Range, count and times feed the /rectification status card (issue #145); older payloads omit them. */ -export type RectificationEntryRange = Readonly<{ startTime: string; endTime: string }>; - -type LatestResumableWire = Readonly<{ - caseId: string; - status: string; - lastActivityAt: string; - sessionId?: string | null; - candidateRange?: RectificationEntryRange | null; - acceptedTime?: string | null; - evidenceCount?: number | null; -}>; - -type LatestTerminalWire = Readonly<{ - caseId: string; - status: string; - hasUsableTime: boolean; - sessionId?: string | null; - acceptedTime?: string | null; - confirmedTime?: string | null; -}>; - -export type RectificationEntrySummaryWire = Readonly<{ - hasResumableCase: boolean; - hasTerminalCaseWithTime: boolean; - latestResumable: LatestResumableWire | null; - latestTerminal: LatestTerminalWire | null; -}>; - -export type RectificationEntrySummary = Readonly<{ - hasResumableCase: boolean; - hasTerminalCaseWithTime: boolean; - latestResumable: (Omit & { - readonly status: RectificationCaseStatus; - }) | null; - latestTerminal: (Omit & { - readonly status: RectificationCaseStatus; - }) | null; -}>; +export const RECTIFICATION_CASE_STATUSES = [ + "draft", + "collecting_evidence", + "candidate_ready", + "candidate_accepted", + "needs_rebaseline", + "paused", + "confirmed", + "closed", + "abandoned", + "superseded", +] as const; + +export type RectificationCaseStatus = (typeof RECTIFICATION_CASE_STATUSES)[number]; + +/** Statuses that may be resumed by the user. */ +export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [ + "draft", + "collecting_evidence", + "candidate_ready", + "candidate_accepted", + "needs_rebaseline", + "paused", +]; + +/** Statuses that only allow read-only history access. */ +export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [ + "confirmed", + "closed", + "abandoned", + "superseded", +]; + +export type OpenRectificationDisposition = "created" | "resumed" | "readonly"; + +/** Wire shape. Status stays a string so cached and older payloads still parse. */ +export type OpenRectificationCaseResponseWire = Readonly<{ + disposition: OpenRectificationDisposition; + caseId: string; + sessionId: string; + status: string; + shouldStartOpening: boolean; + skillVersion: string; + /** Whose chart (issue #160): a chart_profiles id, or null for the account owner. Older payloads omit it. */ + subjectId?: string | null; +}>; + +export type OpenRectificationCaseResponse = Omit & { + readonly status: RectificationCaseStatus; +}; + +/** Range, count and times feed the /rectification status card (issue #145); older payloads omit them. */ +export type RectificationEntryRange = Readonly<{ startTime: string; endTime: string }>; + +type LatestResumableWire = Readonly<{ + caseId: string; + status: string; + lastActivityAt: string; + sessionId?: string | null; + candidateRange?: RectificationEntryRange | null; + acceptedTime?: string | null; + evidenceCount?: number | null; +}>; + +type LatestTerminalWire = Readonly<{ + caseId: string; + status: string; + hasUsableTime: boolean; + sessionId?: string | null; + acceptedTime?: string | null; + confirmedTime?: string | null; +}>; + +export type RectificationEntrySummaryWire = Readonly<{ + hasResumableCase: boolean; + hasTerminalCaseWithTime: boolean; + latestResumable: LatestResumableWire | null; + latestTerminal: LatestTerminalWire | null; +}>; + +export type RectificationEntrySummary = Readonly<{ + hasResumableCase: boolean; + hasTerminalCaseWithTime: boolean; + latestResumable: (Omit & { + readonly status: RectificationCaseStatus; + }) | null; + latestTerminal: (Omit & { + readonly status: RectificationCaseStatus; + }) | null; +}>; diff --git a/frontend/src/lib/lagna-precheck.ts b/frontend/src/lib/lagna-precheck.ts index 78bc7439..48d0e7bd 100644 --- a/frontend/src/lib/lagna-precheck.ts +++ b/frontend/src/lib/lagna-precheck.ts @@ -177,14 +177,13 @@ function signList(signs: readonly string[]): string { return signs.map((sign) => `${sign}座`).join("、"); } -/** Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has no link. Someone else's chart never links. */ -export function lagnaPrecheckLine( - value: ChartViewLagnaPrecheck, - audience: { readonly self?: boolean } = {}, -): LagnaPrecheckLine { - const self = audience.self !== false; +/** + * Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has + * no link. Since issue #160 someone else's chart links too: /rectification + * opens for the same current person. + */ +export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckLine { if (value.state === "unknown_time") { - if (!self) return { before: "不知道出生时间时,上升星座定不下来。", linked: false, after: "" }; return { before: "不知道出生时间时,上升星座定不下来;做一次", linked: true, @@ -201,7 +200,6 @@ export function lagnaPrecheckLine( } if (value.signs.length >= 3) { const listed = `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。`; - if (!self) return { before: listed, linked: false, after: "" }; return { before: `${listed}想定下来可以做一次`, linked: true, after: "。" }; } const [first, second] = value.signs; @@ -211,6 +209,5 @@ export function lagnaPrecheckLine( ? `按你给的时间段(${value.periodStart}–${value.periodEnd})` : "按你给的时间精度"; const signs = `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。`; - if (!self) return { before: signs, linked: false, after: "" }; return { before: `${signs}想定下来可以做一次`, linked: true, after: "。" }; } diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts index c5545a8b..ecd7e1c9 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts @@ -126,7 +126,7 @@ export async function mutateCaseForWidenWindow(input: { }); const source = input.dossier.case.birthTimeSource; if (source !== "hospital_record") { - await patchV9BirthUncertainty(input.accounting, input.userId, { + await patchV9BirthUncertainty(input.accounting, input.userId, input.caseId, { birth_time_source: "approximate", uncertainty_before_minutes: chosen.radius, uncertainty_after_minutes: chosen.radius, diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index 0e8229ef..33276523 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -37,6 +37,7 @@ import { stageForClockWindow, } from "./search-window.ts"; import { + openRequestSubjectId, openResponse, type OpenRectificationCaseRequest, type OpenRectificationCaseResponse, @@ -213,18 +214,50 @@ function baselineFingerprint(baseline: V9BaselineSnapshot): string { return createHash("sha256").update(canonical).digest("hex"); } +const OWNER_BIRTH_COLUMNS = + "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa"; +/** chart_profiles has no birth_time_clue column; the rest have the same names (issue #160). */ +const SUBJECT_BIRTH_COLUMNS = + "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa"; + +async function readBirthRow( + accounting: AccountingClient, + userId: string, + subjectId: string | null, +): Promise { + if (!subjectId) { + const { data, error } = await accounting + .from("profiles") + .select(OWNER_BIRTH_COLUMNS) + .eq("id", userId) + .single(); + if (error || !data) throw new RectificationCaseServiceError("profile_unavailable"); + return data; + } + // Another person in the account's list. The owner check is in the query and + // again in open_agentic_rectification_case_v3. + const { data, error } = await accounting + .from("chart_profiles") + .select(SUBJECT_BIRTH_COLUMNS) + .eq("id", subjectId) + .eq("user_id", userId) + .eq("role", "other") + .maybeSingle(); + if (error) throw new RectificationCaseServiceError("profile_unavailable"); + if (!data) throw new RectificationCaseServiceError("subject_not_found"); + return data; +} + +/** + * Baseline for a new case. `subjectId` null reads the account owner + * (public.profiles); otherwise that chart_profiles row of the same user. + */ export async function loadV9RectificationProfile( accounting: AccountingClient, userId: string, + subjectId: string | null = null, ): Promise { - const { data, error } = await accounting - .from("profiles") - .select( - "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa", - ) - .eq("id", userId) - .single(); - if (error || !data) throw new RectificationCaseServiceError("profile_unavailable"); + const data = await readBirthRow(accounting, userId, subjectId); let resolvedData: unknown; try { @@ -308,6 +341,8 @@ const KNOWN_RPC_ERROR_CODES = new Map { let profile: V9RectificationProfile | null = null; + const subjectId = openRequestSubjectId(request); if (request.intent === "homepage" || request.intent === "new") { - profile = await loadV9RectificationProfile(accounting, userId); + profile = await loadV9RectificationProfile(accounting, userId, subjectId); } const activeSkill = resolveActiveSkillPackage(RECTIFICATION_SKILL_NAME); @@ -452,7 +488,7 @@ export async function openRectificationCase( const skill = request.intent === "session" ? await resolveBoundSkillForSessionOpen(accounting, userId, request, activeSkill) : activeSkill; - const { data, error } = await accounting.rpc("open_agentic_rectification_case_v2", { + const args = { p_user_id: userId, p_request_id: request.requestId, p_intent: request.intent, @@ -465,7 +501,18 @@ export async function openRectificationCase( p_baseline_birth_snapshot: profile?.baseline ?? {}, p_candidate_range: profile?.candidateRange ?? { start_time: "00:00", end_time: "23:59" }, ...(request.intent !== "session" ? { p_product_domain: productDomain } : {}), - }); + }; + // Issue #160. Creating for another person goes through v3, which checks the + // person and binds the new case and its conversation to them. Reopening an + // exact session also goes through v3 with the same arguments as before (no + // domain, no person), so the answer says whose case it is. Creating for the + // owner keeps the previous call. + const { data, error } = subjectId || request.intent === "session" + ? await accounting.rpc("open_agentic_rectification_case_v3", { + ...args, + ...(subjectId ? { p_chart_profile_id: subjectId } : {}), + }) + : await accounting.rpc("open_agentic_rectification_case_v2", args); if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); const response = openResponse(readRpcData(data)); if (!response) throw new RectificationCaseServiceError("invalid_open_response"); @@ -480,13 +527,20 @@ export async function openRectificationCase( return response; } +/** One person's cases (issue #160, 1A): `subjectId` null is the account owner. */ export async function getRectificationEntrySummary( accounting: AccountingClient, userId: string, + subjectId: string | null = null, ): Promise { - const { data, error } = await accounting.rpc("get_agentic_rectification_entry_summary", { - p_user_id: userId, - }); + const { data, error } = subjectId + ? await accounting.rpc("get_agentic_rectification_entry_summary_v2", { + p_user_id: userId, + p_chart_profile_id: subjectId, + }) + : await accounting.rpc("get_agentic_rectification_entry_summary", { + p_user_id: userId, + }); if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); const row = readRpcData(data); if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_entry_summary"); diff --git a/frontend/src/lib/rectification-agentic/v9/open-request.ts b/frontend/src/lib/rectification-agentic/v9/open-request.ts index d984e59d..70ac9303 100644 --- a/frontend/src/lib/rectification-agentic/v9/open-request.ts +++ b/frontend/src/lib/rectification-agentic/v9/open-request.ts @@ -2,8 +2,9 @@ * V9 case open request/response contracts. * * The browser submits only an intent + idempotency key (+ exact sessionId for - * the session intent, or an owned sourceSessionId for a consultation handoff). - * The server owns profile normalization, baseline + * the session intent, or an owned sourceSessionId for a consultation handoff, + * or the chart_profiles id of the person when it is not the account owner, + * issue #160). The server owns profile normalization, baseline * snapshot/fingerprint generation, candidate range derivation and every * disposition decision. */ @@ -40,6 +41,7 @@ export const openRectificationCaseRequestSchema = z.discriminatedUnion( intent: z.literal("homepage"), requestId: z.string().uuid(), sourceSessionId: z.string().uuid().optional(), + subjectId: z.string().uuid().optional(), }) .strict(), z @@ -54,6 +56,7 @@ export const openRectificationCaseRequestSchema = z.discriminatedUnion( intent: z.literal("new"), requestId: z.string().uuid(), supersedeActive: z.literal(false).optional(), + subjectId: z.string().uuid().optional(), }) .strict(), ], @@ -98,6 +101,11 @@ export function shouldStartOpening( return turnCount === 0; } +/** The person a create request is for: a chart_profiles id, or null for the account owner. */ +export function openRequestSubjectId(request: OpenRectificationCaseRequest): string | null { + return request.intent === "session" ? null : request.subjectId ?? null; +} + export function openResponse( value: unknown, ): OpenRectificationCaseResponse | null { @@ -123,6 +131,7 @@ export function openResponse( status: row.status, shouldStartOpening: row.should_start_opening === true, skillVersion, + subjectId: typeof row.chart_profile_id === "string" && row.chart_profile_id ? row.chart_profile_id : null, }; } diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 92a4e5ff..25ab5fce 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -1681,29 +1681,27 @@ export async function setV9EvidenceDateReliability( }); } +/** + * Widening the window also widens the declared uncertainty of the person the + * case belongs to: the owner's profile, or that chart_profiles row (issue + * #160). The server picks the row from the case, never from the caller. + */ export async function patchV9BirthUncertainty( - accounting: AccountingClient & { - from?: (table: string) => { - update: (values: Record) => { - eq: (column: string, value: string) => PromiseLike<{ error: { message: string } | null }>; - }; - }; - }, + accounting: AccountingClient, userId: string, + caseId: string, patch: { birth_time_source: "approximate"; uncertainty_before_minutes: number; uncertainty_after_minutes: number; }, ): Promise { - if (typeof accounting.from !== "function") { - throw new RectificationToolServiceError("profile_patch_unavailable"); - } - const result = await accounting.from("profiles").update({ - birth_time_source: patch.birth_time_source, - uncertainty_before_minutes: patch.uncertainty_before_minutes, - uncertainty_after_minutes: patch.uncertainty_after_minutes, - }).eq("id", userId); + const result = await accounting.rpc("patch_agentic_rectification_case_uncertainty_v1", { + p_user_id: userId, + p_case_id: caseId, + p_before_minutes: patch.uncertainty_before_minutes, + p_after_minutes: patch.uncertainty_after_minutes, + }); if (result.error) throw new RectificationToolServiceError(result.error.message); } diff --git a/frontend/src/lib/rectification-entry.ts b/frontend/src/lib/rectification-entry.ts index 53b21940..25d75502 100644 --- a/frontend/src/lib/rectification-entry.ts +++ b/frontend/src/lib/rectification-entry.ts @@ -115,22 +115,36 @@ export function openResponseFromPayload(value: unknown): OpenRectificationCaseRe status, shouldStartOpening: row.shouldStartOpening === true, skillVersion: typeof row.skillVersion === "string" ? row.skillVersion : "", + subjectId: typeof row.subjectId === "string" && row.subjectId ? row.subjectId : null, }; } export type RectificationEntryOpenIntent = "homepage" | "session" | "new"; -/** Build the server open request body; the browser never adds business state. */ +const CHART_SUBJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Build the server open request body; the browser never adds business state. + * `subjectId` names the person (a chart_profiles id); "self" sends nothing, + * so the owner's request is the same body as before issue #160. The server + * still reads that person's birth data itself. + */ export function openRectificationRequestBody( intent: RectificationEntryOpenIntent, sessionId: string | null, sourceSessionId: string | null = null, -): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string; sourceSessionId?: string } { + subjectId = "self", +): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string; sourceSessionId?: string; subjectId?: string } { const requestId = globalThis.crypto?.randomUUID?.() ?? fallbackUuid(); if (intent === "session" && sessionId) { return { intent, requestId, sessionId }; } - return { intent, requestId, ...(intent === "homepage" && sourceSessionId ? { sourceSessionId } : {}) }; + return { + intent, + requestId, + ...(intent === "homepage" && sourceSessionId ? { sourceSessionId } : {}), + ...(CHART_SUBJECT_ID.test(subjectId) ? { subjectId } : {}), + }; } function fallbackUuid(): string { diff --git a/frontend/src/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts index db275422..c4df10ea 100644 --- a/frontend/src/lib/rectification-in-chat-server.ts +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -26,18 +26,21 @@ import { * admin client. */ -/** C3: whether the offer card may be shown, decided by the server and never by the model. */ +/** + * C3: whether the offer card may be shown, decided by the server and never by + * the model. `subjectId` is the chart_profiles id of the person this + * conversation is about, or null for the account owner (issue #160). + */ export async function decideRectificationOffer(input: { accounting: DataClient; userId: string; - subjectRole: "self" | "other"; + subjectId: string | null; modelId: string; }): Promise { return decideRectificationOfferWith({ - subjectRole: input.subjectRole, productEnabled: () => isProductEnabled("rectification"), hasResumableCase: async () => { - const summary = await getRectificationEntrySummary(input.accounting, input.userId); + const summary = await getRectificationEntrySummary(input.accounting, input.userId, input.subjectId); return blocksNewRectification(summary.latestResumable?.status ?? ""); }, creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost, diff --git a/frontend/src/lib/rectification-in-chat.ts b/frontend/src/lib/rectification-in-chat.ts index 22a887ac..c4f1cedd 100644 --- a/frontend/src/lib/rectification-in-chat.ts +++ b/frontend/src/lib/rectification-in-chat.ts @@ -62,7 +62,7 @@ export function parseRectificationReturnRecord(value: unknown): RectificationRet /** 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" }>; + | Readonly<{ ok: false; reason: "product_disabled" | "case_in_progress" | "pricing_unavailable" }>; // ---- copy (VOICE.md「生时校正并入对话」) ---- @@ -84,7 +84,6 @@ export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间"; 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 @@ -266,17 +265,16 @@ export function lastBirthTimeChangeIndex(messages: readonly unknown[]): number { /** * 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. + * product switch, entry summary and feature pricing). Since issue #160 any + * person in the account's list can be rectified, so the server reads that + * person's own open case; another person's open case never blocks this one. */ export async function decideRectificationOfferWith(input: { - subjectRole: "self" | "other"; productEnabled: () => Promise; hasResumableCase: () => Promise; creditCost: () => Promise; questionEstimate?: number; }): Promise { - 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. diff --git a/frontend/src/lib/rectification-page.ts b/frontend/src/lib/rectification-page.ts index dee140c1..f71d691b 100644 --- a/frontend/src/lib/rectification-page.ts +++ b/frontend/src/lib/rectification-page.ts @@ -1,10 +1,13 @@ /** * The standalone /rectification page (issue #145). Which card it shows comes * from the server's entry summary and the account; the page never guesses - * from session lists. Self profile only (other people: issue #160). + * from session lists. Since issue #160 the page is for the current person + * (the switcher beside the title): the owner or anyone in the profile list, + * each with their own open case (product 2026-11-01, 1A). */ import { rectificationEntryHref, sessionHref } from "./chat-session-url.ts"; +import { personRelationLabel } from "./person-relation.ts"; import { blocksNewRectification } from "./rectification-in-chat.ts"; import type { RectificationEntrySummary } from "./rectification-entry.ts"; @@ -12,6 +15,7 @@ export type RectificationPageState = | { readonly kind: "signed-out" } | { readonly kind: "unavailable" } | { readonly kind: "self-missing" } + | { readonly kind: "other-missing" } | { readonly kind: "none" } | { readonly kind: "active"; @@ -56,8 +60,10 @@ export const RECTIFICATION_PAGE_COPY = { signIn: "去登录", unavailable: "生时校正这会儿还做不了。", selfMissing: "先把你自己的出生日期、时间和地点填好,才能做生时校正。", + otherMissing: "先把 TA 的出生日期、时间和地点填好,才能做生时校正。", fillSelf: "去填资料", currentTime: "你现在用的时间", + otherCurrentTime: "TA 现在用的时间", start: "开始", activeLabel: "进行中", windowLabel: "时间窗", @@ -69,6 +75,9 @@ export const RECTIFICATION_PAGE_COPY = { again: "再校正一次", back: "返回", checkTitle: "先核对一下资料", + personName: "称呼", + personRelation: "关系", + selfRelation: "本人", birthDate: "出生日期", birthTime: "出生时间", birthPlace: "出生城市", @@ -90,12 +99,16 @@ export function rectificationPageState(input: { readonly signedOut: boolean; /** `account.rectification`; missing means the flag did not arrive, so the page stays closed. */ readonly enabled: boolean | undefined; - readonly selfComplete: boolean; + /** The current person's birth date, time and place are filled in. */ + readonly profileComplete: boolean; + /** The current person is someone in the profile list, not the account owner. */ + readonly forOther?: boolean; + /** That person's own cases only; another person's open case never shows here. */ readonly summary: RectificationEntrySummary; }): RectificationPageState { if (input.signedOut) return { kind: "signed-out" }; if (input.enabled !== true) return { kind: "unavailable" }; - if (!input.selfComplete) return { kind: "self-missing" }; + if (!input.profileComplete) return { kind: input.forOther ? "other-missing" : "self-missing" }; const open = input.summary.latestResumable; if (open && blocksNewRectification(open.status)) { @@ -122,15 +135,39 @@ export function rectificationPageState(input: { return { kind: "none" }; } -/** Where 开始校正 goes: a fresh case after a finished one, otherwise the homepage opener. */ -export function rectificationStartHref(state: RectificationPageState): string { - return rectificationEntryHref(state.kind === "done" ? "new" : "homepage"); +/** + * Where 开始校正 goes: a fresh case after a finished one, otherwise the + * homepage opener, both for `subjectId` ("self" = the account owner). + */ +export function rectificationStartHref(state: RectificationPageState, subjectId = "self"): string { + return rectificationEntryHref(state.kind === "done" ? "new" : "homepage", subjectId); } /** * 继续 / 查看结果: the case's own conversation. Without a session id (an older * server) the homepage opener still resumes the open case. */ -export function rectificationSessionHref(sessionId: string | null): string { - return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage"); +export function rectificationSessionHref(sessionId: string | null, subjectId = "self"): string { + return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage", subjectId); +} + +/** One person's cases (issue #160): the owner reads the bare path, anyone else adds `?subject=`. */ +export function rectificationEntrySummaryPath(subjectId: string): string { + const base = "/api/rectification/cases/entry-summary"; + return subjectId === "self" ? base : `${base}?subject=${encodeURIComponent(subjectId)}`; +} + +/** Where 改这份 / 去填资料 go for the current person. */ +export function rectificationProfileHref(subjectId: string): string { + return subjectId === "self" ? "/people?self=1" : `/people?edit=${encodeURIComponent(subjectId)}`; +} + +/** 称呼 and 关系 of the current person (the 核对 step and the status card). */ +export function rectificationPersonFacts( + subjectId: string, + profile: { readonly name: string; readonly relation?: unknown } | null, +): { readonly name: string; readonly relation: string } { + const self = subjectId === "self"; + const name = profile?.name.trim() || (self ? "我" : "未命名资料"); + return { name, relation: self ? RECTIFICATION_PAGE_COPY.selfRelation : personRelationLabel(profile?.relation) }; } diff --git a/frontend/src/lib/rectification-resume-session.ts b/frontend/src/lib/rectification-resume-session.ts index 1c84951a..27dcb43b 100644 --- a/frontend/src/lib/rectification-resume-session.ts +++ b/frontend/src/lib/rectification-resume-session.ts @@ -27,15 +27,17 @@ function safeCategory(value: string): string { } /** - * Session of the latest case that still blocks a new chat entry. + * Session of the latest case that still blocks a new chat entry, for one + * person (`subjectId` null = the account owner, issue #160). * Accepted cases do not block. The read goes through the case RPC: the * service role cannot select the cases table. */ export async function loadResumableRectificationSessionId( accounting: DataClient, userId: string, + subjectId: string | null = null, ): Promise { - const summary = await getRectificationEntrySummary(accounting, userId); + const summary = await getRectificationEntrySummary(accounting, userId, subjectId); const latest = summary.latestResumable; if (!latest?.caseId || !blocksNewRectification(latest.status)) return null; try { diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 965a0dcf..78b30f6c 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -1,6 +1,5 @@ import { RECTIFICATION_DIRECT_NEW_COPY, - RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_OFFER_TOOL_ID, @@ -1156,7 +1155,6 @@ const evidenceLookupInputSchema = z.object({ }).strict(); const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly["reason"], string>> = { - not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`, product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`, case_in_progress: "用户已经有一次没做完的生时校正。这次不出提议卡,正文照常回答这一轮的问题。", pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`, @@ -1258,9 +1256,7 @@ async function finishExplicitRectificationRequest( note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`, }; } - const sentence = decision.reason === "not_self" - ? RECTIFICATION_DIRECT_NOT_SELF_COPY - : RECTIFICATION_DIRECT_UNAVAILABLE_COPY; + const sentence = RECTIFICATION_DIRECT_UNAVAILABLE_COPY; state.rectificationDirectEntry = { kind: "refused", sentence, diff --git a/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql b/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql new file mode 100644 index 00000000..034d4998 --- /dev/null +++ b/frontend/supabase/migrations/20261101010000_rectification_chart_subject.sql @@ -0,0 +1,1783 @@ +-- Issue #160 (product 2026-11-01: 1A 2A). Birth-time rectification for the +-- other people in an account's profile list. +-- +-- Business schema only (frontend/supabase/migrations; never duplicated into +-- frontend/db/migrations, BUG-127 / BUG-144). +-- +-- * A case records whose chart it is: chart_profile_id null = the account +-- owner (public.profiles), otherwise one public.chart_profiles row of the +-- same user. Add-only: every existing case keeps null, i.e. stays the owner. +-- * Adoption and confirmation write the minute back to that person only. The +-- owner's profile is never touched by another person's case, and the +-- owner's profile edits no longer rebaseline other people's cases. +-- * The entry summary is read per person (1A: one open case per person, and +-- several people may each have one at the same time). +-- * Deleting a person deletes that person's cases with their conversations +-- (2A). Points already charged are not refunded, same as deleting a chat. +-- +-- The adoption RPCs below are the previous definitions (20260920020000, +-- 20261001010000, 20260827020000) unchanged except that the birth row is read +-- and written through agentic_rectification_case_birth_row / +-- agentic_rectification_write_case_birth. For an owner case those two helpers +-- read and write exactly the columns the old bodies did. + +begin; + +-- --------------------------------------------------------------------------- +-- 1. Columns +-- --------------------------------------------------------------------------- + +alter table public.agentic_rectification_cases + add column if not exists chart_profile_id uuid + references public.chart_profiles(id) on delete cascade; + +create index if not exists agentic_rectification_cases_subject_activity_idx + on public.agentic_rectification_cases (user_id, chart_profile_id, last_activity_at desc); + +-- Same meaning as profiles.active_birth_provenance (dated / segment adoption). +alter table public.chart_profiles + add column if not exists active_birth_provenance jsonb; + +-- --------------------------------------------------------------------------- +-- 2. Birth row of the person a case belongs to +-- --------------------------------------------------------------------------- + +-- Returns the row in public.profiles shape. For another person only the birth +-- and adoption columns are filled (id = the account owner, so callers keep +-- their "not found" check on id). Declared window clocks are rendered as +-- HH24:MI, the same text the baseline snapshot stores. +create or replace function public.agentic_rectification_case_birth_row( + p_case public.agentic_rectification_cases, + p_lock boolean +) +returns public.profiles +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_row public.profiles%rowtype; +begin + if p_case.id is null or p_case.user_id is null then + return v_row; + end if; + if p_case.chart_profile_id is null then + if p_lock then + select * into v_row from public.profiles where id = p_case.user_id for update; + else + select * into v_row from public.profiles where id = p_case.user_id; + end if; + return v_row; + end if; + + -- One composite value; a missing or foreign person leaves every field null. + if p_lock then + v_row := ( + select pg_catalog.jsonb_populate_record(null::public.profiles, pg_catalog.jsonb_build_object( + 'id', p_case.user_id, + 'birth_date', s.birth_date, + 'reported_birth_time', s.reported_birth_time, + 'birth_time_source', s.birth_time_source, + 'birth_time_period', s.birth_time_period, + 'declared_window_start', pg_catalog.to_char(s.declared_window_start, 'HH24:MI'), + 'declared_window_end', pg_catalog.to_char(s.declared_window_end, 'HH24:MI'), + 'uncertainty_before_minutes', s.uncertainty_before_minutes, + 'uncertainty_after_minutes', s.uncertainty_after_minutes, + 'latitude', s.latitude, + 'longitude', s.longitude, + 'timezone_id', s.timezone_id, + 'timezone_offset', s.timezone_offset, + 'active_birth_time', s.active_birth_time, + 'active_birth_date', s.active_birth_date, + 'active_birth_timezone_offset', s.active_birth_timezone_offset, + 'active_birth_provenance', s.active_birth_provenance, + 'birth_time_status', s.birth_time_status, + 'birth_time', s.active_birth_time + )) + from public.chart_profiles s + where s.id = p_case.chart_profile_id and s.user_id = p_case.user_id and s.role = 'other' + for update + ); + else + v_row := ( + select pg_catalog.jsonb_populate_record(null::public.profiles, pg_catalog.jsonb_build_object( + 'id', p_case.user_id, + 'birth_date', s.birth_date, + 'reported_birth_time', s.reported_birth_time, + 'birth_time_source', s.birth_time_source, + 'birth_time_period', s.birth_time_period, + 'declared_window_start', pg_catalog.to_char(s.declared_window_start, 'HH24:MI'), + 'declared_window_end', pg_catalog.to_char(s.declared_window_end, 'HH24:MI'), + 'uncertainty_before_minutes', s.uncertainty_before_minutes, + 'uncertainty_after_minutes', s.uncertainty_after_minutes, + 'latitude', s.latitude, + 'longitude', s.longitude, + 'timezone_id', s.timezone_id, + 'timezone_offset', s.timezone_offset, + 'active_birth_time', s.active_birth_time, + 'active_birth_date', s.active_birth_date, + 'active_birth_timezone_offset', s.active_birth_timezone_offset, + 'active_birth_provenance', s.active_birth_provenance, + 'birth_time_status', s.birth_time_status, + 'birth_time', s.active_birth_time + )) + from public.chart_profiles s + where s.id = p_case.chart_profile_id and s.user_id = p_case.user_id and s.role = 'other' + ); + end if; + return v_row; +end; +$$; + +-- The one write of an adopted / confirmed minute. p_status and p_confidence +-- null keep the stored value (segment adoption re-writes the minute only). +-- Another person's row is written under a transaction-local marker that +-- chart_profiles_guard_birth accepts only inside this definer function. +create or replace function public.agentic_rectification_write_case_birth( + p_case public.agentic_rectification_cases, + p_time time without time zone, + p_date date, + p_offset double precision, + p_provenance jsonb, + p_status text, + p_confidence numeric +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +begin + if p_status is not null and p_status not in ('accepted', 'confirmed') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if p_case.chart_profile_id is null then + update public.profiles + set active_birth_time = p_time, + active_birth_date = p_date, + active_birth_timezone_offset = p_offset, + active_birth_provenance = p_provenance, + birth_time = p_time, + birth_time_status = coalesce(p_status, birth_time_status), + rectification_confidence = coalesce(p_confidence, rectification_confidence), + updated_at = pg_catalog.now() + where id = p_case.user_id; + return; + end if; + + perform pg_catalog.set_config('jyotisha.rectification_subject_write', p_case.id::text, true); + update public.chart_profiles + set active_birth_time = p_time, + active_birth_date = p_date, + active_birth_timezone_offset = p_offset, + active_birth_provenance = p_provenance, + birth_time_status = coalesce(p_status, birth_time_status), + updated_at = pg_catalog.now() + where id = p_case.chart_profile_id and user_id = p_case.user_id and role = 'other'; + if not found then + perform pg_catalog.set_config('jyotisha.rectification_subject_write', '', true); + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + perform pg_catalog.set_config('jyotisha.rectification_subject_write', '', true); +end; +$$; + +revoke all on function public.agentic_rectification_case_birth_row(public.agentic_rectification_cases, boolean) + from public, anon, authenticated, service_role; +revoke all on function public.agentic_rectification_write_case_birth( + public.agentic_rectification_cases, time without time zone, date, double precision, jsonb, text, numeric +) from public, anon, authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- 3. chart_profiles guards +-- --------------------------------------------------------------------------- + +-- Clients still cannot write an adopted minute or an accepted/confirmed status +-- (column grants + this guard). Two writes are now allowed: the adoption RPC +-- above, and clearing the adopted minute (all three columns back to null), +-- which chart_profiles_zz_drop_adopted_on_declaration does after an edit. +create or replace function public.chart_profiles_guard_birth() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + v_trusted boolean := coalesce(pg_catalog.current_setting('jyotisha.rectification_subject_write', true), '') <> '' + and current_user not in ('anon', 'authenticated'); +begin + if tg_op = 'UPDATE' + and old.reported_birth_time is not null + and new.reported_birth_time is distinct from old.reported_birth_time then + raise exception 'reported_birth_time_is_immutable'; + end if; + + if tg_op = 'INSERT' then + if new.active_birth_time is not null + or new.active_birth_date is not null + or new.active_birth_timezone_offset is not null then + raise exception 'chart_subject_active_birth_is_locked'; + end if; + if new.birth_time_status in ('accepted', 'confirmed') then + raise exception 'chart_subject_birth_status_locked'; + end if; + elsif not v_trusted then + if (new.active_birth_time is distinct from old.active_birth_time + or new.active_birth_date is distinct from old.active_birth_date + or new.active_birth_timezone_offset is distinct from old.active_birth_timezone_offset) + and (new.active_birth_time is not null + or new.active_birth_date is not null + or new.active_birth_timezone_offset is not null) then + raise exception 'chart_subject_active_birth_is_locked'; + end if; + if new.birth_time_status is distinct from old.birth_time_status + and new.birth_time_status in ('accepted', 'confirmed') then + raise exception 'chart_subject_birth_status_locked'; + end if; + end if; + return new; +end; +$$; + +-- Same rule as the owner (account-profile-patch.ts, product 2026-09-29): an +-- adopted or confirmed minute survives every edit except a new declaration. +-- Runs last among the BEFORE triggers (zz_), after the guard has checked what +-- the client sent. +create or replace function public.chart_profiles_drop_adopted_on_declaration() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if old.birth_date is distinct from new.birth_date + or old.reported_birth_time is distinct from new.reported_birth_time + or old.birth_time_source is distinct from new.birth_time_source + or old.birth_time_period is distinct from new.birth_time_period + or old.declared_window_start is distinct from new.declared_window_start + or old.declared_window_end is distinct from new.declared_window_end + or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes + or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes + or old.latitude is distinct from new.latitude + or old.longitude is distinct from new.longitude + or old.timezone_id is distinct from new.timezone_id + or old.timezone_offset is distinct from new.timezone_offset then + new.active_birth_time := null; + new.active_birth_date := null; + new.active_birth_timezone_offset := null; + new.active_birth_provenance := null; + if new.birth_time_status in ('accepted', 'confirmed') then + new.birth_time_status := case when new.reported_birth_time is null then null else 'reported' end; + end if; + end if; + return new; +end; +$$; + +drop trigger if exists chart_profiles_zz_drop_adopted_on_declaration on public.chart_profiles; +create trigger chart_profiles_zz_drop_adopted_on_declaration +before update on public.chart_profiles +for each row execute function public.chart_profiles_drop_adopted_on_declaration(); + +revoke all on function public.chart_profiles_drop_adopted_on_declaration() from public; + +-- --------------------------------------------------------------------------- +-- 4. Declaration changes rebaseline only the cases of that person +-- --------------------------------------------------------------------------- + +create or replace function public.agentic_rectification_profiles_rebaseline_guard() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if old.birth_date is distinct from new.birth_date + or old.reported_birth_time is distinct from new.reported_birth_time + or old.birth_time_source is distinct from new.birth_time_source + or old.birth_time_period is distinct from new.birth_time_period + or old.declared_window_start is distinct from new.declared_window_start + or old.declared_window_end is distinct from new.declared_window_end + or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes + or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes + or old.latitude is distinct from new.latitude + or old.longitude is distinct from new.longitude + or old.timezone_id is distinct from new.timezone_id + or old.timezone_offset is distinct from new.timezone_offset then + update public.agentic_rectification_cases + set status = 'needs_rebaseline', + updated_at = pg_catalog.now(), + last_activity_at = pg_catalog.now() + where user_id = new.id + and chart_profile_id is null + and status = any (public.agentic_rectification_resumable_statuses()); + end if; + return new; +end; +$$; + +create or replace function public.invalidate_agentic_rectification_results_on_profile_change() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if old.birth_date is distinct from new.birth_date + or old.reported_birth_time is distinct from new.reported_birth_time + or old.birth_time_source is distinct from new.birth_time_source + or old.birth_time_period is distinct from new.birth_time_period + or old.declared_window_start is distinct from new.declared_window_start + or old.declared_window_end is distinct from new.declared_window_end + or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes + or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes + or old.latitude is distinct from new.latitude + or old.longitude is distinct from new.longitude + or old.timezone_id is distinct from new.timezone_id + or old.timezone_offset is distinct from new.timezone_offset then + update public.agentic_rectification_results result + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where result.user_id = new.id + and result.invalidated_at is null + and not exists ( + select 1 from public.agentic_rectification_cases c + where c.id = result.case_id and c.chart_profile_id is not null + ); + end if; + return new; +end; +$$; + +create or replace function public.agentic_rectification_chart_profiles_rebaseline() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if old.birth_date is distinct from new.birth_date + or old.reported_birth_time is distinct from new.reported_birth_time + or old.birth_time_source is distinct from new.birth_time_source + or old.birth_time_period is distinct from new.birth_time_period + or old.declared_window_start is distinct from new.declared_window_start + or old.declared_window_end is distinct from new.declared_window_end + or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes + or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes + or old.latitude is distinct from new.latitude + or old.longitude is distinct from new.longitude + or old.timezone_id is distinct from new.timezone_id + or old.timezone_offset is distinct from new.timezone_offset then + update public.agentic_rectification_results result + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where result.user_id = new.user_id + and result.invalidated_at is null + and exists ( + select 1 from public.agentic_rectification_cases c + where c.id = result.case_id and c.chart_profile_id = new.id + ); + update public.agentic_rectification_cases + set status = 'needs_rebaseline', + updated_at = pg_catalog.now(), + last_activity_at = pg_catalog.now() + where user_id = new.user_id + and chart_profile_id = new.id + and status = any (public.agentic_rectification_resumable_statuses()); + end if; + return new; +end; +$$; + +drop trigger if exists agentic_rectification_chart_profiles_rebaseline_trigger on public.chart_profiles; +create trigger agentic_rectification_chart_profiles_rebaseline_trigger +after update on public.chart_profiles +for each row execute function public.agentic_rectification_chart_profiles_rebaseline(); + +revoke all on function public.agentic_rectification_profiles_rebaseline_guard() from public, anon, authenticated; +revoke all on function public.invalidate_agentic_rectification_results_on_profile_change() from public, anon, authenticated; +revoke all on function public.agentic_rectification_chart_profiles_rebaseline() from public, anon, authenticated; + +-- --------------------------------------------------------------------------- +-- 5. Open a case for a person +-- --------------------------------------------------------------------------- + +-- Wraps open_agentic_rectification_case_v2. p_product_domain null (or left +-- out) = the 11-argument form: the session intent passes no domain, exactly +-- as before. p_chart_profile_id null (or left out) = the owner. The person is +-- checked here, against the same user, before anything is created; a fresh +-- case and its conversation are then bound to that person. A replayed +-- requestId or an exact session must already belong to the same person. +create or replace function public.open_agentic_rectification_case_v3( + p_user_id uuid, p_request_id uuid, p_intent text, p_session_id uuid, + p_skill_name text, p_skill_version text, p_skill_sha256 text, p_skill_source_commit text, + p_baseline_profile_fingerprint text, p_baseline_birth_snapshot jsonb, p_candidate_range jsonb, + p_product_domain text default null, p_chart_profile_id uuid default null +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_result jsonb; + v_case public.agentic_rectification_cases%rowtype; + v_subject public.chart_profiles%rowtype; +begin + if p_user_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if p_chart_profile_id is not null then + select * into v_subject + from public.chart_profiles + where id = p_chart_profile_id and user_id = p_user_id and role = 'other'; + if not found then + raise exception 'agentic_rectification_subject_not_found' using errcode = 'P0001'; + end if; + end if; + + if p_product_domain is null then + v_result := public.open_agentic_rectification_case_v2(p_user_id, p_request_id, p_intent, p_session_id, + p_skill_name, p_skill_version, p_skill_sha256, p_skill_source_commit, + p_baseline_profile_fingerprint, p_baseline_birth_snapshot, p_candidate_range); + else + v_result := public.open_agentic_rectification_case_v2(p_user_id, p_request_id, p_intent, p_session_id, + p_skill_name, p_skill_version, p_skill_sha256, p_skill_source_commit, + p_baseline_profile_fingerprint, p_baseline_birth_snapshot, p_candidate_range, p_product_domain); + end if; + + select * into v_case + from public.agentic_rectification_cases + where id = (v_result ->> 'case_id')::uuid and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + if v_result ->> 'disposition' = 'created' and p_chart_profile_id is not null + and v_case.chart_profile_id is null + and not exists (select 1 from public.agentic_rectification_turns where case_id = v_case.id) then + update public.agentic_rectification_cases + set chart_profile_id = p_chart_profile_id + where id = v_case.id + returning * into v_case; + update public.chat_sessions + set chart_profile_id = p_chart_profile_id::text, + chart_profile_name = coalesce(nullif(pg_catalog.btrim(v_subject.name), ''), chart_profile_name), + chart_profile_role = 'other' + where id = v_case.session_id and user_id = p_user_id; + end if; + + if p_intent <> 'session' and v_case.chart_profile_id is distinct from p_chart_profile_id then + raise exception 'agentic_rectification_subject_mismatch' using errcode = 'P0001'; + end if; + + return v_result || jsonb_build_object('chart_profile_id', v_case.chart_profile_id); +end; +$$; + +revoke all on function public.open_agentic_rectification_case_v3( + uuid, uuid, text, uuid, text, text, text, text, text, jsonb, jsonb, text, uuid +) from public, anon, authenticated; +grant execute on function public.open_agentic_rectification_case_v3( + uuid, uuid, text, uuid, text, text, text, text, text, jsonb, jsonb, text, uuid +) to service_role; + +-- --------------------------------------------------------------------------- +-- 6. Entry summary per person +-- --------------------------------------------------------------------------- + +-- Same body as 20261028170000, filtered to one person (null = the owner). +create or replace function public.get_agentic_rectification_entry_summary_v2( + p_user_id uuid, + p_chart_profile_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_resumable public.agentic_rectification_cases%rowtype; + v_terminal public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + select * into v_resumable + from public.agentic_rectification_cases + where user_id = p_user_id + and chart_profile_id is not distinct from p_chart_profile_id + and status = any (public.agentic_rectification_resumable_statuses()) + order by last_activity_at desc, id + limit 1; + + select * into v_terminal + from public.agentic_rectification_cases + where user_id = p_user_id + and chart_profile_id is not distinct from p_chart_profile_id + and status in ('confirmed', 'closed', 'abandoned', 'superseded') + order by last_activity_at desc, id + limit 1; + + return jsonb_build_object( + 'chart_profile_id', p_chart_profile_id, + 'has_resumable_case', v_resumable.id is not null, + 'has_terminal_case_with_time', v_terminal.id is not null + and (v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null), + 'latest_resumable', case + when v_resumable.id is null then null + else jsonb_build_object( + 'case_id', v_resumable.id, + 'status', v_resumable.status, + 'last_activity_at', v_resumable.last_activity_at, + 'session_id', v_resumable.session_id, + 'candidate_range', v_resumable.candidate_range, + 'accepted_time', v_resumable.accepted_time, + 'evidence_count', ( + select count(*) + from public.agentic_rectification_evidence as evidence + where evidence.case_id = v_resumable.id + and evidence.status = 'confirmed' + ) + ) + end, + 'latest_terminal', case + when v_terminal.id is null then null + else jsonb_build_object( + 'case_id', v_terminal.id, + 'status', v_terminal.status, + 'has_usable_time', v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null, + 'session_id', v_terminal.session_id, + 'accepted_time', v_terminal.accepted_time, + 'confirmed_time', v_terminal.confirmed_time + ) + end + ); +end; +$$; + +-- The one-argument form keeps its keys and now means the owner only. Every +-- case written before this migration is the owner's, so its answers do not +-- change for existing data. +create or replace function public.get_agentic_rectification_entry_summary( + p_user_id uuid +) +returns jsonb +language sql +security definer +set search_path = '' +as $$ + select public.get_agentic_rectification_entry_summary_v2(p_user_id, null) - 'chart_profile_id'; +$$; + +revoke all on function public.get_agentic_rectification_entry_summary_v2(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_entry_summary_v2(uuid, uuid) + to service_role; +revoke all on function public.get_agentic_rectification_entry_summary(uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_entry_summary(uuid) + to service_role; + +-- --------------------------------------------------------------------------- +-- 7. Widening the window patches that person's declared uncertainty +-- --------------------------------------------------------------------------- + +-- Replaces the direct profiles update in block-scan-answer.ts for both owner +-- and other people, so the server never decides the table from the browser. +create or replace function public.patch_agentic_rectification_case_uncertainty_v1( + p_user_id uuid, + p_case_id uuid, + p_before_minutes integer, + p_after_minutes integer +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null or p_case_id is null + or p_before_minutes is null or p_after_minutes is null + or p_before_minutes not between 0 and 720 or p_after_minutes not between 0 and 720 then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.chart_profile_id is null then + update public.profiles + set birth_time_source = 'approximate', + uncertainty_before_minutes = p_before_minutes, + uncertainty_after_minutes = p_after_minutes + where id = p_user_id; + else + update public.chart_profiles + set birth_time_source = 'approximate', + uncertainty_before_minutes = p_before_minutes, + uncertainty_after_minutes = p_after_minutes, + updated_at = pg_catalog.now() + where id = v_case.chart_profile_id and user_id = p_user_id and role = 'other'; + end if; +end; +$$; + +revoke all on function public.patch_agentic_rectification_case_uncertainty_v1(uuid, uuid, integer, integer) + from public, anon, authenticated; +grant execute on function public.patch_agentic_rectification_case_uncertainty_v1(uuid, uuid, integer, integer) + to service_role; + +-- --------------------------------------------------------------------------- +-- 8. Deleting a person deletes that person's cases (2A) +-- --------------------------------------------------------------------------- + +-- Previous body (20261019010000) plus the case cleanup, which runs first: +-- decisions, then results (candidates and transitions cascade), then the case +-- conversations (the case cascades from its session), then any case left. +create or replace function public.delete_chart_subject(p_id uuid) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + owner uuid := (select auth.uid()); +begin + if owner is null then raise exception 'chart_subject_unauthenticated'; end if; + perform 1 from public.chart_profiles + where id = p_id and user_id = owner and role = 'other' for update; + if not found then raise exception 'chart_subject_not_found'; end if; + perform 1 from public.agentic_rectification_cases + where user_id = owner and chart_profile_id = p_id + order by id for update; + delete from public.agentic_rectification_candidate_decisions + where user_id = owner + and case_id in (select id from public.agentic_rectification_cases where user_id = owner and chart_profile_id = p_id); + delete from public.agentic_rectification_results + where user_id = owner + and case_id in (select id from public.agentic_rectification_cases where user_id = owner and chart_profile_id = p_id); + delete from public.chat_sessions + where user_id = owner + and id in (select session_id from public.agentic_rectification_cases where user_id = owner and chart_profile_id = p_id); + delete from public.agentic_rectification_cases where user_id = owner and chart_profile_id = p_id; + perform 1 from public.personal_report_jobs j + join public.personal_reports r on r.user_id = j.user_id and r.request_id = j.request_id + where r.user_id = owner + and ( + r.chart_profile_id = p_id + or r.partner_chart_profile_id = p_id + or r.subject_low = p_id::text + or r.subject_high = p_id::text + ) + order by j.id for update of j; + perform 1 from public.personal_reports + where user_id = owner + and ( + chart_profile_id = p_id + or partner_chart_profile_id = p_id + or subject_low = p_id::text + or subject_high = p_id::text + ) + order by id for update; + delete from public.chat_sessions where user_id = owner and chart_profile_id = p_id::text; + delete from public.personal_reports + where user_id = owner + and ( + chart_profile_id = p_id + or partner_chart_profile_id = p_id + or subject_low = p_id::text + or subject_high = p_id::text + ); + delete from public.synastry_reports + where user_id = owner + and (subject_low = p_id::text or subject_high = p_id::text); + delete from public.chart_profiles where id = p_id and user_id = owner and role = 'other'; +end; +$$; +revoke all on function public.delete_chart_subject(uuid) from public, anon; +grant execute on function public.delete_chart_subject(uuid) to authenticated; + +-- --------------------------------------------------------------------------- +-- 9. Adoption, confirmation and validation read/write that person's row +-- --------------------------------------------------------------------------- + +-- Accept (20260920020000). +create or replace function public.accept_agentic_rectification_candidate_for_case_v2( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_candidate_id uuid, + p_request_id uuid, + p_credible_range jsonb default null +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_candidate public.agentic_rectification_candidates%rowtype; + v_profile public.profiles%rowtype; + v_snapshot jsonb; + v_inference jsonb; + v_expected_candidate_set_id text; + v_persisted_candidate_count integer; + v_top_active_time text; + v_active_range_start text; + v_active_range_end text; + v_latest_transition public.agentic_rectification_inference_transitions%rowtype; + v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; + v_response jsonb; + v_adopted jsonb; + v_adopted_start text; + v_adopted_end text; + v_adopted_rep text; + v_adopted_width integer; + v_saved_date date; + v_provenance jsonb; + v_saved_offset double precision; +begin + if p_user_id is null or p_case_id is null or p_result_id is null + or p_candidate_id is null or p_request_id is null then + raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) + ); + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_candidate + from public.agentic_rectification_candidates + where id = p_candidate_id + and result_id = p_result_id + and user_id = p_user_id + and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_existing_decision + from public.agentic_rectification_candidate_decisions + where user_id = p_user_id and request_id = p_request_id + for update; + if found then + if v_existing_decision.decision_kind <> 'accept' + or v_existing_decision.case_id <> p_case_id + or v_existing_decision.result_id <> p_result_id + or v_existing_decision.candidate_id <> p_candidate_id then + raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; + end if; + return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); + end if; + + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if not v_result.display_allowed or not v_result.selection_allowed then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + v_profile := public.agentic_rectification_case_birth_row(v_case, true); + if v_profile.id is null then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.declared_window_start is distinct from v_snapshot ->> 'declared_window_start' + or v_profile.declared_window_end is distinct from v_snapshot ->> 'declared_window_end' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_id is distinct from v_snapshot ->> 'timezone_id' + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + -- Adoption is a trust-boundary write. Structured answers append inference + -- transitions instead of mutating the engine result, so the latest transition + -- is authoritative when present; persisted display candidates remain a cache. + select * into v_latest_transition + from public.agentic_rectification_inference_transitions + where case_id = p_case_id + and result_id = v_result.id + order by revision desc + limit 1; + v_inference := case + when v_latest_transition.id is not null then v_latest_transition.inference_state + else v_result.decision_receipt -> 'inference_state' + end; + if v_inference is null + or jsonb_typeof(v_inference) <> 'object' + or jsonb_typeof(v_inference -> 'candidates') <> 'array' + or jsonb_array_length(v_inference -> 'candidates') = 0 + or jsonb_typeof(v_inference -> 'revision') is distinct from 'number' + or coalesce((v_inference ->> 'revision') !~ '^[0-9]+$', true) + or length(btrim(coalesce(v_inference ->> 'candidate_set_id', ''))) = 0 + or (v_inference ->> 'range_start') is distinct from v_case.candidate_range ->> 'start_time' + or (v_inference ->> 'range_end') is distinct from v_case.candidate_range ->> 'end_time' then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if v_latest_transition.id is not null + and ( + v_latest_transition.revision is distinct from (v_inference ->> 'revision')::integer + or v_latest_transition.candidate_set_id is distinct from v_inference ->> 'candidate_set_id' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + v_saved_date := public.validate_dated_rectification_candidate(v_case, v_result, v_candidate, v_inference); + if v_saved_date is null then + select count(*), + (v_case.candidate_range ->> 'start_time') || '-' || + (v_case.candidate_range ->> 'end_time') || ':' || + string_agg(pg_catalog.to_char(candidate_time, 'HH24:MI'), ',' order by candidate_time) + into v_persisted_candidate_count, v_expected_candidate_set_id + from public.agentic_rectification_candidates + where result_id = v_result.id + and user_id = p_user_id + and case_id = p_case_id; + + if v_persisted_candidate_count = 0 + or jsonb_array_length(v_inference -> 'candidates') <> v_persisted_candidate_count + or (v_inference ->> 'candidate_set_id') is distinct from v_expected_candidate_set_id + or exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where jsonb_typeof(item.value) <> 'object' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where coalesce((item.value ->> 'time') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true) + or coalesce(item.value ->> 'status', '') not in ('active', 'equivalent', 'winner', 'eliminated') + or jsonb_typeof(item.value -> 'probability') is distinct from 'number' + or jsonb_typeof(item.value -> 'posterior_score') is distinct from 'number' + or jsonb_typeof(item.value -> 'cluster_range') is distinct from 'array' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where jsonb_array_length(item.value -> 'cluster_range') is distinct from 2 + or coalesce((item.value -> 'cluster_range' ->> 0) !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true) + or coalesce((item.value -> 'cluster_range' ->> 1) !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true) + or item.value -> 'cluster_range' ->> 0 > item.value ->> 'time' + or item.value -> 'cluster_range' ->> 1 < item.value ->> 'time' + or not exists ( + select 1 + from public.agentic_rectification_candidates persisted + where persisted.result_id = v_result.id + and pg_catalog.to_char(persisted.candidate_time, 'HH24:MI') = item.value ->> 'time' + ) + ) or ( + select count(distinct item.value ->> 'time') + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + ) <> v_persisted_candidate_count then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + select item.value ->> 'time' + into v_top_active_time + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where item.value ->> 'status' <> 'eliminated' + order by (item.value ->> 'probability')::numeric desc, + (item.value ->> 'posterior_score')::numeric desc, + item.value ->> 'time' + limit 1; + + select min(item.value -> 'cluster_range' ->> 0), + max(item.value -> 'cluster_range' ->> 1) + into v_active_range_start, v_active_range_end + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where item.value ->> 'status' <> 'eliminated'; + + if v_top_active_time is null + or (v_inference ->> 'representative_time') is distinct from v_top_active_time + or jsonb_typeof(v_inference -> 'credible_range') <> 'array' + or jsonb_array_length(v_inference -> 'credible_range') <> 2 + or (v_inference -> 'credible_range' ->> 0) is distinct from v_active_range_start + or (v_inference -> 'credible_range' ->> 1) is distinct from v_active_range_end + or not exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where item.value ->> 'time' = pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI') + and item.value ->> 'status' <> 'eliminated' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + else + v_top_active_time := v_inference ->> 'representative_time'; + v_saved_offset := (v_result.decision_receipt ->> 'candidate_timezone_offset')::double precision; + v_provenance := jsonb_build_object('contract', 'dated-v1', 'case_id', p_case_id, + 'result_id', p_result_id, 'candidate_id', p_candidate_id, + 'declared_birth_date', v_profile.birth_date, 'candidate_date', v_saved_date, + 'candidate_time', to_char(v_candidate.candidate_time, 'HH24:MI'), + 'timezone_offset', v_saved_offset, 'timezone_id', v_result.decision_receipt ->> 'candidate_timezone_id', + 'source', 'persisted_candidate_calculation', 'adopted_at', pg_catalog.now()); + end if; + + if v_result.selected_candidate_id is not null then + if v_result.selected_candidate_id = p_candidate_id + and v_result.selected_time is not distinct from v_candidate.candidate_time + and v_case.accepted_time is not distinct from v_candidate.candidate_time + and v_result.selection_kind is not distinct from 'user_accepted' + and v_profile.active_birth_time is not distinct from v_candidate.candidate_time + and v_profile.birth_time_status is not distinct from 'accepted' + and v_profile.active_birth_date is not distinct from v_saved_date then + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'saved_date', coalesce(v_saved_date, v_profile.birth_date), + 'day_offset', coalesce(v_saved_date - v_profile.birth_date, 0), + 'date_changed', coalesce(v_saved_date <> v_profile.birth_date, false), + 'saved_timezone_offset', coalesce(v_saved_offset, v_profile.timezone_offset), + 'status', 'accepted', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'candidate_accepted', + 'idempotent', true + ); + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response + ); + return v_response; + end if; + + if v_case.status is distinct from 'candidate_accepted' + or v_result.selection_kind is distinct from 'user_accepted' + or v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.birth_time_status is distinct from 'accepted' then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + else + if v_result.selected_time is not null then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + end if; + + perform public.agentic_rectification_write_case_birth( + v_case, v_candidate.candidate_time, v_saved_date, v_saved_offset, v_provenance, 'accepted', + case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end + ); + + update public.agentic_rectification_results + set selected_candidate_id = p_candidate_id, + selected_time = v_candidate.candidate_time, + selection_kind = 'user_accepted', + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = p_user_id + and case_id = p_case_id + and id <> v_result.id + and invalidated_at is null + and selected_time is null; + + v_adopted := null; + if p_credible_range is not null + and jsonb_typeof(p_credible_range) = 'object' then + v_adopted_start := nullif(btrim(coalesce(p_credible_range->>'start_time', '')), ''); + v_adopted_end := nullif(btrim(coalesce(p_credible_range->>'end_time', '')), ''); + v_adopted_rep := nullif(btrim(coalesce(p_credible_range->>'representative_time', '')), ''); + begin + v_adopted_width := (p_credible_range->>'width_minutes')::integer; + exception when others then + v_adopted_width := null; + end; + end if; + if v_adopted_start is null or v_adopted_end is null + or v_adopted_start !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + or v_adopted_end !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + v_adopted_start := v_inference -> 'credible_range' ->> 0; + v_adopted_end := v_inference -> 'credible_range' ->> 1; + v_adopted_rep := v_inference ->> 'representative_time'; + v_adopted_width := null; + end if; + if v_adopted_start is not null + and v_adopted_end is not null + and v_adopted_start ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + and v_adopted_end ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + v_adopted := jsonb_strip_nulls(jsonb_build_object( + 'start_time', v_adopted_start, + 'end_time', v_adopted_end, + 'representative_time', coalesce(v_adopted_rep, v_top_active_time), + 'width_minutes', v_adopted_width, + 'source', 'inference_credible_range' + )); + end if; + + if v_saved_date is not null then + v_adopted := jsonb_build_object( + 'candidate_window_contract', 'dated-v1', + 'intervals', v_inference -> 'credible_intervals', + 'representative_time', to_char(v_candidate.candidate_time, 'HH24:MI'), + 'representative_date', v_saved_date, + 'source', 'inference_credible_range'); + if jsonb_array_length(v_inference -> 'credible_intervals') = 1 then + v_adopted := v_adopted || jsonb_build_object( + 'start_time', substring(v_inference -> 'credible_intervals' -> 0 ->> 'start_at' from 12 for 5), + 'end_time', substring(v_inference -> 'credible_intervals' -> 0 ->> 'end_at' from 12 for 5)); + end if; + end if; + + update public.agentic_rectification_cases + set status = 'candidate_accepted', + accepted_time = v_candidate.candidate_time, + confirmed_time = null, + completed_at = null, + adopted_credible_range = coalesce(v_adopted, adopted_credible_range), + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'saved_date', coalesce(v_saved_date, v_profile.birth_date), + 'day_offset', coalesce(v_saved_date - v_profile.birth_date, 0), + 'date_changed', coalesce(v_saved_date <> v_profile.birth_date, false), + 'saved_timezone_offset', coalesce(v_saved_offset, v_profile.timezone_offset), + 'status', 'accepted', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'candidate_accepted', + 'idempotent', false + ); + + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response + ); + + return v_response; +end; +$$; + +-- Confirm (20260920020000). +create or replace function public.confirm_agentic_rectification_candidate_for_case_v2( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_candidate_id uuid, + p_request_id uuid, + p_consent_quote text, + p_source_turn_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_candidate public.agentic_rectification_candidates%rowtype; + v_profile public.profiles%rowtype; + v_turn public.agentic_rectification_turns%rowtype; + v_snapshot jsonb; + v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; + v_response jsonb; + v_saved_date date; + v_saved_offset double precision; + v_provenance jsonb; + v_inference jsonb; +begin + if p_user_id is null or p_case_id is null or p_result_id is null + or p_candidate_id is null or p_request_id is null or p_source_turn_id is null + or length(btrim(coalesce(p_consent_quote, ''))) = 0 then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) + ); + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_candidate + from public.agentic_rectification_candidates + where id = p_candidate_id + and result_id = p_result_id + and user_id = p_user_id + and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_existing_decision + from public.agentic_rectification_candidate_decisions + where user_id = p_user_id and request_id = p_request_id + for update; + if found then + if v_existing_decision.decision_kind <> 'confirm' + or v_existing_decision.case_id <> p_case_id + or v_existing_decision.result_id <> p_result_id + or v_existing_decision.candidate_id <> p_candidate_id then + raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; + end if; + return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); + end if; + + select inference_state into v_inference from public.agentic_rectification_inference_transitions + where case_id = p_case_id and result_id = p_result_id order by revision desc limit 1; + v_inference := coalesce(v_inference, v_result.decision_receipt -> 'inference_state'); + v_saved_date := public.validate_dated_rectification_candidate(v_case, v_result, v_candidate, v_inference); + if v_saved_date is not null then + v_saved_offset := (v_result.decision_receipt ->> 'candidate_timezone_offset')::double precision; + v_provenance := jsonb_build_object('contract', 'dated-v1', 'case_id', p_case_id, + 'result_id', p_result_id, 'candidate_id', p_candidate_id, + 'declared_birth_date', v_case.baseline_birth_snapshot ->> 'birth_date', + 'candidate_date', v_saved_date, 'candidate_time', to_char(v_candidate.candidate_time, 'HH24:MI'), + 'timezone_offset', v_saved_offset, 'timezone_id', v_result.decision_receipt ->> 'candidate_timezone_id', + 'source', 'persisted_candidate_calculation', 'adopted_at', pg_catalog.now()); + end if; + + if v_case.status = 'confirmed' then + if v_result.selected_candidate_id is distinct from p_candidate_id + or v_case.confirmed_time is distinct from v_candidate.candidate_time then + raise exception 'agentic_rectification_case_already_confirmed' using errcode = 'P0001'; + end if; + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'saved_date', coalesce(v_saved_date, (v_case.baseline_birth_snapshot ->> 'birth_date')::date), + 'day_offset', coalesce(v_saved_date - (v_case.baseline_birth_snapshot ->> 'birth_date')::date, 0), + 'date_changed', coalesce(v_saved_date <> (v_case.baseline_birth_snapshot ->> 'birth_date')::date, false), + 'saved_timezone_offset', coalesce(v_saved_offset, (v_case.baseline_birth_snapshot ->> 'timezone_offset')::double precision), + 'status', 'confirmed', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'confirmed', + 'idempotent', true + ); + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'confirm', v_response + ); + return v_response; + end if; + if v_case.status in ('closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_turn + from public.agentic_rectification_turns + where id = p_source_turn_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + if v_turn.user_message is null + or position( + public.agentic_rectification_normalize_quote(p_consent_quote) + in public.agentic_rectification_normalize_quote(v_turn.user_message) + ) = 0 then + raise exception 'agentic_rectification_consent_not_grounded' using errcode = 'P0001'; + end if; + + if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if not v_result.confirmation_allowed then + raise exception 'agentic_rectification_confirmation_blocked' using errcode = 'P0001'; + end if; + if not v_candidate.is_representative + or v_result.representative_time is distinct from v_candidate.candidate_time then + raise exception 'agentic_rectification_confirmation_exact_gate_blocked' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + v_profile := public.agentic_rectification_case_birth_row(v_case, true); + if v_profile.id is null then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.declared_window_start is distinct from v_snapshot ->> 'declared_window_start' + or v_profile.declared_window_end is distinct from v_snapshot ->> 'declared_window_end' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_id is distinct from v_snapshot ->> 'timezone_id' + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + + if v_result.selected_candidate_id is null then + if v_result.selected_time is not null then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + elsif v_result.selected_candidate_id <> p_candidate_id + or v_result.selected_time is distinct from v_candidate.candidate_time + or v_result.selection_kind is distinct from 'user_accepted' + or v_case.status is distinct from 'candidate_accepted' + or v_case.accepted_time is distinct from v_candidate.candidate_time + or v_profile.active_birth_time is distinct from v_candidate.candidate_time + or v_profile.birth_time_status is distinct from 'accepted' then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + + perform public.agentic_rectification_write_case_birth( + v_case, v_candidate.candidate_time, v_saved_date, v_saved_offset, v_provenance, 'confirmed', + case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end + ); + + update public.agentic_rectification_results + set selected_candidate_id = p_candidate_id, + selected_time = v_candidate.candidate_time, + selection_kind = 'engine_confirmed', + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = p_user_id + and case_id = p_case_id + and id <> v_result.id + and invalidated_at is null + and selected_time is null; + + update public.agentic_rectification_cases + set status = 'confirmed', + accepted_time = v_candidate.candidate_time, + confirmed_time = v_candidate.candidate_time, + completed_at = pg_catalog.now(), + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'saved_date', coalesce(v_saved_date, (v_case.baseline_birth_snapshot ->> 'birth_date')::date), + 'day_offset', coalesce(v_saved_date - (v_case.baseline_birth_snapshot ->> 'birth_date')::date, 0), + 'date_changed', coalesce(v_saved_date <> (v_case.baseline_birth_snapshot ->> 'birth_date')::date, false), + 'saved_timezone_offset', coalesce(v_saved_offset, (v_case.baseline_birth_snapshot ->> 'timezone_offset')::double precision), + 'status', 'confirmed', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'confirmed', + 'idempotent', false + ); + + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'confirm', v_response + ); + + return v_response; +end; +$$; + +-- Segment accept (20261001010000). +create or replace function public.accept_agentic_rectification_segment_for_case_v1( + p_user_id uuid, p_case_id uuid, p_result_id uuid, p_candidate_id uuid, p_request_id uuid +) returns jsonb language plpgsql security definer set search_path = '' as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_profile public.profiles%rowtype; + v_existing public.agentic_rectification_candidate_decisions%rowtype; + v_previous_response jsonb; + v_state jsonb; + v_minute jsonb; + v_response jsonb; + v_provenance jsonb; + v_date date; + v_time time; + v_offset double precision; + v_candidate public.agentic_rectification_candidates%rowtype; + v_candidate_count integer; + v_candidate_set text; + v_top_time text; + v_range_start text; + v_range_end text; +begin + if p_user_id is null or p_case_id is null or p_result_id is null or p_candidate_id is null or p_request_id is null then + raise exception 'agentic_rectification_candidate_invalid_input'; + end if; + perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || p_request_id::text, 0)); + select * into v_case from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id for update; + if not found then raise exception 'agentic_rectification_case_not_found'; end if; + select * into v_result from public.agentic_rectification_results where id = p_result_id and case_id = p_case_id and user_id = p_user_id for update; + if not found or not exists (select 1 from public.agentic_rectification_candidates + where id = p_candidate_id and result_id = p_result_id and case_id = p_case_id and user_id = p_user_id) then + raise exception 'agentic_rectification_candidate_not_found'; + end if; + select * into v_existing from public.agentic_rectification_candidate_decisions where user_id = p_user_id and request_id = p_request_id for update; + if found then + if v_existing.decision_kind <> 'accept' or v_existing.case_id <> p_case_id or v_existing.result_id <> p_result_id or v_existing.candidate_id <> p_candidate_id then + raise exception 'agentic_rectification_candidate_request_conflict'; + end if; + -- Old requests remain old receipts; a retry never changes the minute already adopted. + return jsonb_set(v_existing.response, '{idempotent}', 'true'::jsonb, true); + end if; + select inference_state into v_state from public.agentic_rectification_inference_transitions + where case_id = p_case_id and result_id = p_result_id order by revision desc limit 1; + v_state := coalesce(v_state, v_result.decision_receipt -> 'inference_state'); + if not (v_state ? 'segment_summary') then + return public.accept_agentic_rectification_candidate_for_case_v2(p_user_id,p_case_id,p_result_id,p_candidate_id,p_request_id,null); + end if; + v_minute := public.rectification_segment_adoption_minute(v_case, v_result, v_state); + v_profile := public.agentic_rectification_case_birth_row(v_case, true); + if v_profile.id is null then raise exception 'agentic_rectification_candidate_profile_changed'; end if; + if v_result.selected_candidate_id is not null and v_case.adopted_credible_range ->> 'source' = 'server_segment_midpoint' + and (v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.active_birth_date is distinct from (v_case.adopted_credible_range ->> 'representative_date')::date + or v_profile.birth_time_status is distinct from 'accepted' + or v_profile.active_birth_timezone_offset is null + or v_profile.active_birth_timezone_offset not between -14 and 14 + or v_profile.active_birth_timezone_offset is distinct from (v_profile.active_birth_provenance ->> 'timezone_offset')::double precision + or v_profile.timezone_id is distinct from v_profile.active_birth_provenance ->> 'timezone_id' + or v_profile.active_birth_provenance ->> 'contract' is distinct from 'segment-v1' + or v_profile.active_birth_provenance ->> 'case_id' is distinct from p_case_id::text + or v_profile.active_birth_provenance ->> 'candidate_id' is distinct from v_result.selected_candidate_id::text + or v_profile.active_birth_provenance ->> 'candidate_date' is distinct from v_profile.active_birth_date::text + or v_profile.active_birth_provenance ->> 'candidate_time' is distinct from to_char(v_profile.active_birth_time,'HH24:MI') + or v_profile.active_birth_provenance ->> 'result_id' is distinct from p_result_id::text) then + raise exception 'agentic_rectification_candidate_selection_blocked'; + end if; + -- A new request for the same segment selection must not bounce through the + -- candidate minute. Revalidate freshness and the frozen dated gate read-only. + if v_result.selected_candidate_id = p_candidate_id + and v_case.adopted_credible_range ->> 'source' = 'server_segment_midpoint' + and v_result.selected_time = (v_minute ->> 'time')::time + and v_profile.active_birth_date = (v_minute ->> 'date')::date + and v_case.accepted_time = v_result.selected_time + and v_result.selection_kind = 'user_accepted' + and v_case.status = 'candidate_accepted' then + if v_result.invalidated_at is not null or v_result.expires_at <= now() then + raise exception 'agentic_rectification_candidate_expired'; + end if; + if not v_result.display_allowed or not v_result.selection_allowed then + raise exception 'agentic_rectification_candidate_selection_blocked'; + end if; + if exists (select 1 from public.agentic_rectification_results newer where newer.user_id=p_user_id + and newer.case_id=p_case_id and newer.invalidated_at is null and newer.created_at>v_result.created_at) then + raise exception 'agentic_rectification_candidate_superseded'; + end if; + if v_profile.birth_date is distinct from (v_case.baseline_birth_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_case.baseline_birth_snapshot ->> 'reported_birth_time')::time + or v_profile.birth_time_source is distinct from v_case.baseline_birth_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_case.baseline_birth_snapshot ->> 'birth_time_period' + or v_profile.declared_window_start is distinct from v_case.baseline_birth_snapshot ->> 'declared_window_start' + or v_profile.declared_window_end is distinct from v_case.baseline_birth_snapshot ->> 'declared_window_end' + or v_profile.uncertainty_before_minutes is distinct from (v_case.baseline_birth_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_case.baseline_birth_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_case.baseline_birth_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_case.baseline_birth_snapshot ->> 'longitude')::double precision + or v_profile.timezone_id is distinct from v_case.baseline_birth_snapshot ->> 'timezone_id' + or v_profile.timezone_offset is distinct from (v_case.baseline_birth_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed'; + end if; + if jsonb_typeof(v_state -> 'revision') is distinct from 'number' or coalesce(v_state ->> 'revision','') !~ '^[0-9]+$' + or length(btrim(coalesce(v_state ->> 'candidate_set_id',''))) = 0 + or v_state ->> 'range_start' is distinct from v_case.candidate_range ->> 'start_time' + or v_state ->> 'range_end' is distinct from v_case.candidate_range ->> 'end_time' + or exists (select 1 from public.agentic_rectification_inference_transitions t where t.case_id=p_case_id and t.result_id=p_result_id + and t.revision=(select max(revision) from public.agentic_rectification_inference_transitions where case_id=p_case_id and result_id=p_result_id) + and (t.revision is distinct from (v_state ->> 'revision')::integer or t.candidate_set_id is distinct from v_state ->> 'candidate_set_id')) then + raise exception 'agentic_rectification_candidate_state_inconsistent'; + end if; + select * into v_candidate from public.agentic_rectification_candidates where id=p_candidate_id; + if public.validate_dated_rectification_candidate(v_case,v_result,v_candidate,v_state) is null then + -- Frozen legacy identity/active/credible gate, evaluated without any writes. + select count(*), (v_case.candidate_range ->> 'start_time') || '-' || (v_case.candidate_range ->> 'end_time') || ':' || + string_agg(to_char(candidate_time,'HH24:MI'),',' order by candidate_time) + into v_candidate_count,v_candidate_set from public.agentic_rectification_candidates + where result_id=p_result_id and case_id=p_case_id and user_id=p_user_id; + if v_candidate_count=0 or jsonb_array_length(v_state -> 'candidates')<>v_candidate_count + or v_state ->> 'candidate_set_id' is distinct from v_candidate_set + or exists (select 1 from jsonb_array_elements(v_state -> 'candidates') a where + jsonb_typeof(a) is distinct from 'object' or coalesce(a ->> 'time','') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + or coalesce(a ->> 'status','') not in ('active','equivalent','winner','eliminated') + or jsonb_typeof(a -> 'probability') is distinct from 'number' or jsonb_typeof(a -> 'posterior_score') is distinct from 'number' + or jsonb_typeof(a -> 'cluster_range') is distinct from 'array') then + raise exception 'agentic_rectification_candidate_state_inconsistent'; + end if; + if exists (select 1 from jsonb_array_elements(v_state -> 'candidates') a where + jsonb_array_length(a -> 'cluster_range') is distinct from 2 + or coalesce(a -> 'cluster_range' ->> 0,'') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + or coalesce(a -> 'cluster_range' ->> 1,'') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + or a -> 'cluster_range' ->> 0 > a ->> 'time' or a -> 'cluster_range' ->> 1 < a ->> 'time' + or not exists (select 1 from public.agentic_rectification_candidates c where c.result_id=p_result_id + and to_char(c.candidate_time,'HH24:MI')=a ->> 'time')) + or (select count(distinct a ->> 'time') from jsonb_array_elements(v_state -> 'candidates') a)<>v_candidate_count then + raise exception 'agentic_rectification_candidate_state_inconsistent'; + end if; + select a ->> 'time' into v_top_time from jsonb_array_elements(v_state -> 'candidates') a where a ->> 'status'<>'eliminated' + order by (a ->> 'probability')::numeric desc,(a ->> 'posterior_score')::numeric desc,a ->> 'time' limit 1; + select min(a -> 'cluster_range' ->> 0),max(a -> 'cluster_range' ->> 1) into v_range_start,v_range_end + from jsonb_array_elements(v_state -> 'candidates') a where a ->> 'status'<>'eliminated'; + if v_top_time is null or v_state ->> 'representative_time' is distinct from v_top_time + or jsonb_typeof(v_state -> 'credible_range') is distinct from 'array' or jsonb_array_length(v_state -> 'credible_range')<>2 + or v_state -> 'credible_range' ->> 0 is distinct from v_range_start or v_state -> 'credible_range' ->> 1 is distinct from v_range_end + or not exists (select 1 from jsonb_array_elements(v_state -> 'candidates') a + where a ->> 'time'=to_char(v_candidate.candidate_time,'HH24:MI') and a ->> 'status'<>'eliminated') then + raise exception 'agentic_rectification_candidate_state_inconsistent'; + end if; + end if; + select response into v_previous_response from public.agentic_rectification_candidate_decisions + where user_id=p_user_id and case_id=p_case_id and result_id=p_result_id and candidate_id=p_candidate_id + and decision_kind='accept' and response ->> 'adoption_contract'='segment-v1' + and response ->> 'saved_time'=v_minute ->> 'time' and response ->> 'saved_date'=v_minute ->> 'date' + order by created_at desc limit 1; + if v_previous_response is not null then + v_response := v_previous_response || jsonb_build_object('idempotent',true); + insert into public.agentic_rectification_candidate_decisions(user_id,case_id,result_id,candidate_id,request_id,decision_kind,response) + values(p_user_id,p_case_id,p_result_id,p_candidate_id,p_request_id,'accept',v_response); + return v_response; + end if; + end if; + -- Reuse every frozen accept gate in the same transaction for a new selection. + -- Any later exception rolls back its writes as well as ours. + v_response := public.accept_agentic_rectification_candidate_for_case_v2(p_user_id,p_case_id,p_result_id,p_candidate_id,p_request_id,null); + v_date := (v_minute ->> 'date')::date; + v_time := (v_minute ->> 'time')::time; + v_offset := coalesce((v_result.decision_receipt ->> 'candidate_timezone_offset')::double precision, v_profile.timezone_offset); + v_provenance := jsonb_build_object('contract','segment-v1','case_id',p_case_id,'result_id',p_result_id,'candidate_id',p_candidate_id, + 'declared_birth_date',v_profile.birth_date,'candidate_date',v_date,'candidate_time',v_minute ->> 'time', + 'timezone_offset',v_offset,'timezone_id',v_profile.timezone_id,'source','server_segment_midpoint', + 'window_offset_minutes',v_minute -> 'offset','adopted_at',now()); + perform public.agentic_rectification_write_case_birth(v_case, v_time, v_date, v_offset, v_provenance, null, null); + update public.agentic_rectification_results set selected_time=v_time, updated_at=now() where id=p_result_id; + update public.agentic_rectification_cases set accepted_time=v_time, + adopted_credible_range=coalesce(adopted_credible_range,'{}'::jsonb) || jsonb_build_object('representative_time',v_minute ->> 'time', + 'representative_date',v_date,'source','server_segment_midpoint','segment_summary',v_state -> 'segment_summary'), + updated_at=now() where id=p_case_id; + v_response := v_response || jsonb_build_object('saved_time',v_minute ->> 'time','saved_date',v_date, + 'day_offset',v_date-v_profile.birth_date,'date_changed',v_date<>v_profile.birth_date, + 'saved_timezone_offset',v_offset,'adoption_contract','segment-v1'); + update public.agentic_rectification_candidate_decisions set response=v_response where user_id=p_user_id and request_id=p_request_id; + return v_response; +end; +$$; + +-- VedAstro validation refresh (20260827020000). +create or replace function public.refresh_agentic_rectification_vedastro_validation( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_evidence_ledger_fingerprint text, + p_candidate_range_fingerprint text, + p_validation jsonb, + p_minute_sensitive_status text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_profile public.profiles%rowtype; + v_snapshot jsonb; + v_status text; + v_minute_sensitive_status text; + v_failure_code text; + v_safe_validation jsonb; + v_exact jsonb; + v_gates jsonb; + v_receipt jsonb; +begin + if p_user_id is null or p_case_id is null or p_result_id is null + or length(btrim(coalesce(p_evidence_ledger_fingerprint, ''))) = 0 + or length(btrim(coalesce(p_candidate_range_fingerprint, ''))) = 0 + or p_validation is null or jsonb_typeof(p_validation) <> 'object' + or p_minute_sensitive_status not in ('passed', 'failed', 'not_evaluated') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + v_status := p_validation ->> 'status'; + if v_status not in ('passed', 'failed', 'not_evaluated') + or jsonb_typeof(p_validation -> 'search_events_primary_supports_local_winner') is distinct from 'boolean' + or jsonb_typeof(p_validation -> 'can_confirm_exact_minute') is distinct from 'boolean' + or ( + jsonb_typeof(p_validation -> 'failure') is distinct from 'object' + and jsonb_typeof(p_validation -> 'failure') is distinct from 'null' + ) then + raise exception 'agentic_rectification_invalid_validation' using errcode = 'P0001'; + end if; + + v_failure_code := case + when jsonb_typeof(p_validation -> 'failure') = 'object' + then nullif(btrim(coalesce(p_validation #>> '{failure,code}', '')), '') + else null + end; + if v_failure_code is not null + and v_failure_code not in ('timeout', 'engine_request_failed', 'engine_invalid_response', 'unknown') then + raise exception 'agentic_rectification_invalid_validation' using errcode = 'P0001'; + end if; + if p_minute_sensitive_status = 'passed' and v_status <> 'passed' then + raise exception 'agentic_rectification_invalid_validation' using errcode = 'P0001'; + end if; + if (p_validation ->> 'can_confirm_exact_minute')::boolean + and (v_status <> 'passed' or p_minute_sensitive_status <> 'passed') then + raise exception 'agentic_rectification_invalid_validation' using errcode = 'P0001'; + end if; + v_minute_sensitive_status := case + when v_status = 'passed' then p_minute_sensitive_status + when v_status = 'failed' then 'failed' + else 'not_evaluated' + end; + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded', 'needs_rebaseline') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() + or v_result.evidence_ledger_fingerprint is distinct from p_evidence_ledger_fingerprint + or v_result.candidate_range_fingerprint is distinct from p_candidate_range_fingerprint then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + v_profile := public.agentic_rectification_case_birth_row(v_case, false); + if v_profile.id is null then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.declared_window_start is distinct from v_snapshot ->> 'declared_window_start' + or v_profile.declared_window_end is distinct from v_snapshot ->> 'declared_window_end' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_id is distinct from v_snapshot ->> 'timezone_id' + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + v_safe_validation := jsonb_build_object( + 'status', v_status, + 'search_events_primary_supports_local_winner', + (p_validation ->> 'search_events_primary_supports_local_winner')::boolean, + 'can_confirm_exact_minute', + (p_validation ->> 'can_confirm_exact_minute')::boolean, + 'failure', case + when v_failure_code is null then 'null'::jsonb + else jsonb_build_object('code', v_failure_code) + end + ); + v_receipt := case + when jsonb_typeof(v_result.decision_receipt) = 'object' then v_result.decision_receipt + else '{}'::jsonb + end; + v_gates := case + when jsonb_typeof(v_receipt -> 'gates') = 'object' then v_receipt -> 'gates' + else '{}'::jsonb + end; + v_exact := case + when jsonb_typeof(v_gates -> 'exact_confirmation') = 'object' then v_gates -> 'exact_confirmation' + else '{}'::jsonb + end; + v_exact := jsonb_set(v_exact, '{vedastro_event_validation}', v_safe_validation, true); + v_exact := jsonb_set(v_exact, '{external_validation_status}', to_jsonb(v_minute_sensitive_status), true); + v_gates := jsonb_set(v_gates, '{exact_confirmation}', v_exact, true); + v_receipt := jsonb_set(v_receipt, '{gates}', v_gates, true); + v_receipt := jsonb_set(v_receipt, '{unique_minute_claim}', 'false'::jsonb, true); + + update public.agentic_rectification_results + set decision_receipt = v_receipt, + updated_at = pg_catalog.now() + where id = v_result.id; + + return jsonb_build_object( + 'result_id', v_result.id, + 'decision_receipt', public.compose_agentic_rectification_decision_receipt( + v_case.id, + v_result.id, + v_receipt + ) + ); +end; +$$; + +-- Segment selection reader (20261001010000), reading that person's row. +create or replace function public.get_agentic_rectification_segment_selection_v1(p_user_id uuid, p_case_id uuid, p_result_id uuid) +returns jsonb language sql security definer set search_path = '' as $$ + select jsonb_build_object('result_id', r.id, 'candidate_id', r.selected_candidate_id, + 'date', p.active_birth_date, 'time', to_char(p.active_birth_time, 'HH24:MI'), 'contract', 'segment-v1') + from public.agentic_rectification_results r + join public.agentic_rectification_cases c on c.id=r.case_id and c.user_id=r.user_id + cross join lateral public.agentic_rectification_case_birth_row(c, false) p + where r.id=p_result_id and r.case_id=p_case_id and r.user_id=p_user_id + and p.id = r.user_id + and r.selection_kind='user_accepted' and p.birth_time_status='accepted' and c.status='candidate_accepted' + and r.invalidated_at is null and r.expires_at>now() + and not exists (select 1 from public.agentic_rectification_results newer where newer.case_id=c.id and newer.user_id=p_user_id + and newer.invalidated_at is null and newer.created_at>r.created_at) + and p.active_birth_timezone_offset is not null and p.active_birth_timezone_offset between -14 and 14 + and p.active_birth_timezone_offset=(p.active_birth_provenance ->> 'timezone_offset')::double precision + and p.timezone_id is not distinct from p.active_birth_provenance ->> 'timezone_id' + and c.accepted_time=r.selected_time and p.active_birth_time=r.selected_time + and c.adopted_credible_range ->> 'source'='server_segment_midpoint' + and c.adopted_credible_range ->> 'representative_date'=p.active_birth_date::text + and p.active_birth_provenance ->> 'contract'='segment-v1' + and p.active_birth_provenance ->> 'case_id'=c.id::text + and p.active_birth_provenance ->> 'result_id'=r.id::text + and p.active_birth_provenance ->> 'candidate_id'=r.selected_candidate_id::text + and p.active_birth_provenance ->> 'candidate_date'=p.active_birth_date::text + and p.active_birth_provenance ->> 'candidate_time'=to_char(p.active_birth_time,'HH24:MI'); +$$; + +revoke all on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, jsonb) + from public, anon, authenticated; +grant execute on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, jsonb) + to service_role; +revoke all on function public.confirm_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, text, uuid) + from public, anon, authenticated; +grant execute on function public.confirm_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, text, uuid) + to service_role; +revoke all on function public.accept_agentic_rectification_segment_for_case_v1(uuid,uuid,uuid,uuid,uuid) from public, anon, authenticated; +grant execute on function public.accept_agentic_rectification_segment_for_case_v1(uuid,uuid,uuid,uuid,uuid) to service_role; +revoke all on function public.get_agentic_rectification_segment_selection_v1(uuid,uuid,uuid) from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_segment_selection_v1(uuid,uuid,uuid) to service_role; +revoke all on function public.refresh_agentic_rectification_vedastro_validation(uuid, uuid, uuid, text, text, jsonb, text) + from public, anon, authenticated; +grant execute on function public.refresh_agentic_rectification_vedastro_validation(uuid, uuid, uuid, text, text, jsonb, text) + to service_role; + +commit; diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 7e866616..fc2fd485 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -179,6 +179,9 @@ test("the birth line is followed by whether the ascendant changes inside the sta assert.doesNotMatch(period, /出生时间校正/); }); +// 原值:别人的盘只陈述上升,不带「出生时间校正」链接 +// 新值:别人的盘和本人一样带链接,指向 /rectification(页面跟着当前人物走) +// 原因:issue #160 档案里的人都能做生时校正。测试名沿用原名(frontend/AGENTS.md:测试名不得消失) test("someone else's chart states the ascendant without a rectification link", async () => { const view = await okView(); const other = { ...view, subjectId: "person-1" }; @@ -199,11 +202,12 @@ test("someone else's chart states the ascendant without a rectification link", a precisionKind: "unknown" as const, }; const otherTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: mayChange } })); - assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。/); - assert.doesNotMatch(otherTwo, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|想定下来/); + assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。想定下来可以做一次/); + assert.match(otherTwo, /href="\/rectification"/); + assert.doesNotMatch(otherTwo, /href="\/\?rectify=1"/); const otherUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: unknown } })); - assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来。/); - assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|可以缩小范围/); + assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来;做一次/); + assert.match(otherUnknown, /href="\/rectification"/); const selfTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: mayChange } })); assert.match(selfTwo, /href="\/rectification"/); assert.match(selfTwo, />出生时间校正 session\.sessionType === "birth_time_rectification"\)/); assert.match(component, / line.trim()).filter(Boolean); + return lines[lines.length - 1] ?? ""; +} + +function asUser(fixture: PostgresFixture, userId: string, sql: string): string { + return lastLine(fixture.psqlAs( + "app_runtime", + APP_PASSWORD, + `set role authenticated; select set_config('request.jwt.claim.sub', '${userId}', true); ${sql}`, + )); +} + +test("another person's case is that person's: opened for them, adopted into their row only, counted per person, deleted with them", { skip: !docker && "docker unavailable" }, async () => { + const fixture = startPostgresFixture(); + const url = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + try { + const migration = spawnSync(process.execPath, [runner], { encoding: "utf8", env: { + ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + } }); + assert.equal(migration.status, 0, migration.stderr); + const service = createLocalPostgresDataClient(url, null, "service_role"); + const skill = resolveActiveSkillPackage("jyotish-birth-time-rectification"); + const userId = randomUUID(); + const strangerId = randomUUID(); + const person = randomUUID(); + const strangersPerson = randomUUID(); + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users(id,name,email,email_verified) values + ('${userId}','Synthetic Household','${userId}@example.invalid',true), + ('${strangerId}','Synthetic Stranger','${strangerId}@example.invalid',true); + `); + // Owner: an exact family time. Person: the dated boundary of database-adopted-birth-date. + fixture.psql(` + update public.profiles set birth_date='1990-01-02',reported_birth_time='08:00',birth_time_source='family_exact', + birth_time_status='reported',uncertainty_before_minutes=15,uncertainty_after_minutes=15, + latitude=31.2,longitude=121.5,timezone_id='Asia/Shanghai',timezone_offset=8,birth_place_label='Synthetic Harbour' + where id='${userId}'; + insert into public.chart_profiles(id,user_id,role,profile,name,birth_date,reported_birth_time,birth_time_source, + uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset, + birth_place_label,ayanamsa,birth_time_status) + values + ('${person}','${userId}','other','{"name":"Synthetic Parent"}','Synthetic Parent','2000-06-15','00:10','family_exact', + 15,15,40,-74,'America/New_York',-4,'Synthetic Bay','lahiri','reported'), + ('${strangersPerson}','${strangerId}','other','{"name":"Synthetic Other"}','Synthetic Other','2001-01-01','12:00','family_exact', + 15,15,40,-74,'America/New_York',-5,'Synthetic Bay','lahiri','reported'); + `); + const ownerBefore = fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${userId}'`); + + // --- open: one open case per person, at the same time (1A) --------------- + const personSnapshot = { + birth_date: "2000-06-15", reported_birth_time: "00:10", active_birth_time: null, + birth_time_source: "family_exact", birth_time_period: null, + uncertainty_before_minutes: 15, uncertainty_after_minutes: 15, + latitude: 40, longitude: -74, timezone_id: "America/New_York", timezone_offset: -4, + }; + const ownerSnapshot = { + birth_date: "1990-01-02", reported_birth_time: "08:00", active_birth_time: null, + birth_time_source: "family_exact", birth_time_period: null, + uncertainty_before_minutes: 15, uncertainty_after_minutes: 15, + latitude: 31.2, longitude: 121.5, timezone_id: "Asia/Shanghai", timezone_offset: 8, + }; + const openArgs = (snapshot: unknown, range: unknown) => ({ + p_user_id: userId, p_request_id: randomUUID(), p_intent: "new", p_session_id: null, + p_skill_name: skill.name, p_skill_version: skill.version, p_skill_sha256: skill.sha256, p_skill_source_commit: skill.sourceCommit, + p_baseline_profile_fingerprint: "a".repeat(64), p_baseline_birth_snapshot: snapshot, p_candidate_range: range, + p_product_domain: "general", + }); + const personRange = { start_time: "23:55", end_time: "00:25", candidate_intervals: [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }] }; + const ownerRange = { start_time: "07:45", end_time: "08:15", candidate_intervals: [{ start_at: "1990-01-02T07:45", end_at: "1990-01-02T08:15" }] }; + + const personArgs = { ...openArgs(personSnapshot, personRange), p_chart_profile_id: person }; + const openedForPerson = await service.rpc("open_agentic_rectification_case_v3", personArgs); + assert.equal(openedForPerson.error, null, JSON.stringify(openedForPerson.error)); + const personOpen = openedForPerson.data as { case_id: string; session_id: string; disposition: string; chart_profile_id: string }; + assert.equal(personOpen.disposition, "created"); + assert.equal(personOpen.chart_profile_id, person); + assert.equal(fixture.psql(`select chart_profile_id::text from public.agentic_rectification_cases where id='${personOpen.case_id}'`), person); + assert.equal(fixture.psql(`select chart_profile_id || '|' || chart_profile_role from public.chat_sessions where id='${personOpen.session_id}'`), `${person}|other`); + + const replay = await service.rpc("open_agentic_rectification_case_v3", personArgs); + assert.equal(replay.error, null); + assert.equal((replay.data as { case_id: string }).case_id, personOpen.case_id, "a repeated request id is the same case"); + const replayAsOwner = await service.rpc("open_agentic_rectification_case_v3", { ...personArgs, p_chart_profile_id: null }); + assert.match(String(replayAsOwner.error?.message), /agentic_rectification_subject_mismatch/); + const foreign = await service.rpc("open_agentic_rectification_case_v3", { ...openArgs(personSnapshot, personRange), p_chart_profile_id: strangersPerson }); + assert.match(String(foreign.error?.message), /agentic_rectification_subject_not_found/, "another account's person is not this account's"); + + const openedForOwner = await service.rpc("open_agentic_rectification_case_v2", openArgs(ownerSnapshot, ownerRange)); + assert.equal(openedForOwner.error, null, JSON.stringify(openedForOwner.error)); + const ownerOpen = openedForOwner.data as { case_id: string; session_id: string }; + assert.equal(fixture.psql(`select coalesce(chart_profile_id::text,'owner') from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "owner"); + + const reopened = await service.rpc("open_agentic_rectification_case_v3", { + p_user_id: userId, p_request_id: randomUUID(), p_intent: "session", p_session_id: personOpen.session_id, + p_skill_name: skill.name, p_skill_version: skill.version, p_skill_sha256: skill.sha256, p_skill_source_commit: skill.sourceCommit, + p_baseline_profile_fingerprint: "session-view", p_baseline_birth_snapshot: {}, p_candidate_range: { start_time: "00:00", end_time: "23:59" }, + }); + assert.equal(reopened.error, null, JSON.stringify(reopened.error)); + assert.equal((reopened.data as { chart_profile_id: string }).chart_profile_id, person, "reopening a session says whose case it is"); + + const summaryFor = async (subject: string | null) => { + const read = subject + ? await service.rpc("get_agentic_rectification_entry_summary_v2", { p_user_id: userId, p_chart_profile_id: subject }) + : await service.rpc("get_agentic_rectification_entry_summary", { p_user_id: userId }); + assert.equal(read.error, null, JSON.stringify(read.error)); + return read.data as { has_resumable_case: boolean; latest_resumable: { case_id: string } | null }; + }; + assert.equal((await summaryFor(person)).latest_resumable?.case_id, personOpen.case_id); + assert.equal((await summaryFor(null)).latest_resumable?.case_id, ownerOpen.case_id, "the owner and the person each have one open case"); + assert.equal((await summaryFor(strangersPerson)).has_resumable_case, false); + + // --- adoption writes back to the person only ----------------------------- + const intervals = [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }]; + const cluster = [{ segment_index: 0, start_index: 0, end_index: 16, start_offset_minutes: 0, + end_offset_minutes: 16, start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:11" }]; + const candidates = [ + { time: "23:55", candidate_date: "2000-06-14", window_index: 0, window_offset_minutes: 0, segment_index: 0 }, + { time: "00:11", candidate_date: "2000-06-15", window_index: 16, window_offset_minutes: 16, segment_index: 0 }, + ].map((row, i) => ({ ...row, id: row.time, rank: i + 1, relative_support: 50, tied_minute_count: 1, + probability: i ? 0.4 : 0.6, posterior_score: i ? 55 : 60, status: "active", + cluster_range: [row.time, row.time], cluster_intervals: [{ segment_index: 0, + start_index: row.window_index, end_index: row.window_index, + start_offset_minutes: row.window_offset_minutes, end_offset_minutes: row.window_offset_minutes, + start_at: `${row.candidate_date}T${row.time}`, end_at: `${row.candidate_date}T${row.time}` }] })); + const inference = { + revision: 0, range_start: "23:55", range_end: "00:25", + candidate_set_id: "dated-v1:23:55-00:25:2000-06-14T23:55@0@0@0,2000-06-15T00:11@16@16@0", + candidates, representative_time: "23:55", credible_range: ["23:55", "00:11"], credible_intervals: cluster, + }; + const adoptCase = randomUUID(); + const adoptSession = randomUUID(); + fixture.psql(` + insert into public.chat_sessions(id,user_id,title,theme,session_type,messages,chart_profile_id,chart_profile_role) + values('${adoptSession}','${userId}','Synthetic boundary','general','birth_time_rectification','[]','${person}','other'); + insert into public.agentic_rectification_cases(id,user_id,session_id,status,skill_name,skill_version, + baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range,chart_profile_id) + values('${adoptCase}','${userId}','${adoptSession}','candidate_ready','jyotish-birth-time-rectification','9.0.0', + '${"a".repeat(64)}','${JSON.stringify(personSnapshot)}','${JSON.stringify(personRange)}','${person}'); + `); + const persisted = await service.rpc("persist_agentic_rectification_candidate_v2", { + p_user_id: userId, p_case_id: adoptCase, p_engine_result_id: "synthetic-subject-boundary", + p_evidence_ledger_fingerprint: "b".repeat(64), p_candidate_range_fingerprint: "c".repeat(64), + p_skill_version: "9.0.0", p_algorithm_version: "synthetic-boundary-v1", + p_event_contract_version: "rectification-event-contract-v2", p_decision_policy_version: "rectification-candidate-policy-v2", + p_candidate_range: personRange, p_candidates: candidates, + p_decision_receipt: { display_allowed: true, accept_allowed: true, confirm_allowed: true, + representative_time: "23:55", overall_confidence: "low", margin_percent: 1, + candidate_window_contract: "dated-v1", candidate_intervals: intervals, + candidate_timezone_offset: -4, candidate_timezone_id: "America/New_York", inference_state: inference }, + p_execution_ledger: [{ phase: "candidate.score", status: "completed", engine: "synthetic-boundary" }], + }); + assert.equal(persisted.error, null, JSON.stringify(persisted.error)); + const row = persisted.data as { result_id: string; candidates: { time: string; candidate_id: string }[] }; + const candidateId = row.candidates.find((c) => c.time === "23:55")!.candidate_id; + const ownerCaseBefore = fixture.psql(`select to_jsonb(c)::text from public.agentic_rectification_cases c where id='${ownerOpen.case_id}'`); + + const accepted = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, p_case_id: adoptCase, p_result_id: row.result_id, p_candidate_id: candidateId, p_request_id: randomUUID(), + }); + assert.equal(accepted.error, null, JSON.stringify(accepted.error)); + assert.equal((accepted.data as Record).saved_date, "2000-06-14"); + assert.equal(fixture.psql(`select to_char(active_birth_time,'HH24:MI') || '|' || active_birth_date || '|' || active_birth_timezone_offset + || '|' || birth_time_status || '|' || (active_birth_provenance->>'contract') || '|' || (active_birth_provenance->>'case_id') + from public.chart_profiles where id='${person}'`), `23:55|2000-06-14|-4|accepted|dated-v1|${adoptCase}`); + assert.equal(fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${userId}'`), ownerBefore, + "the owner's profile is untouched by another person's adoption"); + assert.equal(fixture.psql(`select to_jsonb(c)::text from public.agentic_rectification_cases c where id='${ownerOpen.case_id}'`), ownerCaseBefore, + "the owner's case is untouched"); + + const turnId = randomUUID(); + fixture.psql(`insert into public.agentic_rectification_turns(id,case_id,model_name,user_message,assistant_message,status) + values('${turnId}','${adoptCase}','synthetic','Confirm synthetic boundary','Synthetic acknowledgment','completed')`); + const confirmed = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, p_case_id: adoptCase, p_result_id: row.result_id, p_candidate_id: candidateId, + p_request_id: randomUUID(), p_source_turn_id: turnId, p_consent_quote: "Confirm synthetic boundary", + }); + assert.equal(confirmed.error, null, JSON.stringify(confirmed.error)); + assert.equal(fixture.psql(`select birth_time_status from public.chart_profiles where id='${person}'`), "confirmed"); + assert.equal(fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${userId}'`), ownerBefore); + + // --- the browser still cannot write an adopted minute -------------------- + assert.throws(() => asUser(fixture, userId, `update public.chart_profiles set birth_time_status='accepted' where id='${person}'`)); + assert.throws(() => asUser(fixture, userId, `update public.chart_profiles set active_birth_time='01:00' where id='${person}'`)); + assert.throws(() => asUser(fixture, userId, + `select set_config('jyotisha.rectification_subject_write', 'x', true); update public.chart_profiles set birth_time_status='accepted' where id='${person}'`), + /chart_subject_birth_status_locked/, "the marker means nothing outside the adoption function"); + + // --- a new declaration drops the adopted minute and rebaselines only that person + assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${personOpen.case_id}'`), "draft"); + asUser(fixture, userId, `update public.chart_profiles set birth_date='2000-06-16' where id='${person}'`); + assert.equal(fixture.psql(`select (active_birth_time is null and active_birth_date is null and active_birth_timezone_offset is null + and active_birth_provenance is null)::text || '|' || birth_time_status from public.chart_profiles where id='${person}'`), "true|reported"); + assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${personOpen.case_id}'`), "needs_rebaseline"); + assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "draft", + "editing a person never rebaselines the owner's case"); + fixture.psql(`update public.agentic_rectification_cases set status='draft' where id='${personOpen.case_id}'`); + fixture.psql(`update public.profiles set birth_place_label='Synthetic Harbour', latitude=31.3 where id='${userId}'`); + assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "needs_rebaseline"); + assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${personOpen.case_id}'`), "draft", + "editing the owner never rebaselines another person's case"); + + // --- deleting the person deletes that person's cases (2A) ---------------- + assert.equal(asUser(fixture, userId, `select session_count::text from public.chart_subject_usage('${person}');`), "2", + "the delete prompt counts the person's rectification conversations"); + asUser(fixture, userId, `select public.delete_chart_subject('${person}');`); + assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id='${userId}' and chart_profile_id is not null`), "0"); + assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_cases where id in ('${personOpen.case_id}','${adoptCase}')`), "0"); + assert.equal(fixture.psql(`select count(*) from public.chat_sessions where id in ('${personOpen.session_id}','${adoptSession}')`), "0"); + assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_results where id='${row.result_id}'`), "0"); + assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_candidate_decisions where case_id='${adoptCase}'`), "0"); + assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "1"); + assert.equal(fixture.psql(`select count(*) from public.chat_sessions where id='${ownerOpen.session_id}'`), "1"); + assert.equal(fixture.psql(`select count(*) from public.chart_profiles where id='${person}'`), "0"); + } finally { + await closeLocalPostgresDataPool(url); + fixture.stop(); + } +}); diff --git a/frontend/tests/lagna-precheck.test.ts b/frontend/tests/lagna-precheck.test.ts index c1de4f01..201793cb 100644 --- a/frontend/tests/lagna-precheck.test.ts +++ b/frontend/tests/lagna-precheck.test.ts @@ -136,12 +136,11 @@ test("header sentences name the sign and link only when it can change", () => { assert.equal(lagnaPrecheckLine(unknown).linked, true); assert.match(lagnaPrecheckLine(unknown).before, /不知道出生时间时/); - const otherTwo = lagnaPrecheckLine(two, { self: false }); - assert.equal(otherTwo.linked, false); - assert.equal(otherTwo.after, ""); - assert.match(otherTwo.before, /大约 12:01 换座)。$/); - assert.doesNotMatch(otherTwo.before, /出生时间校正|想定下来/); - const otherUnknown = lagnaPrecheckLine(unknown, { self: false }); - assert.equal(otherUnknown.linked, false); - assert.equal(otherUnknown.before, "不知道出生时间时,上升星座定不下来。"); + // 原值:别人的盘 `lagnaPrecheckLine(value, { self: false })` 不带链接,句子在「换座)。」结束,未知时间只说「不知道出生时间时,上升星座定不下来。」 + // 新值:不再分本人和别人,任何人的盘都是同一句、都带「出生时间校正」链接 + // 原因:issue #160 起档案里的人都能做生时校正;/rectification 跟着当前人物走 + assert.equal(lagnaPrecheckLine.length, 1); + assert.deepEqual(lagnaPrecheckLine(two), twoLine); + assert.match(twoLine.before, /大约 12:01 换座)。想定下来可以做一次$/); + assert.equal(lagnaPrecheckLine(unknown).before, "不知道出生时间时,上升星座定不下来;做一次"); }); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index da038fc7..7baa2af0 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -971,7 +971,10 @@ test("stream failures remove empty assistant placeholders", () => { test("rectification sessions are opened by the server Case API; the browser never creates them", () => { assert.match(page, /\/api\/rectification\/cases\/open/); // 原值:intent/exactSessionId;新值:附 sourceSessionId;原因:仅传 owned 来源引用,领域仍由服务端读取。 - assert.match(page, /openRectificationRequestBody\(intent, exactSessionId, sourceSessionId\)/); + // 原值(2):openRectificationRequestBody(intent, exactSessionId, sourceSessionId) + // 新值(2):多传人物 subjectId(本人不进请求体,请求体和以前一样) + // 原因(2):issue #160 档案里的人都能做校正;出生资料仍由服务端按这个人去读 + assert.match(page, /openRectificationRequestBody\(intent, exactSessionId, sourceSessionId, subjectId\)/); assert.match(page, /openResponseFromPayload\(payload\)/); assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/); assert.doesNotMatch(page, /hasRectificationSession/); diff --git a/frontend/tests/rectification-chart-subject-20261101.test.ts b/frontend/tests/rectification-chart-subject-20261101.test.ts new file mode 100644 index 00000000..18bf06ef --- /dev/null +++ b/frontend/tests/rectification-chart-subject-20261101.test.ts @@ -0,0 +1,415 @@ +/** + * Issue #160 (product 2026-11-01: 1A 2A): rectification for the other people + * in the account's profile list. Fictional ids and birth data only. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + RECTIFICATION_SUBJECT_QUERY_KEY, + rectificationEntryHref, + rectificationEntryIntent, + rectificationEntrySubject, +} from "../src/lib/chat-session-url.ts"; +import { openRectificationRequestBody, openResponseFromPayload } from "../src/lib/rectification-entry.ts"; +import { + RECTIFICATION_PAGE_COPY, + rectificationEntrySummaryPath, + rectificationPageState, + rectificationPersonFacts, + rectificationProfileHref, + rectificationSessionHref, + rectificationStartHref, +} from "../src/lib/rectification-page.ts"; +import { + RectificationCaseServiceError, + getRectificationEntrySummary, + mapRectificationRpcError, + openRectificationCase, +} from "../src/lib/rectification-agentic/v9/case-service.ts"; +import { + openRequestSubjectId, + openResponse, + parseOpenRectificationCaseRequest, +} from "../src/lib/rectification-agentic/v9/open-request.ts"; +import { patchV9BirthUncertainty } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { loadResumableRectificationSessionId } from "../src/lib/rectification-resume-session.ts"; +import { parseChartSubjectWrite } from "../src/lib/chart-subject-write.ts"; + +const PERSON = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa"; +const OTHER_PERSON = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb"; +const REQUEST = "cccccccc-3333-4333-8333-cccccccccccc"; +const CASE = "dddddddd-4444-4444-8444-dddddddddddd"; +const SESSION = "eeeeeeee-5555-4555-8555-eeeeeeeeeeee"; +const USER = "ffffffff-6666-4666-8666-ffffffffffff"; + +const personRow = { + birth_date: "1991-03-09", + birth_place_label: "虚构市", + reported_birth_time: "10:00:00", + birth_time_source: "approximate", + birth_time_period: null, + declared_window_start: null, + declared_window_end: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + latitude: 30.5, + longitude: 114.3, + timezone_id: "Asia/Shanghai", + timezone_offset: 8, + ayanamsa: "lahiri", +}; + +const createdPayload = { + disposition: "created", + case_id: CASE, + session_id: SESSION, + status: "draft", + should_start_opening: true, + skill_version: "9.0.0", +}; + +type Read = { table: string; columns: string; filters: Record }; + +function accountingFor(input: { + rows: Record | null>; + rpc: (fn: string, args: Record) => { data: unknown; error: { message: string } | null }; +}) { + const reads: Read[] = []; + const rpcs: Array<{ fn: string; args: Record }> = []; + const client = { + from(table: string) { + const read: Read = { table, columns: "", filters: {} }; + reads.push(read); + const builder = { + select(columns: string) { read.columns = columns; return builder; }, + eq(column: string, value: string) { read.filters[column] = value; return builder; }, + async single() { + const row = input.rows[table] ?? null; + return row ? { data: row, error: null } : { data: null, error: { message: "not found" } }; + }, + async maybeSingle() { + return { data: input.rows[table] ?? null, error: null }; + }, + }; + return builder; + }, + async rpc(fn: string, args: Record) { + rpcs.push({ fn, args }); + return input.rpc(fn, args); + }, + }; + return { client: client as never, reads, rpcs }; +} + +test("the open request takes a person id for new cases only; the owner sends the same body as before", () => { + const forOther = parseOpenRectificationCaseRequest({ intent: "homepage", requestId: REQUEST, subjectId: PERSON }); + assert.ok(forOther); + assert.equal(openRequestSubjectId(forOther), PERSON); + const again = parseOpenRectificationCaseRequest({ intent: "new", requestId: REQUEST, subjectId: PERSON }); + assert.equal(again && openRequestSubjectId(again), PERSON); + const owner = parseOpenRectificationCaseRequest({ intent: "homepage", requestId: REQUEST }); + assert.equal(owner && openRequestSubjectId(owner), null); + assert.equal(parseOpenRectificationCaseRequest({ intent: "homepage", requestId: REQUEST, subjectId: "self" }), null); + assert.equal(parseOpenRectificationCaseRequest({ intent: "session", requestId: REQUEST, sessionId: SESSION, subjectId: PERSON }), null, + "an exact session already belongs to one person"); + + assert.deepEqual(Object.keys(openRectificationRequestBody("homepage", null, null, "self")).sort(), ["intent", "requestId"]); + assert.equal(openRectificationRequestBody("homepage", null, null, PERSON).subjectId, PERSON); + assert.equal(openRectificationRequestBody("new", null, null, PERSON).subjectId, PERSON); + assert.equal("subjectId" in openRectificationRequestBody("session", SESSION, null, PERSON), false); + assert.equal("subjectId" in openRectificationRequestBody("homepage", null, null, "not-an-id"), false); +}); + +test("the open answer says whose case it is; an older payload reads as the owner", () => { + assert.equal(openResponse({ ...createdPayload, chart_profile_id: PERSON })?.subjectId, PERSON); + assert.equal(openResponse({ ...createdPayload, chart_profile_id: null })?.subjectId, null); + assert.equal(openResponse(createdPayload)?.subjectId, null); + assert.equal(openResponseFromPayload({ disposition: "created", caseId: CASE, sessionId: SESSION, status: "draft", subjectId: PERSON })?.subjectId, PERSON); + assert.equal(openResponseFromPayload({ disposition: "created", caseId: CASE, sessionId: SESSION, status: "draft" })?.subjectId, null); +}); + +test("the queued home open carries the person; the owner URL is unchanged", () => { + assert.equal(rectificationEntryHref("homepage", "self"), "/?rectify=1"); + assert.equal(rectificationEntryHref("new"), "/?rectify=new"); + assert.equal(rectificationEntryHref("homepage", PERSON), `/?rectify=1&${RECTIFICATION_SUBJECT_QUERY_KEY}=${PERSON}`); + assert.equal(rectificationEntryHref("new", "garbage"), "/?rectify=new"); + assert.equal(rectificationEntrySubject(`?rectify=new&rectifyFor=${PERSON}`), PERSON); + assert.equal(rectificationEntryIntent(`?rectify=new&rectifyFor=${PERSON}`), "new"); + assert.equal(rectificationEntrySubject("?rectify=1"), "self"); + assert.equal(rectificationEntrySubject("?rectify=1&rectifyFor=../../x"), "self"); +}); + +test("the page links, summary path and person facts follow the current person", () => { + assert.equal(rectificationStartHref({ kind: "none" }, PERSON), `/?rectify=1&rectifyFor=${PERSON}`); + assert.equal(rectificationStartHref({ kind: "done", sessionId: SESSION, time: "07:42" }, PERSON), `/?rectify=new&rectifyFor=${PERSON}`); + assert.equal(rectificationSessionHref(SESSION, PERSON), `/?c=${SESSION}`); + assert.equal(rectificationSessionHref(null, PERSON), `/?rectify=1&rectifyFor=${PERSON}`); + assert.equal(rectificationEntrySummaryPath("self"), "/api/rectification/cases/entry-summary"); + assert.equal(rectificationEntrySummaryPath(PERSON), `/api/rectification/cases/entry-summary?subject=${PERSON}`); + assert.equal(rectificationProfileHref("self"), "/people?self=1"); + assert.equal(rectificationProfileHref(PERSON), `/people?edit=${PERSON}`); + + assert.deepEqual(rectificationPersonFacts("self", { name: "小林" }), { name: "小林", relation: "本人" }); + assert.deepEqual(rectificationPersonFacts("self", { name: " " }), { name: "我", relation: "本人" }); + assert.deepEqual(rectificationPersonFacts(PERSON, { name: "阿岚", relation: "parent" }), { name: "阿岚", relation: "父母" }); + assert.deepEqual(rectificationPersonFacts(PERSON, { name: "阿岚", relation: null }), { name: "阿岚", relation: "其他" }); + + const empty = { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null }; + assert.equal(rectificationPageState({ signedOut: false, enabled: true, profileComplete: false, forOther: true, summary: empty }).kind, "other-missing"); + assert.equal(RECTIFICATION_PAGE_COPY.otherMissing, "先把 TA 的出生日期、时间和地点填好,才能做生时校正。"); + assert.equal(RECTIFICATION_PAGE_COPY.otherCurrentTime, "TA 现在用的时间"); + for (const text of Object.values(RECTIFICATION_PAGE_COPY)) assert.doesNotMatch(text, /只支持本人|暂时还不支持/); +}); + +test("a case for another person reads that person's row and opens through v3; the owner's row is not read", async () => { + const fake = accountingFor({ + rows: { chart_profiles: personRow }, + rpc: (fn) => fn === "open_agentic_rectification_case_v3" + ? { data: { ...createdPayload, chart_profile_id: PERSON }, error: null } + : { data: null, error: { message: `unexpected rpc ${fn}` } }, + }); + const opened = await openRectificationCase(fake.client, USER, { intent: "homepage", requestId: REQUEST, subjectId: PERSON }, "career"); + assert.equal(opened.subjectId, PERSON); + assert.deepEqual(fake.reads.map((read) => read.table), ["chart_profiles"]); + assert.deepEqual(fake.reads[0].filters, { id: PERSON, user_id: USER, role: "other" }); + assert.doesNotMatch(fake.reads[0].columns, /birth_time_clue/); + const open = fake.rpcs.find((call) => call.fn === "open_agentic_rectification_case_v3"); + assert.ok(open); + assert.equal(open.args.p_chart_profile_id, PERSON); + assert.equal(open.args.p_product_domain, "career"); + assert.equal(open.args.p_user_id, USER); + const snapshot = open.args.p_baseline_birth_snapshot as Record; + assert.equal(snapshot.birth_date, "1991-03-09"); + assert.equal(snapshot.reported_birth_time, "10:00"); + assert.equal(snapshot.active_birth_time, null, "a fresh case never inherits an adopted minute"); + assert.equal(fake.rpcs.some((call) => call.fn === "open_agentic_rectification_case_v2"), false); +}); + +test("the owner's new case still reads profiles and calls v2 without a person", async () => { + const fake = accountingFor({ + rows: { profiles: personRow }, + rpc: (fn) => fn === "open_agentic_rectification_case_v2" + ? { data: createdPayload, error: null } + : { data: null, error: { message: `unexpected rpc ${fn}` } }, + }); + const opened = await openRectificationCase(fake.client, USER, { intent: "new", requestId: REQUEST }); + assert.equal(opened.subjectId, null); + assert.deepEqual(fake.reads.map((read) => [read.table, read.filters]), [["profiles", { id: USER }]]); + assert.equal("p_chart_profile_id" in fake.rpcs[0].args, false); +}); + +test("a person who is not in this account is not found, and nothing is opened", async () => { + const fake = accountingFor({ + rows: { chart_profiles: null }, + rpc: (fn) => ({ data: null, error: { message: `unexpected rpc ${fn}` } }), + }); + await assert.rejects( + () => openRectificationCase(fake.client, USER, { intent: "homepage", requestId: REQUEST, subjectId: OTHER_PERSON }), + (error: unknown) => error instanceof RectificationCaseServiceError && error.code === "subject_not_found", + ); + assert.equal(fake.rpcs.length, 0); + assert.deepEqual(mapRectificationRpcError(new Error("agentic_rectification_subject_not_found")), + { status: 404, code: "subject_not_found", message: "这个人的资料不存在或已删除" }); + assert.equal(mapRectificationRpcError(new Error("agentic_rectification_subject_mismatch")).status, 409); +}); + +test("open cases are counted per person (1A): the summary and the resume lookup read one person", async () => { + const fake = accountingFor({ + rows: {}, + rpc: (fn, args) => { + if (fn === "get_agentic_rectification_entry_summary_v2") { + return { + data: { + chart_profile_id: args.p_chart_profile_id, + has_resumable_case: true, + has_terminal_case_with_time: false, + latest_resumable: { case_id: CASE, status: "collecting_evidence", last_activity_at: "x", session_id: SESSION }, + latest_terminal: null, + }, + error: null, + }; + } + if (fn === "get_agentic_rectification_entry_summary") { + return { data: { has_resumable_case: false, has_terminal_case_with_time: false, latest_resumable: null, latest_terminal: null }, error: null }; + } + if (fn === "get_agentic_rectification_case") { + return { data: { case_id: CASE, session_id: SESSION, status: "collecting_evidence" }, error: null }; + } + return { data: null, error: { message: `unexpected rpc ${fn}` } }; + }, + }); + const forPerson = await getRectificationEntrySummary(fake.client, USER, PERSON); + assert.equal(forPerson.hasResumableCase, true); + const forOwner = await getRectificationEntrySummary(fake.client, USER); + assert.equal(forOwner.hasResumableCase, false, "another person's open case does not block the owner"); + assert.equal(await loadResumableRectificationSessionId(fake.client, USER, PERSON), SESSION); + assert.equal(await loadResumableRectificationSessionId(fake.client, USER), null); + assert.deepEqual( + fake.rpcs.filter((call) => call.fn.startsWith("get_agentic_rectification_entry_summary")).map((call) => [call.fn, call.args.p_chart_profile_id ?? null]), + [ + ["get_agentic_rectification_entry_summary_v2", PERSON], + ["get_agentic_rectification_entry_summary", null], + ["get_agentic_rectification_entry_summary_v2", PERSON], + ["get_agentic_rectification_entry_summary", null], + ], + ); +}); + +test("widening patches the case's person through the case, never a table the caller picks", async () => { + const fake = accountingFor({ rows: {}, rpc: () => ({ data: null, error: null }) }); + await patchV9BirthUncertainty(fake.client, USER, CASE, { + birth_time_source: "approximate", + uncertainty_before_minutes: 45, + uncertainty_after_minutes: 45, + }); + assert.deepEqual(fake.rpcs, [{ + fn: "patch_agentic_rectification_case_uncertainty_v1", + args: { p_user_id: USER, p_case_id: CASE, p_before_minutes: 45, p_after_minutes: 45 }, + }]); + assert.equal(fake.reads.length, 0); +}); + +test("the self-only limits are gone from the surface, the chat offer and the copy", () => { + const surface = readFileSync(new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), "utf8"); + assert.doesNotMatch(surface, /rectificationLimitedToSelf|只支持本人/); + assert.match(surface, /refreshSubjectCatalog\(\)/, "another person's adopted time refreshes the people list"); + assert.match(surface, /function handleRectificationSaved\(\)/); + const inChat = readFileSync(new URL("../src/lib/rectification-in-chat.ts", import.meta.url), "utf8"); + assert.doesNotMatch(inChat, /not_self|给家人校正暂时还不支持/); + const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8"); + assert.doesNotMatch(tools, /not_self|只支持本人/); + const page = readFileSync(new URL("../src/components/rectification-page/rectification-entry-page.tsx", import.meta.url), "utf8"); + assert.match(page, /useCurrentSubject\(\)/); + assert.match(page, /rectificationEntrySummaryPath\(subjectId\)/); + assert.match(page, /COPY\.personName/); + assert.match(page, /COPY\.personRelation/); + const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8"); + assert.doesNotMatch(voice, /当前人物不是本人时,生时校正入口禁用/); + assert.doesNotMatch(voice, /家人档案说「给家人校正暂时还不支持」/); +}); + +test("editing someone whose time was adopted saves, and the adopted status stays the database's to change", () => { + const base = { name: "阿岚", date: "1991-03-09", reportedTime: "10:00", birthTimeSource: "approximate" }; + for (const echoed of ["accepted", "confirmed"]) { + const parsed = parseChartSubjectWrite({ ...base, birthTimeStatus: echoed }); + assert.equal(parsed.ok, true, echoed); + assert.equal(parsed.ok && "birth_time_status" in parsed.write.columns, false, `${echoed} is not written by the browser`); + } + const reported = parseChartSubjectWrite({ ...base, birthTimeStatus: "reported" }); + assert.equal(reported.ok && reported.write.columns.birth_time_status, "reported"); + const fresh = parseChartSubjectWrite(base); + assert.equal(fresh.ok && fresh.write.columns.birth_time_status, "reported"); + assert.equal(parseChartSubjectWrite({ ...base, activeBirthTime: "10:05" }).ok, false, "an adopted minute is still never accepted from the browser"); + const migration = readFileSync(new URL("../supabase/migrations/20261101010000_rectification_chart_subject.sql", import.meta.url), "utf8"); + assert.match(migration, /create trigger chart_profiles_zz_drop_adopted_on_declaration\nbefore update on public\.chart_profiles/); +}); + +const MIGRATIONS = new URL("../supabase/migrations/", import.meta.url); +const migration = readFileSync(new URL("20261101010000_rectification_chart_subject.sql", MIGRATIONS), "utf8"); + +function functionBody(sql: string, name: string): string { + const start = sql.search(new RegExp(`create (or replace )?function public\\.${name}\\(`)); + assert.ok(start >= 0, `${name} missing`); + const end = sql.indexOf("\n$$;", start); + assert.ok(end > start, `${name} not closed`); + return sql.slice(start, end + 4); +} + +test("the migration only adds: old cases stay the owner's, and no data is rewritten", () => { + assert.match(migration, /add column if not exists chart_profile_id uuid\s+references public\.chart_profiles\(id\) on delete cascade;/); + assert.match(migration, /alter table public\.chart_profiles\s+add column if not exists active_birth_provenance jsonb;/); + assert.doesNotMatch(migration, /\bdrop (table|column|index)\b/i); + assert.doesNotMatch(migration, /\btruncate\b/i); + // The only write of a case's chart_profile_id is the new case in v3. + const sets = [...migration.matchAll(/update public\.agentic_rectification_cases\s+set chart_profile_id = /g)]; + assert.equal(sets.length, 1); + const v3 = functionBody(migration, "open_agentic_rectification_case_v3"); + assert.ok(v3.includes("set chart_profile_id = p_chart_profile_id")); + assert.match(v3, /where id = p_chart_profile_id and user_id = p_user_id and role = 'other'/); + assert.match(v3, /agentic_rectification_subject_mismatch/); + assert.match(migration, /grant execute on function public\.open_agentic_rectification_case_v3\(\s*uuid, uuid, text, uuid, text, text, text, text, text, jsonb, jsonb, text, uuid\s*\) to service_role;/); + // Deletes exist only inside delete_chart_subject (2A). + const outside = migration.replace(functionBody(migration, "delete_chart_subject"), ""); + assert.doesNotMatch(outside, /\bdelete from\b/i); +}); + +test("the copied adoption RPCs differ from their previous definitions only in the birth row read and write", () => { + // 20260920020000 is stored with CRLF; the copy is LF. Same SQL. + const previous = (file: string) => readFileSync(new URL(file, MIGRATIONS), "utf8").replace(/\r\n/g, "\n"); + const lockRead = / select \* into v_profile\n from public\.profiles\n where id = p_user_id\n for update;\n if not found then\n/; + const lockReadNew = " v_profile := public.agentic_rectification_case_birth_row(v_case, true);\n if v_profile.id is null then\n"; + const write = (status: string) => new RegExp(` update public\\.profiles\\n set active_birth_time = v_candidate\\.candidate_time,[\\s\\S]*?birth_time_status = '${status}',[\\s\\S]*? where id = p_user_id;\\n`); + const writeNew = (status: string) => ` perform public.agentic_rectification_write_case_birth(\n v_case, v_candidate.candidate_time, v_saved_date, v_saved_offset, v_provenance, '${status}',\n case\n when v_result.overall_confidence = 'high' then 100\n when v_result.overall_confidence = 'medium' then 70\n else 40\n end\n );\n`; + + const accept = functionBody(previous("20260920020000_adopted_birth_date.sql"), "accept_agentic_rectification_candidate_for_case_v2") + .replace(lockRead, lockReadNew).replace(write("accepted"), writeNew("accepted")); + assert.equal(functionBody(migration, "accept_agentic_rectification_candidate_for_case_v2"), accept); + + const confirm = functionBody(previous("20260920020000_adopted_birth_date.sql"), "confirm_agentic_rectification_candidate_for_case_v2") + .replace(lockRead, lockReadNew).replace(write("confirmed"), writeNew("confirmed")); + assert.equal(functionBody(migration, "confirm_agentic_rectification_candidate_for_case_v2"), confirm); + + const vedastro = functionBody(previous("20260827020000_rectification_vedastro_validation_retry.sql"), "refresh_agentic_rectification_vedastro_validation") + .replace(" select * into v_profile\n from public.profiles\n where id = p_user_id;\n if not found then\n", + " v_profile := public.agentic_rectification_case_birth_row(v_case, false);\n if v_profile.id is null then\n"); + assert.equal(functionBody(migration, "refresh_agentic_rectification_vedastro_validation"), vedastro); + + const segment = functionBody(previous("20261001010000_rectification_segment_adoption.sql"), "accept_agentic_rectification_segment_for_case_v1") + .replace("create function", "create or replace function") + .replace(" select * into v_profile from public.profiles where id = p_user_id for update;\n if not found then raise", + " v_profile := public.agentic_rectification_case_birth_row(v_case, true);\n if v_profile.id is null then raise") + .replace(/ update public\.profiles set birth_time=v_time,[\s\S]*?where id=p_user_id;\n/, + " perform public.agentic_rectification_write_case_birth(v_case, v_time, v_date, v_offset, v_provenance, null, null);\n"); + assert.equal(functionBody(migration, "accept_agentic_rectification_segment_for_case_v1"), segment); + + for (const name of [ + "accept_agentic_rectification_candidate_for_case_v2", + "confirm_agentic_rectification_candidate_for_case_v2", + "accept_agentic_rectification_segment_for_case_v1", + "refresh_agentic_rectification_vedastro_validation", + "get_agentic_rectification_segment_selection_v1", + ]) { + assert.doesNotMatch(functionBody(migration, name), /public\.profiles\b(?!%rowtype)/, `${name} must not read or write the owner's row directly`); + } +}); + +test("another person's minute is written only by the adoption helper, and the owner's triggers ignore other people", () => { + const write = functionBody(migration, "agentic_rectification_write_case_birth"); + assert.match(write, /if p_case\.chart_profile_id is null then\n update public\.profiles/); + assert.match(write, /set_config\('jyotisha\.rectification_subject_write', p_case\.id::text, true\)/); + assert.match(write, /where id = p_case\.chart_profile_id and user_id = p_case\.user_id and role = 'other'/); + const guard = functionBody(migration, "chart_profiles_guard_birth"); + assert.match(guard, /current_setting\('jyotisha\.rectification_subject_write', true\)/); + assert.match(guard, /current_user not in \('anon', 'authenticated'\)/); + assert.match(guard, /raise exception 'chart_subject_active_birth_is_locked'/); + assert.match(guard, /raise exception 'chart_subject_birth_status_locked'/); + assert.match(migration, /revoke all on function public\.agentic_rectification_write_case_birth\([\s\S]*?\) from public, anon, authenticated, service_role;/); + + const ownerRebaseline = functionBody(migration, "agentic_rectification_profiles_rebaseline_guard"); + assert.match(ownerRebaseline, /and chart_profile_id is null/); + const ownerInvalidate = functionBody(migration, "invalidate_agentic_rectification_results_on_profile_change"); + assert.match(ownerInvalidate, /c\.chart_profile_id is not null/); + const personRebaseline = functionBody(migration, "agentic_rectification_chart_profiles_rebaseline"); + assert.match(personRebaseline, /and chart_profile_id = new\.id/); + assert.match(migration, /create trigger agentic_rectification_chart_profiles_rebaseline_trigger\nafter update on public\.chart_profiles/); +}); + +test("deleting a person deletes that person's cases first, in an order the foreign keys allow (2A)", () => { + const remove = functionBody(migration, "delete_chart_subject"); + const order = [ + "delete from public.agentic_rectification_candidate_decisions", + "delete from public.agentic_rectification_results", + "delete from public.chat_sessions\n where user_id = owner\n and id in (select session_id from public.agentic_rectification_cases", + "delete from public.agentic_rectification_cases where user_id = owner and chart_profile_id = p_id", + "delete from public.chat_sessions where user_id = owner and chart_profile_id = p_id::text", + "delete from public.personal_reports", + "delete from public.synastry_reports", + "delete from public.chart_profiles where id = p_id and user_id = owner and role = 'other'", + ].map((step) => remove.indexOf(step)); + assert.ok(order.every((index) => index > 0), JSON.stringify(order)); + assert.deepEqual([...order].sort((a, b) => a - b), order); + assert.match(remove, /security definer/); + assert.match(remove, /owner uuid := \(select auth\.uid\(\)\)/); +}); diff --git a/frontend/tests/rectification-engine-version-cross-midnight.test.ts b/frontend/tests/rectification-engine-version-cross-midnight.test.ts index af95f084..51a6e4c0 100644 --- a/frontend/tests/rectification-engine-version-cross-midnight.test.ts +++ b/frontend/tests/rectification-engine-version-cross-midnight.test.ts @@ -513,7 +513,10 @@ test("history opens with its bound Skill and reads old engine receipt values unc const accounting = fakeAccounting({ open_agentic_rectification_case: () => open, get_agentic_rectification_skill_identity: () => ({ skill_name: bound.name, skill_version: bound.version, skill_sha256: bound.sha256, skill_source_commit: bound.sourceCommit }), - open_agentic_rectification_case_v2: (_fn, args) => { + // 原值:重开历史会话的第三步是 open_agentic_rectification_case_v2 + // 新值:改走 open_agentic_rectification_case_v3(参数不变,多 p_product_domain / p_chart_profile_id 两个 null) + // 原因:issue #160 重开时要从服务端知道是谁的盘 + open_agentic_rectification_case_v3: (_fn, args) => { assert.equal(args.p_skill_version, bound.version); assert.equal(args.p_skill_sha256, bound.sha256); assert.equal("p_engine_version" in args, false); diff --git a/frontend/tests/rectification-entry-page-20261028.test.tsx b/frontend/tests/rectification-entry-page-20261028.test.tsx index 0c9ff7c0..10bf87cc 100644 --- a/frontend/tests/rectification-entry-page-20261028.test.tsx +++ b/frontend/tests/rectification-entry-page-20261028.test.tsx @@ -34,15 +34,19 @@ const EMPTY: RectificationEntrySummary = { latestTerminal: null, }; -function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; selfComplete: boolean }> = {}) { - return rectificationPageState({ signedOut: false, enabled: true, selfComplete: true, summary, ...patch }); +// 原值:输入字段叫 selfComplete(只看本人资料) +// 新值:改叫 profileComplete(当前人物的资料),另有 forOther +// 原因:issue #160 起 /rectification 跟着当前人物走,档案里的人也能校正 +function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; profileComplete: boolean; forOther: boolean }> = {}) { + return rectificationPageState({ signedOut: false, enabled: true, profileComplete: true, summary, ...patch }); } test("the page refuses before reading cases: signed out, product off or unknown, no self chart", () => { assert.equal(state(EMPTY, { signedOut: true }).kind, "signed-out"); assert.equal(state(EMPTY, { enabled: false }).kind, "unavailable"); assert.equal(state(EMPTY, { enabled: undefined }).kind, "unavailable", "a flag that did not arrive keeps the page closed"); - assert.equal(state(EMPTY, { selfComplete: false }).kind, "self-missing"); + assert.equal(state(EMPTY, { profileComplete: false }).kind, "self-missing"); + assert.equal(state(EMPTY, { profileComplete: false, forOther: true }).kind, "other-missing"); assert.equal(state(EMPTY).kind, "none"); }); @@ -202,7 +206,10 @@ test("the starter home shows the rectification prompt only when asked, linking t assert.match(hinted, /href="\/rectification"/); const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); - assert.match(page, /rectifyHint=\{currentSubjectId === "self" && account\.rectification === true && birthTimeNeedsRectification\(profile\)\}/); + // 原值:rectifyHint={currentSubjectId === "self" && account.rectification === true && birthTimeNeedsRectification(profile)},帮别人看时不出现 + // 新值:看当前人物(dailyProfile,本人时就是本人资料)的时间是不是估的 + // 原因:issue #160 起档案里的人都能做校正,启程任务书红线 11 解除 + assert.match(page, /rectifyHint=\{account\.rectification === true && Boolean\(dailyProfile\) && birthTimeNeedsRectification\(dailyProfile \?\? profile\)\}/); }); test("the sidebar lists 生时校正 under 星盘, behind the account flag", () => { diff --git a/frontend/tests/rectification-explicit-entry-20261012.test.ts b/frontend/tests/rectification-explicit-entry-20261012.test.ts index 622fc1af..9176e815 100644 --- a/frontend/tests/rectification-explicit-entry-20261012.test.ts +++ b/frontend/tests/rectification-explicit-entry-20261012.test.ts @@ -13,7 +13,6 @@ import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan. import { blocksNewRectification, RECTIFICATION_DIRECT_NEW_COPY, - RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_OFFER_TOOL_ID, @@ -44,14 +43,13 @@ const DIRECT_SENTENCES = [ RECTIFICATION_DIRECT_NEW_COPY, RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY, - RECTIFICATION_DIRECT_NOT_SELF_COPY, ] as const; const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; type OfferDecision = | { ok: true; creditCost: number; questionEstimate: number } - | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }; + | { ok: false; reason: "product_disabled" | "case_in_progress" | "pricing_unavailable" }; function offer(status: RectificationOfferRecord["status"] = "offered", extra: Partial = {}): RectificationOfferRecord { return { @@ -122,12 +120,14 @@ test("direct-entry sentences stay within the free-completion length", () => { // 原值:[13, 13, 14, 11, 11],含「可在历史对话里接着做。」 // 新值:[13, 13, 14, 11] // 原因:接不回进行中的校正时改为新开,不再留这句没有出口的兜底 - assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11]); + // 原值(2):[13, 13, 14, 11],第四句是「给家人校正暂时还不支持」 + // 新值(2):[13, 13, 14] + // 原因(2):issue #160 起档案里的人都能做校正,这句和 not_self 一起删掉 + assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14]); for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20); assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。"); assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。"); assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。"); - assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持"); assert.equal(blocksNewRectification("candidate_accepted"), false); assert.equal(blocksNewRectification("collecting_evidence"), true); assert.equal(blocksNewRectification("draft"), true); @@ -337,16 +337,13 @@ test("explicit refusals are one sentence, and none of the four notes says to hid assert.match(String(priced.note), /不写价格/); assert.doesNotMatch(String(priced.note), /不要提校正/); - const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) }); + // 原值:not_self(家人档案)明确要求时只写「给家人校正暂时还不支持」,不开校正 + // 新值:没有 not_self;给家人明确要求校正和本人一样走「好,我们开始核对出生时间。」 + // 原因:issue #160(产品 2026-11-01,1A)档案里的人都能校正,进行中的案例按人物分别算 + const family = offerTool(); const other = await family.tool.execute({ explicitRequest: true }); - assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NOT_SELF_COPY); - assert.match(String(other.note), /给家人校正暂时还不支持/); - assert.doesNotMatch(String(other.note), /不要提校正/); - - const plainFamily = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) }); - const plain = await plainFamily.tool.execute({}); - assert.equal(plainFamily.state.rectificationDirectEntry, undefined); - assert.doesNotMatch(String(plain.note), /不要提校正/); + assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY); + assert.doesNotMatch(String(other.note), /给家人校正暂时还不支持/); const plainPrice = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) }); assert.doesNotMatch(String((await plainPrice.tool.execute({})).note), /不要提校正/); const plainOff = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) }); @@ -722,7 +719,10 @@ test("the offer rule is defined once, and direct entry settles with the free com // 原值:route 直接 getRectificationEntrySummary 后 from("agentic_rectification_cases").select("session_id") // 新值:route 调用 loadResumableRectificationSessionId,不再读这张表 // 原因:服务角色对该表没有读权限,直接读必然失败还被吞掉 - assert.match(route, /loadResumableRectificationSessionId\(accounting, userId\)/); + // 原值(2):loadResumableRectificationSessionId(accounting, userId) + // 新值(2):多传这次对话的人物 rectificationSubjectId(本人为 null) + // 原因(2):issue #160(1A)进行中的案例按人物分别算,接着做的只能是这个人的那次 + assert.match(route, /loadResumableRectificationSessionId\(accounting, userId, rectificationSubjectId\)/); assert.doesNotMatch(route, /\.from\("agentic_rectification_cases"\)/); assert.doesNotMatch(route, /\.select\("session_id"\)/); const serverFile = src("../src/lib/rectification-in-chat-server.ts"); @@ -770,8 +770,11 @@ test("the offer rule is defined once, and direct entry settles with the free com const surface = src("../src/hooks/use-rectification-surface.ts"); const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession")); - assert.match(fromChat, /return null/); - assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/); + // 原值:fromChat 先 rectificationLimitedToSelf()(非本人 return null),再 openRectificationCase("homepage", null, sourceSessionId) + // 新值:不再拦非本人,按当前人物开:openRectificationCase("homepage", null, sourceSessionId, readCurrentSubjectId()) + // 原因:issue #160 档案里的人都能做校正 + assert.doesNotMatch(fromChat, /rectificationLimitedToSelf|只支持本人/); + assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId, readCurrentSubjectId\(\)\)/); }); function cardHtml(record: RectificationOfferRecord) { diff --git a/frontend/tests/rectification-in-chat-20261007.test.ts b/frontend/tests/rectification-in-chat-20261007.test.ts index 897a5a9b..aff7113f 100644 --- a/frontend/tests/rectification-in-chat-20261007.test.ts +++ b/frontend/tests/rectification-in-chat-20261007.test.ts @@ -109,29 +109,29 @@ test("the server decides whether a card may be shown: self, product on, no case const yes = async () => true; const no = async () => false; const price = async () => 3; - assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "other", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: false, reason: "not_self" }); - assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" }); - assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" }); + // 原值:decideRectificationOfferWith({ subjectRole: "other", ... }) → { ok: false, reason: "not_self" },家人档案不出卡 + // 新值:不再收 subjectRole,没有 not_self;hasResumableCase 由服务端按这个人物去读(decideRectificationOffer 的 subjectId) + // 原因:issue #160(产品 2026-11-01,1A)档案里的人都能校正,进行中的案例按人物分别算。测试名沿用原名(frontend/AGENTS.md:测试名不得消失) + assert.deepEqual(await decideRectificationOfferWith({ productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" }); + assert.deepEqual(await decideRectificationOfferWith({ productEnabled: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" }); // 原值:只要摘要说有可续做校正,就判 case_in_progress // 新值:已采用(candidate_accepted)不再挡住入口;collecting_evidence 仍挡住 // 原因:已采用是终点。入口改看 blocksNewRectification,不改共享状态集合 assert.deepEqual(await decideRectificationOfferWith({ - subjectRole: "self", productEnabled: yes, hasResumableCase: async () => blocksNewRectification("candidate_accepted"), creditCost: price, }), { ok: true, creditCost: 3, questionEstimate: 8 }); assert.deepEqual(await decideRectificationOfferWith({ - subjectRole: "self", productEnabled: yes, hasResumableCase: async () => blocksNewRectification("collecting_evidence"), creditCost: price, }), { ok: false, reason: "case_in_progress" }); - assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" }); - assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 }); + assert.deepEqual(await decideRectificationOfferWith({ productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" }); + assert.deepEqual(await decideRectificationOfferWith({ productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 }); }); -function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) { +function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) { const state = createConsultationRuntimeState(); // 原值:建出来还没排盘也能直接提议。 // 新值:这些用例表示已经看过盘,所以先标计算完成。 @@ -171,11 +171,13 @@ test("the offer tool records one offer per turn and never re-offers unasked in t assert.equal(declined.state.rectificationOffer, undefined); assert.equal((await declined.tool.execute({ userAsked: true })).offered, true, "the user asking again opens it"); - const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) }); - const refused = await family.tool.execute({}); - assert.equal(refused.offered, false); - assert.match(String(refused.note), /给家人校正暂时还不支持/); - assert.equal(family.state.rectificationOffer, undefined); + // 原值:not_self 时不出卡,正文说「给家人校正暂时还不支持」 + // 新值:家人档案和本人一样出卡(是否出卡只看开关、这个人物有没有进行中的案例和价格) + // 原因:issue #160(产品 2026-11-01,1A) + const family = offerTool(); + const offeredForFamily = await family.tool.execute({}); + assert.equal(offeredForFamily.offered, true); + assert.doesNotMatch(String(offeredForFamily.note ?? ""), /给家人校正暂时还不支持/); }); test("the completed event carries the offer, so the card appears without a reload (BUG-1248)", () => { @@ -280,7 +282,10 @@ test("nothing is sent until the revisit button is clicked; the offer opens the c assert.match(cards, /onClick=\{\(\) => onRevisit\(record\.sourceQuestion\)\}/); assert.doesNotMatch(cards, /useEffect/); const surface = readFileSync(new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), "utf8"); - assert.match(surface, /openRectificationCase\("homepage", null, sourceSessionId\)/); + // 原值:openRectificationCase("homepage", null, sourceSessionId) + // 新值:多传当前人物 readCurrentSubjectId() + // 原因:issue #160 档案里的人都能做校正,案例记下是谁的盘 + assert.match(surface, /openRectificationCase\("homepage", null, sourceSessionId, readCurrentSubjectId\(\)\)/); assert.doesNotMatch(surface.slice(surface.indexOf("async function returnToRectificationSource")), /\/api\/consult/); }); diff --git a/frontend/tests/rectification-product-domain-route.test.ts b/frontend/tests/rectification-product-domain-route.test.ts index a8f900f3..ddeec28f 100644 --- a/frontend/tests/rectification-product-domain-route.test.ts +++ b/frontend/tests/rectification-product-domain-route.test.ts @@ -46,9 +46,12 @@ test("production open route reads owned consultation themes and fixes new-case t calls.push({fn,args}); if(fn==='get_agentic_rectification_skill_identity')return {error:null,data:{skill_name:skill.name,skill_version:skill.version,skill_sha256:skill.sha256,skill_source_commit:skill.sourceCommit}}; if(fn==='open_agentic_rectification_case')return {error:null,data:response()}; - if(fn==='open_agentic_rectification_case_v2'){ - if(args.p_intent==='session')assert.ok(!Object.hasOwn(args,'p_product_domain')); - else if(persistedDomain===null)persistedDomain=args.p_product_domain; + // 原值:exact session 也走 open_agentic_rectification_case_v2 + // 新值:exact session 走 v3(同样不带 p_product_domain),新开本人的 case 仍走 v2 + // 原因:issue #160 重开时要从服务端知道是谁的盘 + if(fn==='open_agentic_rectification_case_v2'||fn==='open_agentic_rectification_case_v3'){ + if(args.p_intent==='session'){assert.equal(fn,'open_agentic_rectification_case_v3');assert.ok(!Object.hasOwn(args,'p_product_domain'));} + else{assert.equal(fn,'open_agentic_rectification_case_v2');if(persistedDomain===null)persistedDomain=args.p_product_domain;} return {error:null,data:response()}; } throw new Error('unexpected RPC '+fn); @@ -99,7 +102,7 @@ test("production open route reads owned consultation themes and fixes new-case t const general=await post({intent:'homepage',requestId:body.requestId}); assert.equal(general.status,200); assert.equal(persistedDomain,'general'); assert.equal(queries.length,0); calls=[]; queries=[]; const exact=await post({intent:'session',sessionId,requestId:body.requestId}); assert.equal(exact.status,200); - assert.equal(queries.length,0); assert.ok(!Object.hasOwn(calls.find(c=>c.fn==='open_agentic_rectification_case_v2').args,'p_product_domain')); + assert.equal(queries.length,0); assert.ok(!Object.hasOwn(calls.find(c=>c.fn==='open_agentic_rectification_case_v3').args,'p_product_domain')); assert.equal((await post({...body,domain:'career'})).status,400); `; const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], { diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index 7e38ffda..4e3862e4 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -579,7 +579,7 @@ test("session open uses the Case-bound skill identity, not the current registry error: null, }; } - if (fn === "open_agentic_rectification_case_v2") { + if (fn === "open_agentic_rectification_case_v3") { captured.v2 = args; return { data: sessionOpenPayload(bound.version), error: null }; } @@ -594,11 +594,16 @@ test("session open uses the Case-bound skill identity, not the current registry }); assert.equal(response.disposition, "readonly"); + // 原值:第三步是 open_agentic_rectification_case_v2 + // 新值:第三步是 open_agentic_rectification_case_v3(同样的参数,仍不带 p_product_domain,也不带 p_chart_profile_id) + // 原因:issue #160 重开某次会话时要知道这次是谁的盘,v3 在回执里带上 chart_profile_id assert.deepEqual(captured.names, [ "open_agentic_rectification_case", "get_agentic_rectification_skill_identity", - "open_agentic_rectification_case_v2", + "open_agentic_rectification_case_v3", ]); + assert.equal(Object.hasOwn(captured.v2 ?? {}, "p_product_domain"), false); + assert.equal(Object.hasOwn(captured.v2 ?? {}, "p_chart_profile_id"), false); assert.equal(captured.v2?.p_session_id, sessionId); assert.equal(captured.v2?.p_skill_name, bound.name); assert.equal(captured.v2?.p_skill_version, "10.0.17"); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index 5289d15c..3ece9dee 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -103,6 +103,17 @@ export function fakeAccounting( const rawClient: FakeAccounting["client"] = { rpc(fn, args) { calls.push({ fn, args }); + // Issue #160: widening patches the case's person through this RPC + // instead of profiles.update. Recorded in the same shape so the widen + // tests keep asserting the same patch. + if (fn === "patch_agentic_rectification_case_uncertainty_v1" && !handlers[fn]) { + profilePatches.push({ + birth_time_source: "approximate", + uncertainty_before_minutes: args.p_before_minutes, + uncertainty_after_minutes: args.p_after_minutes, + }); + return Promise.resolve({ data: null, error: null }); + } // Production decisions now read the birth snapshot. Fakes that never // stubbed this RPC used to omit the date; a fictional early date keeps // modern probes askable and does not relax the adult floor. From 46273a169e80b95d7215dbc3ad4830e27514d120 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:26:04 +0800 Subject: [PATCH 2/2] docs: rectification for other people (issue #160) CHANGELOG, DESIGN, VOICE, onboarding red line 11 lifted, pre-work ledger entry for this session's blocked checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y --- CHANGELOG.md | 12 +++++++++++ docs/research/pre_work_error_ledger.md | 6 ++++++ .../tasks/TASK-onboarding-journey-20261022.md | 4 ++-- frontend/DESIGN.md | 16 ++++++++++++-- frontend/docs/VOICE.md | 21 +++++++++++++++---- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b68824..907d7ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 印度占星 Skill 更新日志 +## 2026-11-01 — 生时校正可以给档案里的其他人做 + +- 「生时校正」页标题旁可以换人,页面按当前选中的人显示:状态卡第一行写称呼和关系,核对一步列出称呼、关系、出生时间、出生城市,「改这份」打开这个人的资料。 +- 进行中的校正按人算:本人一个、每位家人各一个,可以同时进行(产品 2026-11-01 定)。 +- 采用或确认的时间只写回这个人的档案;本人的资料和本人的校正都不动。之后改了这个人的出生日期、时间或地点,采用的时间作废,和本人一样。 +- 删除一个人时,TA 的校正记录和校正对话一起删除,扣过的点不退,和删除对话一样。删除前的「将同时删除 N 个对话」也算上这些对话。 +- 对话里给家人也会出校正提议,明确说要校正时同样开始核对,不再说「给家人校正暂时还不支持」。别人的星盘上,上升提示也带「出生时间校正」链接;起步三问下面的「去校正」看的是当前这个人的出生时间。 +- 扣点不变:每次校正在第一条真实消息时扣一次,给谁做都一样。 +- 数据库迁移 `20261101010000_rectification_chart_subject.sql`,只加不删,以前的校正都算本人。 +- 还没做:校正对话里仍按「你」称呼,给别人做时怎么称呼待定。 +- Skill 版本不变(issue #160)。 + ## 2026-10-30 — 星盘页:盘旁「盘面数据」卡,表格和盘联动 - 盘的右边(手机上是盘的下面)新增「盘面数据」卡,九个页签:行星、虚星、特殊点、映射、八分点、星力、大运、敌友、瑜伽。原来散在「九曜」「Vimshottari 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 7387ea17..b2e6d6e0 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,12 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-11-01 · issue #160 生时校正给他人(worktree `/workspace/wt-160`) + +- `pre_work_check.py` exit 0,但五个 Python 步骤(运行时、碎片扫描、外部引擎适配器、远端可见性、focused)全部是「No usable project Python found」:本机没有项目 `.venv`,按约定不新建。外部引擎诊断与远端可见性这次都没有跑,不得写成预检通过。 +- Python 测试改用 `uv run --no-project --with pytest --with pyswisseph` 的临时环境跑,不写进仓库。 +- 没有 Docker,`tests/database-*.test.ts` 全部 skip;想在本机解包 Debian PostgreSQL 17 服务端二进制来跑 DB 测试,被会话权限拒绝,已删掉下载物,没有换别的办法绕过。新迁移只做了 libpg_query(pglast)语法和 PL/pgSQL 解析检查,没有在真库上执行过。 + ## 2026-09-30 · 盘型口径实现的预检与逐格平台诊断 - 开工 pre_work_check exit 1:remote verified、适配器/碎片步骤正常,focused 历史 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径断言失败;不制造镜像、不改断言。主会话保留完整 Node / Python 不同容器基线,最终分别逐名比对,不混用 npm 的 54 项与 quick 内 npm 的 56 项。 diff --git a/docs/tasks/TASK-onboarding-journey-20261022.md b/docs/tasks/TASK-onboarding-journey-20261022.md index f2709c85..46c3d7fe 100644 --- a/docs/tasks/TASK-onboarding-journey-20261022.md +++ b/docs/tasks/TASK-onboarding-journey-20261022.md @@ -71,7 +71,7 @@ 8. 不引第三方引导库或动画库。动效用 CSS 和 Web Animations API,复用 `TASK-site-motion-20261022.md` M1 的 `lib/motion/` 底座;提示用现有的 `@base-ui/react` Popover。 9. 系统开了 `prefers-reduced-motion` 时,D6 的全部动效退成淡入或直接出现,星点和闪烁去掉。 10. 清单的“做没做”**一律从真实数据推导**。唯一例外是“进过星盘页”(没有数据可推),见 T2。 -11. 生时校正仍只支持本人。帮别人看时,任何地方都不出现校正提议。 +11. ~~生时校正仍只支持本人。帮别人看时,任何地方都不出现校正提议。~~ 2026-11-01 解除(issue #160,产品定 1A 2A):档案里的人都能做生时校正,进行中的校正按人物分别算;帮别人看时,校正提议和起步三问下面的「去校正」看的是 TA 的出生时间。启程清单里的「做一次生时校正」仍只看本人。 12. 迁移只加不删(AGENTS.md §7.6):三列废列本单不删,关系列可为空。必须真跑 `npm run test:db`;本机没有 Docker 时,按已记录的无 Docker PG17 做法跑;仍做不了就写进 `BLOCKED.md`,不得写成通过。 13. 测试、fixture、文档里不得出现任何真实用户资料。 @@ -131,7 +131,7 @@ - `missingProfileStep()` 改为:本人资料完整,**或**档案里至少有一位资料完整的人,就算建完档。中途退出的,下次从缺的那一步接着问。 - 逐个核对事故实证 4 列出的调用点,按“当前人物”而不是“本人”判断: - 今日星语:本人没有出生资料时,首页不显示「今日星语」按钮。 - - 生时校正:当前人物不是本人时,不出现校正提议(红线 11)。 + - 生时校正:~~当前人物不是本人时,不出现校正提议(红线 11)。~~ 2026-11-01 起按当前人物判断(issue #160,红线 11 已解除)。 - 「和我合盘」:本人没有出生资料时,按钮位置改为文字「加上你自己后可合盘」。 - 顺手清理事故实证 6 的死代码,并修正 DESIGN.md §9 那一段。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index bf3e9ff2..440e2106 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1382,12 +1382,24 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` 推翻 2026-10-07「校正只从对话里的提议卡进入」(BUG-1247):产品要求校正单独有入口。 - **侧栏**:导航里「星盘」下面一项「生时校正」(Lucide `Clock3`),链接 `/rectification`。账户的 `rectification` 开关为 true 才出现;开关没到之前不出现,和「婚恋合盘」同一规则。 -- **`/rectification`**:沿用二级页外壳(`SecondaryPageShell`),标题旁不放人物切换(校正只支持本人,他人见 #160)。正文一栏宽 `min(100%, 680px)` 居中,上边距 32px(手机 20px,左右 16px)。一句说明,下面一张状态卡(`.rectification-page-card`:细边、`--radius-lg`、24px 内边距,手机 20px)。状态卡分没做过 / 进行中 / 已完成三种,状态只来自服务端 `entry-summary`(含会话 id、时间窗、确认过的大事数、采用时间)。进行中是两列数字(时间窗、已收大事);已完成时刻 28px 衬线数字。主按钮是组件库默认按钮,次按钮描边,都是真链接(`AppLink`)。「开始」「再校正一次」先进核对:两行资料表(手机上标签和值上下排),「开始校正」+ 链接「改这份」(`/people?self=1`)。开始后进首页校正面(`/?rectify=1` 续上或新开;做完之后再校正用 `/?rectify=new`),「继续」「查看结果」打开那次校正的会话(`/?c=`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。 +- **`/rectification`**:沿用二级页外壳(`SecondaryPageShell`),标题旁放人物切换(2026-11-01 起,见下节 #160;之前不放)。正文一栏宽 `min(100%, 680px)` 居中,上边距 32px(手机 20px,左右 16px)。一句说明,下面一张状态卡(`.rectification-page-card`:细边、`--radius-lg`、24px 内边距,手机 20px)。状态卡分没做过 / 进行中 / 已完成三种,状态只来自服务端 `entry-summary`(含会话 id、时间窗、确认过的大事数、采用时间)。进行中是两列数字(时间窗、已收大事);已完成时刻 28px 衬线数字。主按钮是组件库默认按钮,次按钮描边,都是真链接(`AppLink`)。「开始」「再校正一次」先进核对:两行资料表(手机上标签和值上下排),「开始校正」+ 链接「改这份」(`/people?self=1`)。开始后进首页校正面(`/?rectify=1` 续上或新开;做完之后再校正用 `/?rectify=new`),「继续」「查看结果」打开那次校正的会话(`/?c=`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。 - **对话回答下面**(`.rectification-offer-card`):改成一行,不再是带底色的卡片:灰字一句 + 主色下划线链接「去做生时校正」和 Lucide `ArrowRight`。点链接去 `/rectification`,不从这段对话开案例;对话进行中链接置灰不可点。回来后的分隔线、「按校正前的时间」不变(旧的已绑定案例照常显示)。 - **明确要求**:过渡句之后跳到 `/rectification`,不再直接打开校正面。 -- **建完档的首页**:起步三问下面一行浅底圆角条(`.starter-rectify-hint`,`--color-canvas-soft`),一句 + 描边胶囊「去校正」。只在本人出生时间是估的、首问还没用掉时出现,按启程进场依次浮出。 +- **建完档的首页**:起步三问下面一行浅底圆角条(`.starter-rectify-hint`,`--color-canvas-soft`),一句 + 描边胶囊「去校正」。只在当前人物出生时间是估的、首问还没用掉时出现(#160 之前只看本人),按启程进场依次浮出。 - **启程清单**:本人时间是估的账户在「问第一个问题」后多一项「做一次生时校正」,清单变成 8 项;其他账户仍是 7 项。 +## 生时校正给档案里的其他人(2026-11-01,issue #160) + +产品定 1A 2A:进行中的校正按人物分别算,可以同时进行;删人时 TA 的校正记录一起删除,扣过的点不退。 + +- **选人**:`/rectification` 用二级页默认的人物切换(`CurrentSubjectSwitcher`),页面跟着当前人物走;未登录时标题旁空着。换人后状态卡按新的人重读 `entry-summary?subject=`,上一个人的结果不会顶着显示,读取中仍用等待文案。 +- **状态卡第一行**(`.rectification-page-person`):称呼(正文字号、500)+ 关系小标签(`.rectification-page-person-relation`:细边、`--radius-sm`、小字、次要墨色)。一行不换行,称呼太长省略号截断,标签不挤到下一行。本人的标签写「本人」。不加说明句。 +- **核对**:资料表四行「称呼」「关系」「出生时间」「出生城市」,「改这份」打开这个人的编辑(`/people?edit=`;本人仍是 `/people?self=1`)。 +- **开始**:`/?rectify=1&rectifyFor=`(做完再校正 `/?rectify=new&rectifyFor=`);本人仍是不带参数的老地址。校正会话绑到那个人(`chat_sessions.chart_profile_id`),侧栏在那个人下面列出。 +- **星盘页上升提示**:别人的盘也带「出生时间校正」链接(以前只有本人带)。 +- **采用之后**:时间只写回那个人的档案;本人的资料和本人的校正不动。页面刷新人物列表和星盘、星历缓存。 +- **数据**:迁移 `20261101010000_rectification_chart_subject.sql`,只加不删:案例加 `chart_profile_id`(空 = 本人,老案例都是空),`chart_profiles` 加 `active_birth_provenance`。 + ## 用量页分段计时(2026-10-05,在途) `/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 6ed59777..71a885ae 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -48,7 +48,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。 - 每天的天象和行运在这里,可以按日期翻。 -首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。 +首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」。2026-11-01 起(issue #160)档案里的人都能做校正,不再写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。 ## 婚恋合盘报告(2026-10-19) @@ -88,14 +88,27 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 读不到:「校正记录这会儿读不到。」按钮「再试一次」。未登录:「登录后可以做生时校正。」「去登录」。开关关着:「生时校正这会儿还做不了。」本人资料不全:「先把你自己的出生日期、时间和地点填好,才能做生时校正。」「去填资料」。 - **对话回答下面**:一句「出生时间还不确定。」加链接「去做生时校正」,指向 `/rectification`。不写题数和点数,开没开过案都是这一句。 - **明确要求**:过渡句仍是「好,我们开始核对出生时间。」/「核对还没做完,我们接着做。」,之后跳到 `/rectification`,由页面给「开始」或「继续」。 -- **建完档的首页**:本人出生时间是估的(家人记得、时段、不知道),且首问还没用掉时,起步三问下面一行「出生时间还不确定,可以先做生时校正。」加按钮「去校正」。帮别人看时不出现。 +- **建完档的首页**:当前人物的出生时间是估的(家人记得、时段、不知道),且首问还没用掉时,起步三问下面一行「出生时间还不确定,可以先做生时校正。」加按钮「去校正」。2026-11-01 起帮别人看时也出现(issue #160),看的是 TA 的时间。 - **启程清单**:同样只对本人时间是估的账户多一项「做一次生时校正」,采用过时间就打勾。 +## 生时校正给档案里的其他人(2026-11-01,issue #160) + +产品 2026-11-01 定 1A 2A:进行中的校正按人物分别算(本人一个、每位家人各一个,可以同时进行);删除人物时 TA 的校正记录一起删除,扣过的点不退。 + +- **`/rectification`**:标题旁是人物切换,页面跟着当前人物走。状态卡第一行写这个人的称呼,后面一个小标签写关系(本人写「本人」,没填关系写「其他」)。不加说明句。 +- **状态卡**:别人没做过时小字是「TA 现在用的时间」;本人仍是「你现在用的时间」。进行中、已完成的写法不变,数的是这个人自己的那次。 +- **核对**:四行「称呼」「关系」「出生时间」「出生城市」,按钮「开始校正」,链接「改这份」(打开这个人的编辑)。 +- **资料不全**:本人仍是「先把你自己的出生日期、时间和地点填好,才能做生时校正。」;别人是「先把 TA 的出生日期、时间和地点填好,才能做生时校正。」按钮都是「去填资料」。 +- **首页校正面**:资料不全时一句「请先把 TA 的出生资料填完整,再开始生时校正。」 +- **对话里**:给家人和本人一样出提议、一样能「好,我们开始核对出生时间。」,有没有进行中的那次只看这个人自己的。 +- 扣点不变:每次校正在第一条真实消息时扣一次,给谁做都一样。 +- 校正对话里 agent 仍按「你」称呼(「我们来把你的出生时间缩小……」);给别人做时怎么称呼还没定,这次不改。 + ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) - **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 - **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经采用过时间的,不算还在进行,明说要校正就新开一次。真没做完的,直接接着做那一次。这次如果接不回原来的会话,也直接新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。 -- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。不再说「可在历史对话里接着做。」 +- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案和本人一样能开(issue #160),不再说「给家人校正暂时还不支持」。不再说「可在历史对话里接着做。」 - **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。 - **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。 - **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。 @@ -509,7 +522,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。 -星盘档案不写关系。人数到顶时只说「最多保存 20 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。 +星盘档案不写关系。人数到顶时只说「最多保存 20 个人的星盘」。生时校正对档案里的每个人都能用(issue #160),不再禁用入口,也不再写「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」这个数里也算上 TA 的生时校正对话:删人时 TA 的校正记录一起删除,扣过的点不退,和删除对话一样,不另写一句。本人不能删除,不要给删除按钮配一句绕过去的确认。 余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。