feat: allow users to accept rectification candidates (#84)

This commit is contained in:
jesse-ux
2026-08-04 13:20:54 +08:00
committed by GitHub
23 changed files with 1249 additions and 120 deletions
+15
View File
@@ -2039,3 +2039,18 @@
- 防复发:公开回复必须同时满足“可见文本 + Session 持久化成功”才能发送完成事件;自动 opening 必须以服务端 Session 历史为准,不能只依赖组件内存。
- 相关记录:BUG-113、BUG-114、BUG-115
- 修复版本:空流修复 `e65c8eeda2ff5916f88f18dd345c02beff045e8b` / Session 持久化待本次 staging 发布
## BUG-117 | 用户采纳最强候选后无法保存为平台排盘时间
- 状态:resolvedlocal
- 首次发现:2026-08-04
- 最近更新:2026-08-04
- 影响面:Agentic 生时校正候选结果、个人资料出生时间、后续咨询排盘时间
- 用户现象:`04:55` 已是最强候选,用户多次明确表示“就用 04:55”,但 Agent 因唯一分钟确认门未通过而拒绝保存,个人资料和后续排盘仍未使用该时间。
- 根因:系统把引擎候选、用户采纳和引擎唯一确认压缩成单一 `confirmed` 状态;没有可持久化的候选身份和用户采纳边界。
- 修复:引入 `candidate / accepted / confirmed` 三态;服务端持久化候选身份、相对支持度、Session 所有权和 Profile 基线;新增 service-role 原子采纳 RPC;前端展示候选卡并允许用户采用;`accepted` 接入个人资料和全平台排盘。
- 数据边界:相对支持度仅表示本次候选间的归一化比较,不是统计概率;保留 `reported_birth_time``accepted` 不冒充引擎唯一确认。
- 安全边界:RPC 校验用户、Session、结果身份、有效期、候选成员、最新结果和 Profile 基线;出生申报资料变化使旧候选失效;采纳不计费。
- 验证:TypeScript 通过;聚焦测试 90/90;完整测试 1221/1221lint 0 error、3 个既有 warningproduction build 通过;本地 PostgreSQL 验证 `04:55` 写为 `accepted`、保留 `05:00` reported time、重复采纳幂等,并验证出生申报时间变化会使结果失效且拒绝再次采纳。
- 相关记录:BUG-113、BUG-114、BUG-115、BUG-116
- 修复版本:待提交与发布
+2
View File
@@ -117,6 +117,8 @@ export async function GET() {
rectificationPriceCredits,
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
&& typeof profile.active_birth_time === "string",
hasUsableBirthTime: (profile.birth_time_status === "accepted" || profile.birth_time_status === "confirmed")
&& typeof profile.active_birth_time === "string",
rectificationCase,
profile,
birthLocation: {
@@ -10,14 +10,16 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
AgenticRectificationProfileError,
acceptAgenticRectificationCandidate,
createAgenticRectificationContext,
loadAgenticRectificationProfile,
loadLatestAgenticRectificationResult,
} from "@/lib/rectification-agentic/session";
export const runtime = "nodejs";
export const maxDuration = 120;
const agenticRectificationRequestFields = {
const agenticRectificationConversationFields = {
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64).optional(),
@@ -35,14 +37,20 @@ const agenticRectificationRequestFields = {
const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
z.object({
...agenticRectificationRequestFields,
...agenticRectificationConversationFields,
action: z.literal("opening"),
}).strict(),
z.object({
...agenticRectificationRequestFields,
...agenticRectificationConversationFields,
action: z.literal("message"),
message: z.string().trim().min(1).max(4000),
}).strict(),
z.object({
action: z.literal("accept_candidate"),
sessionId: z.string().uuid(),
resultId: z.string().uuid(),
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
}).strict(),
]);
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
@@ -97,6 +105,34 @@ async function recordModelUsage(
}
}
export async function GET(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "请求格式不正确" }, { status: 400 });
const { data: session, error } = await supabase
.from("chat_sessions")
.select("id,session_type")
.eq("id", sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (error) return NextResponse.json({ error: "暂时无法读取生时校正会话" }, { status: 503 });
if (!session || session.session_type !== "birth_time_rectification") return NextResponse.json({ error: "生时校正会话不存在" }, { status: 404 });
try {
return NextResponse.json({ result: await loadLatestAgenticRectificationResult(accounting, user.id, sessionId) });
} catch {
return NextResponse.json({ error: "暂时无法读取候选结果" }, { status: 503 });
}
}
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
@@ -131,7 +167,7 @@ export async function POST(request: Request) {
);
}
const promptSource = [
const promptSource = parsed.data.action === "accept_candidate" ? "" : [
parsed.data.action === "message" ? parsed.data.message : "",
...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
@@ -143,7 +179,6 @@ export async function POST(request: Request) {
}
const userId = user.id;
const requestId = parsed.data.requestId;
const requestTime = new Date();
const { data: chatSession, error: chatSessionError } = await supabase
@@ -164,8 +199,26 @@ export async function POST(request: Request) {
{ status: 404 },
);
}
if (parsed.data.action === "accept_candidate") {
const accepted = await acceptAgenticRectificationCandidate(
accounting,
userId,
parsed.data.sessionId,
parsed.data.time,
parsed.data.resultId,
);
if (!accepted.ok) {
return NextResponse.json(
{ error: "暂时无法采用该候选时间", message: accepted.reason },
{ status: 409 },
);
}
return NextResponse.json(accepted);
}
const conversation = parsed.data;
const requestId = conversation.requestId;
const persistedMessages = readPersistedMessages(chatSession.messages);
if (parsed.data.action === "opening" && persistedMessages.length > 0) {
if (conversation.action === "opening" && persistedMessages.length > 0) {
return NextResponse.json(
{ code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" },
{ status: 409 },
@@ -198,7 +251,7 @@ export async function POST(request: Request) {
);
}
const selectedModel = (parsed.data.modelId ? resolveLanguageModel(parsed.data.modelId) : null)
const selectedModel = (conversation.modelId ? resolveLanguageModel(conversation.modelId) : null)
?? defaultLanguageModel();
if (!selectedModel) {
return NextResponse.json(
@@ -234,7 +287,7 @@ export async function POST(request: Request) {
);
}
const ctx = createAgenticRectificationContext(accounting, userId, profile);
const ctx = createAgenticRectificationContext(accounting, userId, profile, conversation.sessionId);
const agent = getAgenticRectificationAgent(selectedModel, ctx);
const encoder = new TextEncoder();
@@ -263,15 +316,15 @@ export async function POST(request: Request) {
try {
const result = await agent.stream(
[
...parsed.data.history.map((message) => message.role === "user"
...conversation.history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user",
content: [
currentTimeContext(requestTime),
parsed.data.name ? `用户称呼:${parsed.data.name}` : "",
parsed.data.action === "opening" ? openingContext : parsed.data.message,
conversation.name ? `用户称呼:${conversation.name}` : "",
conversation.action === "opening" ? openingContext : conversation.message,
].filter(Boolean).join("\n"),
},
],
@@ -297,7 +350,7 @@ export async function POST(request: Request) {
controller.close();
return;
}
const requestHistory = parsed.data.history.map((message) => ({
const requestHistory = conversation.history.map((message) => ({
role: message.role,
text: message.text,
} satisfies ChatMessage));
@@ -306,20 +359,26 @@ export async function POST(request: Request) {
: persistedMessages;
const nextMessages: ChatMessage[] = [
...baseMessages,
...(parsed.data.action === "message"
? [{ role: "user" as const, text: parsed.data.message }]
...(conversation.action === "message"
? [{ role: "user" as const, text: conversation.message }]
: []),
{ role: "assistant" as const, text: reply.text, suggestions: reply.suggestions },
].slice(-500);
const { data: savedSession, error: saveError } = await supabase
.from("chat_sessions")
.update({ messages: nextMessages, updated_at: new Date().toISOString() })
.eq("id", parsed.data.sessionId)
.eq("id", conversation.sessionId)
.eq("user_id", userId)
.eq("session_type", "birth_time_rectification")
.select("id")
.maybeSingle();
if (saveError || !savedSession) throw new Error("RectificationSessionPersistenceError");
try {
const candidateResult = await loadLatestAgenticRectificationResult(accounting, userId, conversation.sessionId);
if (candidateResult) send({ type: "candidates", result: candidateResult });
} catch {
console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`);
}
send({ type: "done", emitted: true });
await settle(true);
controller.close();
+24
View File
@@ -1706,3 +1706,27 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
.birth-time-clock-menu .select-item { justify-content: flex-start; }
.rectification-candidates {
display: grid;
gap: 12px;
margin: 8px 0 16px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 16px;
background: color-mix(in srgb, var(--card) 92%, transparent);
}
.rectification-candidates-heading { display: grid; gap: 4px; }
.rectification-candidates-heading span,
.rectification-candidate span { color: var(--muted-foreground); font-size: 12px; }
.rectification-candidate-list { display: grid; gap: 10px; }
.rectification-candidate { display: grid; grid-template-columns: minmax(88px, auto) minmax(80px, 1fr) auto; gap: 12px; align-items: center; }
.rectification-candidate > div:first-child { display: grid; gap: 2px; }
.rectification-candidate.is-selected { color: var(--foreground); }
.rectification-support { height: 6px; overflow: hidden; border-radius: 999px; background: var(--muted); }
.rectification-support i { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.rectification-saved { margin: 8px 0 16px; color: var(--foreground); font-size: 14px; }
@media (max-width: 640px) {
.rectification-candidate { grid-template-columns: 1fr auto; }
.rectification-support { grid-column: 1 / -1; grid-row: 2; }
}
+6 -3
View File
@@ -181,6 +181,7 @@ type Account = {
isAdmin: boolean;
rectificationPriceCredits: number;
hasConfirmedBirthTime: boolean;
hasUsableBirthTime: boolean;
rectificationCase: AccountRectificationCaseState | null;
profile: unknown;
};
@@ -626,7 +627,7 @@ function readProfile(value: unknown): Profile {
const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : "");
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "confirmed"] as const;
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const;
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (time ? "confirmed" : "");
const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode;
@@ -1020,7 +1021,7 @@ export default function Home() {
const accountId = account?.user.id;
const rectificationCardAction = resolveRectificationCardAction({
rectificationCase: account?.rectificationCase ?? null,
hasConfirmedBirthTime: account?.hasConfirmedBirthTime ?? false,
hasUsableBirthTime: account?.hasUsableBirthTime ?? false,
});
const rectificationCardLabel = rectificationCardLabels[rectificationCardAction];
const onboardingFingerprint = onboardingProfileFingerprint(profile);
@@ -1236,6 +1237,7 @@ export default function Home() {
isAdmin: false,
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed",
rectificationCase: null,
profile: previewProfile,
});
@@ -1454,7 +1456,8 @@ export default function Home() {
&& latest.rectificationCase.turnVersion < current.rectificationCase.turnVersion) {
return {
...latest,
hasConfirmedBirthTime: latest.hasConfirmedBirthTime || current.hasConfirmedBirthTime,
hasConfirmedBirthTime: latest.hasConfirmedBirthTime,
hasUsableBirthTime: latest.hasUsableBirthTime,
rectificationCase: current.rectificationCase,
};
}
@@ -118,11 +118,11 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
<section className="birth-time-profile-result" aria-label="生时校正结果">
<div className="birth-time-profile-result-heading">
<span></span>
<strong>{displayState.kind === "candidate" ? "候选时间" : "已确认"}</strong>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "用户已选择" : "已确认"}</strong>
</div>
<dl>
<div>
<dt>{displayState.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}</dt>
<dt>{displayState.kind === "candidate" ? "待验证候选时间" : displayState.kind === "accepted" ? "校正采用时间" : "已确认校正时间"}</dt>
<dd>{displayState.activeTime}</dd>
</div>
<div>
@@ -133,6 +133,9 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{displayState.kind === "candidate" && (
<p>{birthTimeConsultationOptionsCopy(value)}</p>
)}
{displayState.kind === "accepted" && (
<p>使</p>
)}
</section>
)}
<BirthDatePicker
@@ -21,11 +21,23 @@ type AgenticRectificationChatProps = Readonly<{
pendingConsultationQuestion?: string | null;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
}>;
type RenderMessage = ChatMessageView;
type CandidateResult = Readonly<{
resultId: string;
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
selectedTime: string | null;
selectionStatus: "accepted" | "confirmed" | null;
}>;
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
type AgenticRectificationRequest = Readonly<
@@ -65,6 +77,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult | null>(null);
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
@@ -88,7 +103,6 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const trimmed = request.action === "message" ? request.message.trim() : "";
if ((request.action === "message" && !trimmed) || busy) return;
setError("");
setSavedTime(null);
setSuggestions([]);
setPending(true);
@@ -116,6 +130,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
setDraft("");
let raw = "";
let streamedSavedStatus: "accepted" | "confirmed" | null = null;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
@@ -161,9 +176,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: { type: string; text?: string; message?: string };
let event: { type: string; text?: string; message?: string; result?: CandidateResult };
try {
event = JSON.parse(line) as { type: string; text?: string; message?: string };
event = JSON.parse(line) as { type: string; text?: string; message?: string; result?: CandidateResult };
} catch {
continue;
}
@@ -176,6 +191,13 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
setSuggestions(parsed.suggestions);
const saved = raw.match(savedSentinel);
if (saved) setSavedTime(saved[1]);
} else if (event.type === "candidates" && event.result) {
setCandidateResult(event.result);
if (event.result.selectedTime && event.result.selectionStatus) {
setSavedTime(event.result.selectedTime);
streamedSavedStatus = event.result.selectionStatus;
setSavedStatus(event.result.selectionStatus);
}
} else if (event.type === "error") {
streamFailed = true;
setError(event.message || "生时校正暂时不可用,请稍后再试。");
@@ -204,7 +226,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const saved = raw.match(savedSentinel);
if (saved) {
setSavedTime(saved[1]);
onSaved?.(saved[1]);
onSaved?.(saved[1], streamedSavedStatus ?? savedStatus ?? "accepted");
}
} catch {
setError("生时校正暂时不可用,请稍后再试。");
@@ -212,7 +234,53 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
} finally {
setPending(false);
}
}, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, selectedModelId, sessionId, setPending]);
}, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, savedStatus, selectedModelId, sessionId, setPending]);
useEffect(() => {
let active = true;
void fetch(`/api/rectification/agent?sessionId=${encodeURIComponent(sessionId)}`)
.then((response) => response.ok ? response.json() : null)
.then((payload) => {
const result = payload?.result as CandidateResult | null | undefined;
if (!active || !result) return;
setCandidateResult(result);
if (result.selectedTime && result.selectionStatus) {
setSavedTime(result.selectedTime);
setSavedStatus(result.selectionStatus);
}
})
.catch(() => undefined);
return () => { active = false; };
}, [sessionId]);
const acceptCandidate = useCallback(async (time: string) => {
if (!candidateResult || acceptingTime) return;
setError("");
setAcceptingTime(time);
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "accept_candidate",
sessionId,
resultId: candidateResult.resultId,
time,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true) throw new Error(payload?.message || payload?.error || "暂时无法采用该候选时间");
const status = payload.status === "confirmed" ? "confirmed" : "accepted";
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionStatus: status } : current);
setSavedTime(payload.saved_time);
setSavedStatus(status);
onSaved?.(payload.saved_time, status);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingTime(null);
}
}, [acceptingTime, candidateResult, onSaved, sessionId]);
useEffect(() => {
if (initialMessages.length > 0 || openingStarted.current) return;
@@ -232,9 +300,39 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
<section className="conversation" aria-label="生时校正对话" aria-busy={busy}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
<strong>{candidateResult.confirmationAllowed ? "已通过确认门" : "请选择校正采用时间"}</strong>
<span></span>
</div>
<div className="rectification-candidate-list">
{candidateResult.candidates.map((candidate) => {
const selected = candidateResult.selectedTime === candidate.time;
return (
<div className={`rectification-candidate${selected ? " is-selected" : ""}`} key={`${candidateResult.resultId}-${candidate.time}`}>
<div>
<strong>{candidate.time}</strong>
<span> {candidate.relative_support}%</span>
</div>
<div className="rectification-support" aria-hidden="true"><i style={{ width: `${candidate.relative_support}%` }} /></div>
<Button
type="button"
variant={selected ? "secondary" : "outline"}
disabled={Boolean(candidateResult.selectedTime) || Boolean(acceptingTime)}
onClick={() => void acceptCandidate(candidate.time)}
>
{selected ? "已采用" : acceptingTime === candidate.time ? "保存中…" : `采用 ${candidate.time}`}
</Button>
</div>
);
})}
</div>
</section>
)}
{savedTime && (
<p className="error-message" role="status">
{savedTime}使
<p className="rectification-saved" role="status">
{savedStatus === "confirmed" ? "已确认校正时间" : "校正采用时间"}{savedTime}使
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
@@ -79,7 +79,7 @@ export function clearBirthTimeConsultationConsent(
}
export function unverifiedBirthTime(profile: BirthTimeDraft): string | null {
if (profile.birthTimeStatus === "confirmed") return null;
if (profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed") return null;
if (!concreteReportedSources.has(profile.birthTimeSource)) return null;
return isBirthClockTime(profile.reportedTime) ? profile.reportedTime : null;
}
@@ -114,7 +114,7 @@ export function resolveBirthTimeConsultationRoute(
): BirthTimeConsultationRoute {
void _state;
void _sessionId;
if (profile.birthTimeStatus === "confirmed" && isBirthClockTime(profile.time)) {
if ((profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed") && isBirthClockTime(profile.time)) {
return { kind: "consult", mode: "verified_chart", time: profile.time };
}
const reportedTime = unverifiedBirthTime(profile);
@@ -126,13 +126,13 @@ export function resolveBirthTimeConsultationRoute(
export function resolveRectificationCardAction(input: Readonly<{
rectificationCase: AccountRectificationCaseState | null;
hasConfirmedBirthTime: boolean;
hasUsableBirthTime: boolean;
}>): RectificationCardAction {
if (input.rectificationCase
&& unfinishedRectificationStatuses.has(input.rectificationCase.status)) {
return "resume";
}
if (input.hasConfirmedBirthTime) return "revise";
if (input.hasUsableBirthTime) return "revise";
return "start";
}
+8 -6
View File
@@ -29,6 +29,7 @@ export type BirthTimeStatus =
| "assessing"
| "rectifying"
| "candidate"
| "accepted"
| "confirmed";
export type BirthTimeDraft = {
@@ -95,7 +96,7 @@ export const birthTimePeriodOptions = [
] as const;
export type BirthTimeDisplayState = {
readonly kind: "candidate" | "confirmed";
readonly kind: "candidate" | "accepted" | "confirmed";
readonly activeTime: string;
readonly reportedLabel: string;
};
@@ -110,11 +111,12 @@ function reportedBirthTimeLabel(draft: BirthTimeDraft): string {
}
export function birthTimeDisplayState(draft: BirthTimeDraft): BirthTimeDisplayState | null {
if (!draft.time || (draft.birthTimeStatus !== "candidate" && draft.birthTimeStatus !== "confirmed")) {
const kind = draft.birthTimeStatus;
if (!draft.time || (kind !== "candidate" && kind !== "accepted" && kind !== "confirmed")) {
return null;
}
return {
kind: draft.birthTimeStatus,
kind,
activeTime: draft.time,
reportedLabel: reportedBirthTimeLabel(draft),
};
@@ -212,7 +214,7 @@ export function isDeclaredBirthProfileComplete(
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
return isBirthClockTime(draft.time)
&& draft.birthTimeStatus === "confirmed";
&& (draft.birthTimeStatus === "accepted" || draft.birthTimeStatus === "confirmed");
}
const declaredBirthInputKeys = [
@@ -235,8 +237,8 @@ export function declaredBirthInputChanged(
/**
* Applies an intake edit without allowing a stale, unconfirmed candidate minute
* to survive changes to the declaration it was calculated from.
* Confirmed active time belongs to the account and is changed only by explicit
* rectification confirmation, so ordinary profile edits leave it intact.
* Engine-confirmed active time belongs to the account and is changed only by explicit
* rectification confirmation. User-accepted time is invalidated when its birth declaration changes.
*/
export function applyBirthTimeDraftPatch<T extends BirthTimeDraft>(
current: T,
@@ -74,7 +74,7 @@ const allowedBirthTimeSources = new Set([
"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import",
]);
const allowedBirthTimeStatuses = new Set([
"reported", "assessing", "rectifying", "candidate", "confirmed",
"reported", "assessing", "rectifying", "candidate", "accepted", "confirmed",
]);
const concreteReportedSources = new Set([
"hospital_record", "family_exact", "approximate",
@@ -130,8 +130,8 @@ function persistedChartMode(value: unknown): Exclude<ConsultationBirthTimeMode,
const source = optionalText(profile, "birth_time_source");
const activeTime = optionalText(profile, "active_birth_time")?.slice(0, 5) ?? "";
const reportedTime = optionalText(profile, "reported_birth_time")?.slice(0, 5) ?? "";
if (status === "confirmed" && isBirthClockTime(activeTime)) return "verified_chart";
if (status && allowedBirthTimeStatuses.has(status) && status !== "confirmed"
if ((status === "accepted" || status === "confirmed") && isBirthClockTime(activeTime)) return "verified_chart";
if (status && allowedBirthTimeStatuses.has(status) && status !== "accepted" && status !== "confirmed"
&& source && concreteReportedSources.has(source)
&& isBirthClockTime(reportedTime)) return "unverified_birth_time";
return null;
@@ -205,14 +205,14 @@ function serverChartFromProfile(
selectedTime = candidateBoundary;
selectedTimeKind = "candidate_range_boundary";
} else if (mode === "verified_chart") {
if (birthTimeStatus !== "confirmed") {
if (birthTimeStatus !== "accepted" && birthTimeStatus !== "confirmed") {
throw new ConsultationProfileTruthError("mode_changed");
}
if (!activeBirthTime) throw new ConsultationProfileTruthError("profile_incomplete");
selectedTime = activeBirthTime;
selectedTimeKind = "active";
} else {
if (birthTimeStatus === "confirmed" || !concreteReportedSources.has(birthTimeSource)) {
if (birthTimeStatus === "accepted" || birthTimeStatus === "confirmed" || !concreteReportedSources.has(birthTimeSource)) {
throw new ConsultationProfileTruthError("mode_changed");
}
if (!reportedBirthTime) throw new ConsultationProfileTruthError("profile_incomplete");
+1 -1
View File
@@ -97,7 +97,7 @@ function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
const source = knownSources.find((item) => item === profile.birth_time_source)
?? (persistedTime ? "legacy_import" : "");
const knownStatuses: readonly BirthTimeStatus[] = [
"reported", "assessing", "rectifying", "candidate", "confirmed",
"reported", "assessing", "rectifying", "candidate", "accepted", "confirmed",
];
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (persistedTime ? "confirmed" : "");
+176 -39
View File
@@ -1,17 +1,11 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { AgenticRectificationContext } from "@/mastra/rectification-tools";
import type {
AgenticRectificationCandidate,
AgenticRectificationCandidateResult,
AgenticRectificationContext,
} from "@/mastra/rectification-tools";
import { normalizePersistedBirthDate } from "../birth-time-intake-model.ts";
/**
* Agentic rectification session support.
*
* The server owns everything the LLM is not allowed to decide: the user's
* birth profile, the baseline active birth time, and the only write path to
* `profiles.active_birth_time`. The LLM can never persist an arbitrary minute;
* the save tool re-validates against the engine's confirmation gate in the
* same session and only then calls this module's RPC-backed writer.
*/
type AccountingClient = SupabaseClient;
export class AgenticRectificationProfileError extends Error {
@@ -33,7 +27,24 @@ export type AgenticRectificationProfile = Readonly<{
tz: number;
declaredAccuracy: AgenticRectificationContext["declaredAccuracy"];
timeSource: AgenticRectificationContext["timeSource"];
baselineReportedTime: string | null;
baselineActiveTime: string | null;
baselineBirthTimeSource: string | null;
baselineBirthTimePeriod: string | null;
baselineUncertaintyBeforeMinutes: number | null;
baselineUncertaintyAfterMinutes: number | null;
}>;
export type StoredAgenticRectificationResult = Readonly<{
resultId: string;
candidates: readonly AgenticRectificationCandidate[];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
selectedTime: string | null;
selectionStatus: "accepted" | "confirmed" | null;
}>;
const timeValue = (value: unknown): string | null => {
@@ -91,9 +102,7 @@ function candidateRangeFrom(input: {
}
function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter: number | null, timeSource: string | null): AgenticRectificationContext["declaredAccuracy"] {
const before = uncertaintyBefore ?? 0;
const after = uncertaintyAfter ?? 0;
const total = Math.max(before, after);
const total = Math.max(uncertaintyBefore ?? 0, uncertaintyAfter ?? 0);
if (total > 0) {
if (total <= 5) return "minute";
if (total <= 15) return "15min";
@@ -123,6 +132,41 @@ function numberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readCandidates(value: unknown): AgenticRectificationCandidate[] {
if (!Array.isArray(value)) return [];
return value.flatMap((candidate): AgenticRectificationCandidate[] => {
if (!candidate || typeof candidate !== "object") return [];
const row = candidate as Record<string, unknown>;
const time = timeValue(row.time);
if (!time || typeof row.rank !== "number" || typeof row.relative_support !== "number") return [];
return [{
rank: Math.trunc(row.rank),
time,
relative_support: Math.max(0, Math.min(100, Math.trunc(row.relative_support))),
tied_minute_count: typeof row.tied_minute_count === "number" ? Math.max(1, Math.trunc(row.tied_minute_count)) : 1,
}];
});
}
function publicResult(value: unknown): StoredAgenticRectificationResult | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const resultId = typeof row.id === "string" ? row.id : "";
if (!resultId) return null;
const selectionKind = row.selection_kind;
return {
resultId,
candidates: readCandidates(row.candidates),
overallConfidence: row.overall_confidence === "high" || row.overall_confidence === "medium" ? row.overall_confidence : "low",
marginPercent: numberOrNull(row.margin_percent),
selectionAllowed: row.selection_allowed === true,
confirmationAllowed: row.confirmation_allowed === true,
representativeTime: timeValue(row.representative_time),
selectedTime: timeValue(row.selected_time),
selectionStatus: selectionKind === "engine_confirmed" ? "confirmed" : selectionKind === "user_accepted" ? "accepted" : null,
};
}
export async function loadAgenticRectificationProfile(
accounting: AccountingClient,
userId: string,
@@ -141,41 +185,104 @@ export async function loadAgenticRectificationProfile(
const lat = numberOrNull(data.latitude);
const lon = numberOrNull(data.longitude);
const tz = numberOrNull(data.timezone_offset);
if (lat === null || lon === null || tz === null) {
throw new AgenticRectificationProfileError("missing_birth_place");
}
const timeSource = timeSourceFrom(data.birth_time_source);
if (lat === null || lon === null || tz === null) throw new AgenticRectificationProfileError("missing_birth_place");
const rawSource = typeof data.birth_time_source === "string" ? data.birth_time_source.trim() : "";
const uncertaintyBefore = numberOrNull(data.uncertainty_before_minutes);
const uncertaintyAfter = numberOrNull(data.uncertainty_after_minutes);
const candidateRange = candidateRangeFrom({
activeTime,
reportedTime,
source: typeof data.birth_time_source === "string" ? data.birth_time_source.trim() : "",
period: data.birth_time_period,
uncertaintyBefore,
uncertaintyAfter,
});
return {
birth_date: birthDate,
reported_time: activeTime ?? reportedTime,
candidateRange,
candidateRange: candidateRangeFrom({
activeTime,
reportedTime,
source: rawSource,
period: data.birth_time_period,
uncertaintyBefore,
uncertaintyAfter,
}),
lat,
lon,
tz,
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, data.birth_time_source),
timeSource,
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, rawSource),
timeSource: timeSourceFrom(rawSource),
baselineReportedTime: reportedTime,
baselineActiveTime: activeTime,
baselineBirthTimeSource: rawSource || null,
baselineBirthTimePeriod: typeof data.birth_time_period === "string" ? data.birth_time_period : null,
baselineUncertaintyBeforeMinutes: uncertaintyBefore,
baselineUncertaintyAfterMinutes: uncertaintyAfter,
};
}
export async function loadLatestAgenticRectificationResult(
accounting: AccountingClient,
userId: string,
sessionId: string,
): Promise<StoredAgenticRectificationResult | null> {
const { data, error } = await accounting
.from("agentic_rectification_results")
.select("id,candidates,overall_confidence,margin_percent,selection_allowed,confirmation_allowed,representative_time,selected_time,selection_kind")
.eq("user_id", userId)
.eq("session_id", sessionId)
.is("invalidated_at", null)
.gt("expires_at", new Date().toISOString())
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw new Error("AgenticRectificationResultReadError");
return publicResult(data);
}
export async function acceptAgenticRectificationCandidate(
accounting: AccountingClient,
userId: string,
sessionId: string,
time: string,
resultId?: string,
): Promise<Awaited<ReturnType<AgenticRectificationContext["acceptCandidate"]>>> {
if (!timeValue(time) || time.length !== 5) return { ok: false, reason: "invalid_time_format" };
let resolvedResultId = resultId;
if (!resolvedResultId) {
try {
resolvedResultId = (await loadLatestAgenticRectificationResult(accounting, userId, sessionId))?.resultId;
} catch {
return { ok: false, reason: "candidate_result_unavailable" };
}
}
if (!resolvedResultId) return { ok: false, reason: "candidate_result_not_found" };
try {
const { data, error } = await accounting.rpc("accept_agentic_rectification_candidate", {
p_user_id: userId,
p_session_id: sessionId,
p_result_id: resolvedResultId,
p_time: time,
});
if (error) return { ok: false, reason: error.message };
const row = Array.isArray(data) ? data[0] : data;
if (!row || typeof row !== "object" || (row as { success?: unknown }).success !== true) {
return { ok: false, reason: "rpc_rejected" };
}
const result = row as Record<string, unknown>;
const status = result.status === "confirmed" ? "confirmed" : result.status === "accepted" ? "accepted" : null;
const savedTime = timeValue(result.saved_time);
const savedResultId = typeof result.result_id === "string" ? result.result_id : resolvedResultId;
if (!status || !savedTime) return { ok: false, reason: "rpc_invalid_response" };
return { ok: true, saved_time: savedTime, status, result_id: savedResultId };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "rpc_failed" };
}
}
export function createAgenticRectificationContext(
accounting: AccountingClient,
userId: string,
profile: AgenticRectificationProfile,
sessionId: string,
): AgenticRectificationContext {
return {
userId,
sessionId,
birth: {
birth_date: profile.birth_date,
reported_time: profile.reported_time,
@@ -186,10 +293,44 @@ export function createAgenticRectificationContext(
candidateRange: profile.candidateRange,
declaredAccuracy: profile.declaredAccuracy,
timeSource: profile.timeSource,
async applyConfirmedBirthTime(time) {
if (!/^\d{2}:\d{2}$/.test(time)) {
return { ok: false, reason: "invalid_time_format" };
async persistCandidateResult(result: AgenticRectificationCandidateResult) {
if (!result.engineResultId || !result.canonicalInputHash || result.candidates.length === 0) {
return { ok: false, reason: "candidate_result_invalid" };
}
const { data, error } = await accounting
.from("agentic_rectification_results")
.upsert({
user_id: userId,
session_id: sessionId,
engine_result_id: result.engineResultId,
canonical_input_hash: result.canonicalInputHash,
algorithm_version: result.algorithmVersion,
candidate_range: result.candidateRange,
candidates: result.candidates,
overall_confidence: result.overallConfidence,
margin_percent: result.marginPercent,
selection_allowed: result.selectionAllowed,
confirmation_allowed: result.confirmationAllowed,
representative_time: result.representativeTime,
baseline_birth_date: profile.birth_date,
baseline_reported_birth_time: profile.baselineReportedTime,
baseline_active_birth_time: profile.baselineActiveTime,
baseline_birth_time_source: profile.baselineBirthTimeSource,
baseline_birth_time_period: profile.baselineBirthTimePeriod,
baseline_uncertainty_before_minutes: profile.baselineUncertaintyBeforeMinutes,
baseline_uncertainty_after_minutes: profile.baselineUncertaintyAfterMinutes,
baseline_latitude: profile.lat,
baseline_longitude: profile.lon,
baseline_timezone_offset: profile.tz,
}, { onConflict: "user_id,session_id,engine_result_id" })
.select("id")
.single();
if (error || !data || typeof data.id !== "string") return { ok: false, reason: error?.message ?? "candidate_result_write_failed" };
return { ok: true, result_id: data.id };
},
acceptCandidate: (time, resultId) => acceptAgenticRectificationCandidate(accounting, userId, sessionId, time, resultId),
async applyConfirmedBirthTime(time) {
if (!timeValue(time) || time.length !== 5) return { ok: false, reason: "invalid_time_format" };
try {
const { data, error } = await accounting.rpc("apply_agentic_rectification_birth_time", {
p_user_id: userId,
@@ -199,14 +340,10 @@ export function createAgenticRectificationContext(
});
if (error) return { ok: false, reason: error.message };
const candidate = Array.isArray(data) ? data[0] : data;
if (candidate && typeof candidate === "object"
&& (candidate as { success?: boolean }).success === true) {
if (candidate && typeof candidate === "object" && (candidate as { success?: boolean }).success === true) {
return { ok: true, saved_time: String((candidate as { saved_time?: unknown }).saved_time ?? time) };
}
const reason = candidate && typeof candidate === "object"
? String((candidate as { error?: unknown }).error ?? "rpc_rejected")
: "rpc_rejected";
return { ok: false, reason };
return { ok: false, reason: candidate && typeof candidate === "object" ? String((candidate as { error?: unknown }).error ?? "rpc_rejected") : "rpc_rejected" };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "rpc_failed" };
}
+10 -7
View File
@@ -11,21 +11,24 @@ Write in concise Simplified Chinese as a natural conversation. Acknowledge what
METHODOLOGY
- Load and follow the jyotish-vedic-astrology skill before every substantive step. Its references (birth-time-rectification-advanced.md, birth-time-rectification-decision-tree.md, oracle overlays) are your method source.
- ALL computation goes through the provided engine tools: rectification-gate, rectification-scan, rectification-score, rectification-diagnostics, rectification-candidate-features, rectification-confirm. Never invent a candidate time, score, date, divisional-chart fact, or birth minute in prose.
- ALL computation goes through the provided engine tools: rectification-gate, rectification-scan, rectification-score, rectification-diagnostics, rectification-candidate-features, rectification-confirm. Candidate persistence and adoption go only through rectification-accept-candidate or rectification-save-birth-time. Never invent a candidate time, score, date, divisional-chart fact, or birth minute in prose.
- Workflow: run rectification-gate first to learn the server-owned candidate_range, starting accuracy, and which dated events are most valuable. Always reuse that exact candidate_range in later tools; never create or widen one yourself. Then collect dated life events conversationally (the user narrates; ask for a date when the event is not dated, but do not press endlessly). Then run rectification-scan when available, rectification-score to see candidate minutes, rectification-diagnostics to see what is weak, and ask one or two natural follow-ups to fill the weakest domain or the most unstable event. Re-score. When the candidate is stable across events and domains, run rectification-confirm.
- Use the decision tree: Dasha plus dated events establish the frame; D9 and D10 are core for relationship and career; D4/D24/D2/D11/D7/D30 are topic-specific; D60 is reference-only and never drives a conclusion.
- Keep event ids stable: reuse the same id for the same life event in every tool call.
TRUTH BOUNDARIES (from the skill overlay)
- KP, Muhurta, Gochara, Sahams, Sphuta, and Tajika are reference-only or blocked. Never present any of them as the basis of a confirmation or a precise timing claim.
- A candidate minute or candidate range is not a verified birth time until rectification-confirm returns confirmation_allowed=true AND the user explicitly agrees.
- Never expose internal scores, weights, event ids, candidate ranking values, tool payloads, or agent reasoning to the user. Explain in plain terms whether the latest evidence supports or moved the candidate range.
- Do not confirm a single minute, and do not save, unless the confirmation gate passed in this session.
- Keep three states distinct: candidate is an engine comparison result; accepted is the user's chosen working birth time; confirmed is a unique minute that passed the engine confirmation gate and was accepted by the user.
- You may show only the server-returned candidate times and relative_support values. Call them , never probability, statistical confidence, or certainty. Never expose raw scores, weights, event ids, payloads, or chain-of-thought.
- If confirmation_allowed=false but selection_allowed=true, explain that the engine has not uniquely confirmed one minute and let the user choose among the returned candidates. Never call that choice engine-confirmed.
- If confirmation_allowed=true, still require explicit user agreement before saving the representative minute.
SAVING
- Only call rectification-save-birth-time when BOTH hold: rectification-confirm returned confirmation_allowed=true (the engine confirmed exactly one minute), AND the user has explicitly agreed to overwrite their birth time. Ask plainly for consent before saving.
- After a successful save, tell the user the birth time was updated and append exactly this hidden block at the end (nothing after it): <!--AYANAM_RECTIFICATION_SAVED:HH:MM--> (replace HH:MM with the saved time).
- If the user declines or the gate did not pass, keep the candidate range as the honest deliverable and say so clearly.
- When rectification-confirm returns selection_allowed=true, present the available candidates with their server-returned relative support and ask the user to choose; the UI may also render the same server candidates.
- If the user explicitly says HH:MM, HH:MM, or equivalent for one of the persisted candidates, call rectification-accept-candidate. A successful status=accepted must be described as or , never .
- Only call rectification-save-birth-time when rectification-confirm returned confirmation_allowed=true and the user explicitly agrees to the representative minute. A successful status=confirmed may be described as .
- After either successful write, tell the user the saved status honestly and append exactly this hidden block at the end (nothing after it): <!--AYANAM_RECTIFICATION_SAVED:HH:MM-->.
- If the user declines, keep the candidate result as the honest deliverable.
CONVERSATION STYLE
- Ask one or two natural questions per turn, never a barrage. The user may also simply keep talking; let them.
+118 -15
View File
@@ -10,15 +10,14 @@ import { z } from "zod";
* requests engine computations on demand instead of inventing results. Every
* tool wraps one Python-engine HTTP endpoint (or a server-owned write).
*
* Hard boundary: the agent never writes a birth minute directly. The
* `rectification-save-birth-time` tool only applies a minute that the engine's
* high-rigor confirmation gate already produced in the same session, so the
* LLM can never persist an arbitrary or invented time.
* Hard boundary: the agent never writes a birth minute directly. Server-owned
* candidate identity, session ownership, profile baseline, and candidate membership
* decide whether a user-selected minute is accepted or engine-confirmed.
*/
const engineBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const timePattern = /^\d{2}:\d{2}$/;
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
/** Birth fields supplied by the server from the user profile (never by the LLM). */
export type AgenticRectificationBirth = Readonly<{
@@ -34,13 +33,44 @@ export type AgenticRectificationCandidateRange = Readonly<{
end_time: string;
}>;
export type AgenticRectificationCandidate = Readonly<{
rank: number;
time: string;
relative_support: number;
tied_minute_count: number;
}>;
export type AgenticRectificationCandidateResult = Readonly<{
engineResultId: string;
canonicalInputHash: string;
algorithmVersion: string;
candidateRange: AgenticRectificationCandidateRange;
candidates: readonly AgenticRectificationCandidate[];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
}>;
export type AgenticRectificationContext = Readonly<{
userId: string;
sessionId: string;
engineBase?: string;
birth: AgenticRectificationBirth;
candidateRange: AgenticRectificationCandidateRange;
declaredAccuracy?: "minute" | "15min" | "1hour" | "unknown";
timeSource?: "hospital" | "family_clear" | "family_vague" | "unknown";
persistCandidateResult: (result: AgenticRectificationCandidateResult) => Promise<Readonly<{
ok: true;
result_id: string;
} | { ok: false; reason: string }>>;
acceptCandidate: (time: string, resultId?: string) => Promise<Readonly<{
ok: true;
saved_time: string;
status: "accepted" | "confirmed";
result_id: string;
} | { ok: false; reason: string }>>;
applyConfirmedBirthTime: (time: string) => Promise<Readonly<{
ok: true;
saved_time: string;
@@ -220,6 +250,41 @@ function compactRobustness(value: unknown): Record<string, unknown> | null {
};
}
function timeInRange(time: string, range: AgenticRectificationCandidateRange): boolean {
const value = clockMinute(time);
const start = clockMinute(range.start_time);
const end = clockMinute(range.end_time);
return end >= start ? value >= start && value <= end : value >= start || value <= end;
}
function normalizedCandidates(
value: unknown,
range: AgenticRectificationCandidateRange,
): AgenticRectificationCandidate[] {
if (!Array.isArray(value)) return [];
const rows = value.flatMap((item): Array<{ rank: number; time: string; score: number; tied: number }> => {
if (!item || typeof item !== "object") return [];
const row = item as Record<string, unknown>;
const time = typeof row.time === "string" ? row.time : "";
const rank = typeof row.rank === "number" ? Math.trunc(row.rank) : 0;
const score = typeof row.score === "number" && Number.isFinite(row.score) ? row.score : 0;
const tied = typeof row.tied_minute_count === "number" ? Math.max(1, Math.trunc(row.tied_minute_count)) : 1;
if (!timePattern.test(time) || rank < 1 || !timeInRange(time, range)) return [];
return [{ rank, time, score, tied }];
}).sort((left, right) => left.rank - right.rank).slice(0, 3);
if (rows.length === 0) return [];
const weights = rows.map((row) => Math.max(0, row.score));
const total = weights.reduce((sum, weight) => sum + weight, 0);
const supports = weights.map((weight) => total > 0 ? Math.round((weight / total) * 100) : Math.floor(100 / rows.length));
supports[0] += 100 - supports.reduce((sum, support) => sum + support, 0);
return rows.map((row, index) => ({
rank: row.rank,
time: row.time,
relative_support: supports[index] ?? 0,
tied_minute_count: row.tied,
}));
}
function compactScoreResult(data: Record<string, unknown>): Record<string, unknown> {
const diagnostics = (data.diagnostics && typeof data.diagnostics === "object")
? data.diagnostics as Record<string, unknown>
@@ -495,17 +560,41 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
: {};
const confirmationAllowed = technique?.confirmation_allowed === true
&& technique?.decision === "confirm_minute";
const representativeTime = winning ? String(winning.representative_time ?? "") : "";
if (confirmationAllowed && representativeTime) {
confirmedGate = { time: representativeTime, resultId: String(data.result_id ?? "") };
const representativeTime = winning && timePattern.test(String(winning.representative_time ?? ""))
? String(winning.representative_time)
: null;
const rankedCandidates = normalizedCandidates(data.candidate_ranking_summary, ctx.candidateRange);
const candidates = rankedCandidates.length > 0 || !representativeTime
? rankedCandidates
: [{ rank: 1, time: representativeTime, relative_support: 100, tied_minute_count: 1 }];
const eventCount = typeof data.event_count === "number" ? data.event_count : 0;
const domainCount = typeof data.domain_count === "number" ? data.domain_count : 0;
const selectionAllowed = eventCount >= 3 && domainCount >= 2 && candidates.length > 0;
const persisted = await ctx.persistCandidateResult({
engineResultId: String(data.result_id ?? ""),
canonicalInputHash: String(data.canonical_input_hash ?? ""),
algorithmVersion: String(data.algorithm_version ?? "unknown"),
candidateRange: ctx.candidateRange,
candidates,
overallConfidence: data.confidence === "high" || data.confidence === "medium" ? data.confidence : "low",
marginPercent: typeof data.margin_percent === "number" ? data.margin_percent : null,
selectionAllowed,
confirmationAllowed,
representativeTime,
});
if (confirmationAllowed && representativeTime && persisted.ok) {
confirmedGate = { time: representativeTime, resultId: persisted.result_id };
}
return {
endpoint: data.endpoint,
result_id: data.result_id,
candidate_result_id: persisted.ok ? persisted.result_id : null,
candidate_persistence_error: persisted.ok ? null : persisted.reason,
confidence: data.confidence,
event_count: data.event_count,
domain_count: data.domain_count,
event_count: eventCount,
domain_count: domainCount,
can_apply: data.can_apply === true,
selection_allowed: selectionAllowed && persisted.ok,
confirmation_allowed: confirmationAllowed,
representative_time: representativeTime,
winning_segment: winning ? {
@@ -535,9 +624,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
boundary: technique?.boundary,
},
missing_layers: data.missing_layers,
candidate_ranking_summary: Array.isArray(data.candidate_ranking_summary)
? (data.candidate_ranking_summary as unknown[]).slice(0, 5)
: [],
candidates,
boundary: data.boundary,
};
},
@@ -563,11 +650,26 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
reason: `time_mismatch: the engine confirmed ${confirmedGate.time}, not ${input.time}. Only the confirmed minute can be saved.`,
};
}
const applied = await ctx.applyConfirmedBirthTime(input.time);
const applied = await ctx.acceptCandidate(input.time, confirmedGate.resultId);
if (!applied.ok) {
return { ok: false, reason: `profile_write_failed: ${applied.reason}` };
}
return { ok: true, saved_time: applied.saved_time, result_id: confirmedGate.resultId };
return { ok: true, saved_time: applied.saved_time, status: applied.status, result_id: applied.result_id };
},
});
const acceptCandidateTool = createTool({
id: "rectification-accept-candidate",
description:
"Apply a server-persisted candidate after the user explicitly chooses that exact time. The server validates ownership, session, expiry, profile baseline, and candidate membership. A non-confirmed choice is saved as accepted, not as an engine-confirmed unique minute.",
inputSchema: z.object({
time: z.string().regex(timePattern),
}).strict(),
execute: async (input) => {
const applied = await ctx.acceptCandidate(input.time);
return applied.ok
? { ok: true, saved_time: applied.saved_time, status: applied.status, result_id: applied.result_id }
: { ok: false, reason: applied.reason };
},
});
@@ -578,6 +680,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
"rectification-diagnostics": diagnosticsTool,
"rectification-candidate-features": featuresTool,
"rectification-confirm": confirmTool,
"rectification-accept-candidate": acceptCandidateTool,
"rectification-save-birth-time": saveTool,
};
}
@@ -0,0 +1,263 @@
-- Persist Agentic Rectification candidates and atomically apply a user-selected candidate.
-- A user selection is usable chart time (`accepted`) without claiming the engine uniquely confirmed it.
begin;
alter table public.profiles
drop constraint if exists profiles_birth_time_status_check;
alter table public.profiles
add constraint profiles_birth_time_status_check check (
birth_time_status is null or birth_time_status in (
'reported',
'assessing',
'rectifying',
'candidate',
'accepted',
'confirmed'
)
);
create table public.agentic_rectification_results (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
session_id uuid not null references public.chat_sessions(id) on delete cascade,
engine_result_id text not null,
canonical_input_hash text not null,
algorithm_version text not null,
candidate_range jsonb not null,
candidates jsonb not null check (jsonb_typeof(candidates) = 'array'),
overall_confidence text not null check (overall_confidence in ('low', 'medium', 'high')),
margin_percent numeric,
selection_allowed boolean not null default false,
confirmation_allowed boolean not null default false,
representative_time time without time zone,
baseline_birth_date date not null,
baseline_reported_birth_time time without time zone,
baseline_active_birth_time time without time zone,
baseline_birth_time_source text,
baseline_birth_time_period text,
baseline_uncertainty_before_minutes integer,
baseline_uncertainty_after_minutes integer,
baseline_latitude double precision not null,
baseline_longitude double precision not null,
baseline_timezone_offset double precision not null,
selected_time time without time zone,
selection_kind text check (selection_kind in ('user_accepted', 'engine_confirmed')),
selected_at timestamptz,
invalidated_at timestamptz,
expires_at timestamptz not null default (now() + interval '30 days'),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id, session_id, engine_result_id)
);
create index agentic_rectification_results_latest_idx
on public.agentic_rectification_results (user_id, session_id, created_at desc)
where invalidated_at is null;
alter table public.agentic_rectification_results enable row level security;
create policy agentic_rectification_results_select_own
on public.agentic_rectification_results
for select to authenticated
using ((select auth.uid()) = user_id);
revoke all on table public.agentic_rectification_results from public, anon, authenticated, service_role;
grant select on table public.agentic_rectification_results to authenticated;
grant all on table public.agentic_rectification_results to service_role;
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.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_offset is distinct from new.timezone_offset
or (
(old.active_birth_time is distinct from new.active_birth_time
or old.birth_time_status is distinct from new.birth_time_status)
and coalesce(new.birth_time_status, '') not in ('accepted', 'confirmed')
) then
update public.agentic_rectification_results
set invalidated_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where user_id = new.id
and invalidated_at is null;
end if;
return new;
end;
$$;
revoke all on function public.invalidate_agentic_rectification_results_on_profile_change()
from public, anon, authenticated;
drop trigger if exists profiles_invalidate_agentic_rectification_results on public.profiles;
create trigger profiles_invalidate_agentic_rectification_results
after update of birth_date, reported_birth_time, active_birth_time, birth_time_status,
birth_time_source, birth_time_period, uncertainty_before_minutes, uncertainty_after_minutes,
latitude, longitude, timezone_offset
on public.profiles
for each row execute function public.invalidate_agentic_rectification_results_on_profile_change();
create or replace function public.accept_agentic_rectification_candidate(
p_user_id uuid,
p_session_id uuid,
p_result_id uuid,
p_time time without time zone
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_result public.agentic_rectification_results%rowtype;
v_profile public.profiles%rowtype;
v_status text;
v_selection_kind text;
begin
if p_user_id is null or p_session_id is null or p_result_id is null or p_time is null
or extract(second from p_time) is distinct from 0 then
raise exception 'agentic_rectification_candidate_invalid_input' 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 session_id = p_session_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() then
raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001';
end if;
if not v_result.selection_allowed then
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
end if;
if not exists (
select 1
from pg_catalog.jsonb_array_elements(v_result.candidates) candidate
where candidate ->> 'time' = pg_catalog.to_char(p_time, 'HH24:MI')
) then
raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001';
end if;
if v_result.selected_time is not null then
if v_result.selected_time is distinct from p_time then
raise exception 'agentic_rectification_candidate_already_selected' using errcode = 'P0001';
end if;
select * into v_profile
from public.profiles
where id = p_user_id
for update;
if not found
or v_profile.active_birth_time is distinct from v_result.selected_time
or v_profile.birth_time is distinct from v_result.selected_time
or v_profile.birth_time_status is distinct from (
case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end
) then
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
end if;
return jsonb_build_object(
'success', true,
'saved_time', pg_catalog.to_char(v_result.selected_time, 'HH24:MI'),
'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end,
'result_id', v_result.id,
'idempotent', true
);
end if;
if exists (
select 1
from public.agentic_rectification_results newer
where newer.user_id = p_user_id
and newer.session_id = p_session_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;
select * into v_profile
from public.profiles
where id = p_user_id
for update;
if not found
or v_profile.birth_date is distinct from v_result.baseline_birth_date
or v_profile.reported_birth_time is distinct from v_result.baseline_reported_birth_time
or v_profile.active_birth_time is distinct from v_result.baseline_active_birth_time
or v_profile.birth_time_source is distinct from v_result.baseline_birth_time_source
or v_profile.birth_time_period is distinct from v_result.baseline_birth_time_period
or v_profile.uncertainty_before_minutes is distinct from v_result.baseline_uncertainty_before_minutes
or v_profile.uncertainty_after_minutes is distinct from v_result.baseline_uncertainty_after_minutes
or v_profile.latitude is distinct from v_result.baseline_latitude
or v_profile.longitude is distinct from v_result.baseline_longitude
or v_profile.timezone_offset is distinct from v_result.baseline_timezone_offset then
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
end if;
v_selection_kind := case
when v_result.confirmation_allowed
and v_result.representative_time is not distinct from p_time
then 'engine_confirmed'
else 'user_accepted'
end;
v_status := case when v_selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end;
update public.profiles
set active_birth_time = p_time,
birth_time = p_time,
birth_time_status = v_status,
rectification_confidence = case
when v_result.overall_confidence = 'high' then 100
when v_result.overall_confidence = 'medium' then 70
else 40
end,
updated_at = pg_catalog.now()
where id = p_user_id;
update public.agentic_rectification_results
set selected_time = p_time,
selection_kind = v_selection_kind,
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 id <> v_result.id
and invalidated_at is null
and selected_time is null;
return jsonb_build_object(
'success', true,
'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'),
'status', v_status,
'result_id', v_result.id,
'idempotent', false
);
end;
$$;
revoke all on function public.accept_agentic_rectification_candidate(uuid, uuid, uuid, time without time zone)
from public, anon, authenticated;
grant execute on function public.accept_agentic_rectification_candidate(uuid, uuid, uuid, time without time zone)
to service_role;
commit;
+23
View File
@@ -27,6 +27,11 @@ test("account API reads and returns the server-configured rectification price",
assert.doesNotMatch(source, /RECTIFICATION_PRICE_CREDITS[^\n]*\?\?\s*["']1["']/);
});
test("account API separates engine confirmation from an accepted usable chart time", () => {
assert.match(source, /hasConfirmedBirthTime:\s*profile\.birth_time_status === "confirmed"/);
assert.match(source, /hasUsableBirthTime:\s*\(profile\.birth_time_status === "accepted" \|\| profile\.birth_time_status === "confirmed"\)/);
});
test("account API projects only the minimum case state needed by the homepage", () => {
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? "";
@@ -416,6 +421,15 @@ test("ordinary declaration edits clear stale candidate application but never ove
rectification_case_id: null,
});
}
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
birth_time_status: "accepted",
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
birth_time_status: "confirmed",
@@ -462,6 +476,15 @@ test("ordinary declaration edits clear stale candidate application but never ove
birth_time_status: "reported",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
birth_time_status: "accepted",
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
birth_time_status: "confirmed",
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const migration = readFileSync(
new URL("../supabase/migrations/20260804010000_agentic_rectification_candidate_acceptance.sql", import.meta.url),
"utf8",
);
test("candidate acceptance migration adds accepted status and durable result ownership", () => {
assert.match(migration, /birth_time_status in \([\s\S]*'reported'[\s\S]*'assessing'[\s\S]*'rectifying'[\s\S]*'candidate'[\s\S]*'accepted'[\s\S]*'confirmed'[\s\S]*\)/);
assert.match(migration, /create table public\.agentic_rectification_results/);
assert.match(migration, /user_id uuid not null references auth\.users\(id\)/);
assert.match(migration, /session_id uuid not null references public\.chat_sessions\(id\)/);
assert.match(migration, /expires_at timestamptz not null/);
assert.match(migration, /invalidated_at timestamptz/);
});
test("candidate acceptance RPC validates ownership, freshness, gate, membership, and profile baseline", () => {
assert.match(migration, /where id = p_result_id[\s\S]*user_id = p_user_id[\s\S]*session_id = p_session_id/);
assert.match(migration, /invalidated_at is not null or v_result\.expires_at <= pg_catalog\.now\(\)/);
assert.match(migration, /if not v_result\.selection_allowed/);
assert.match(migration, /jsonb_array_elements\(v_result\.candidates\)[\s\S]*candidate ->> 'time'/);
assert.match(migration, /v_profile\.birth_date is distinct from v_result\.baseline_birth_date/);
assert.match(migration, /v_profile\.reported_birth_time is distinct from v_result\.baseline_reported_birth_time/);
assert.match(migration, /v_profile\.active_birth_time is distinct from v_result\.baseline_active_birth_time/);
assert.match(migration, /v_profile\.birth_time_period is distinct from v_result\.baseline_birth_time_period/);
assert.match(migration, /v_profile\.latitude is distinct from v_result\.baseline_latitude/);
assert.match(migration, /v_profile\.longitude is distinct from v_result\.baseline_longitude/);
assert.match(migration, /v_profile\.timezone_offset is distinct from v_result\.baseline_timezone_offset/);
});
test("candidate acceptance is idempotent before baseline checks and separates accepted from confirmed", () => {
const idempotent = migration.indexOf("if v_result.selected_time is not null");
const baseline = migration.indexOf("select * into v_profile");
assert.ok(idempotent >= 0 && baseline > idempotent);
assert.match(migration, /v_result\.confirmation_allowed[\s\S]*v_result\.representative_time is not distinct from p_time[\s\S]*then 'engine_confirmed'[\s\S]*else 'user_accepted'/);
assert.match(migration, /v_status := case when v_selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end/);
assert.match(migration, /agentic_rectification_candidate_superseded/);
assert.match(migration, /v_profile\.active_birth_time is distinct from v_result\.selected_time/);
});
test("candidate acceptance writes the active chart time without replacing reported time", () => {
const profileUpdate = migration.slice(
migration.indexOf("update public.profiles"),
migration.indexOf("update public.agentic_rectification_results", migration.indexOf("update public.profiles")),
);
assert.match(profileUpdate, /active_birth_time = p_time/);
assert.match(profileUpdate, /birth_time = p_time/);
assert.match(profileUpdate, /birth_time_status = v_status/);
assert.doesNotMatch(profileUpdate, /reported_birth_time\s*=/);
});
test("candidate acceptance RPC is service-role only", () => {
assert.match(migration, /revoke all on function public\.accept_agentic_rectification_candidate[\s\S]*from public, anon, authenticated/);
assert.match(migration, /grant execute on function public\.accept_agentic_rectification_candidate[\s\S]*to service_role/);
assert.doesNotMatch(migration, /grant execute on function public\.accept_agentic_rectification_candidate[\s\S]*to authenticated/);
});
test("profile declaration changes invalidate restored Agentic candidate results", () => {
assert.match(migration, /create trigger profiles_invalidate_agentic_rectification_results/);
assert.match(migration, /old\.birth_time_period is distinct from new\.birth_time_period/);
assert.match(migration, /old\.latitude is distinct from new\.latitude/);
assert.match(migration, /set invalidated_at = pg_catalog\.now\(\)/);
assert.match(migration, /coalesce\(new\.birth_time_status, ''\) not in \('accepted', 'confirmed'\)/);
});
@@ -186,15 +186,15 @@ test("card action resumes unfinished account cases and otherwise starts or revis
preservesActiveTime: true,
} as const;
assert.equal(resolveRectificationCardAction({ rectificationCase: null, hasConfirmedBirthTime: false }), "start");
assert.equal(resolveRectificationCardAction({ rectificationCase: unfinishedCase, hasConfirmedBirthTime: true }), "resume");
assert.equal(resolveRectificationCardAction({ rectificationCase: null, hasUsableBirthTime: false }), "start");
assert.equal(resolveRectificationCardAction({ rectificationCase: unfinishedCase, hasUsableBirthTime: true }), "resume");
assert.equal(resolveRectificationCardAction({
rectificationCase: { ...unfinishedCase, status: "completed" },
hasConfirmedBirthTime: true,
hasUsableBirthTime: true,
}), "revise");
assert.equal(resolveRectificationCardAction({
rectificationCase: { ...unfinishedCase, status: "abandoned" },
hasConfirmedBirthTime: false,
hasUsableBirthTime: false,
}), "start");
});
@@ -76,6 +76,20 @@ test("verified route uses only server active time", async () => {
assert.equal(prepared.serverChart?.truth.selectedTimeKind, "active");
});
test("user-accepted rectification time is the server-owned chart time", async () => {
const prepared = await prepareConsultationRoute({
userId: "user-1",
mode: "verified_chart",
loadProfile: async () => ({ ...profile, birth_time_status: "accepted" }),
reserve: async () => "reserved",
});
assert.equal(prepared.serverChart?.toolInput.hour, 5);
assert.equal(prepared.serverChart?.toolInput.minute, 18);
assert.equal(prepared.serverChart?.truth.birthTimeStatus, "accepted");
assert.equal(prepared.serverChart?.truth.selectedTimeKind, "active");
});
test("global normalized places use their exact coordinates and historical offset", async () => {
const prepared = await prepareConsultationRoute({
userId: "user-global",
@@ -32,6 +32,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260723020000_mark_captured_conversational_messages\.sql/);
assert.match(migration.stdout, /applied 20260728010000_conversational_event_semantics\.sql/);
assert.match(migration.stdout, /applied 20260728020000_rectification_agent_v5\.sql/);
assert.match(migration.stdout, /applied 20260804010000_agentic_rectification_candidate_acceptance\.sql/);
assert.equal(
fixture.psql(`
@@ -77,6 +78,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
where schemaname = 'public'
`),
[
"agentic_rectification_results",
"birth_time_rectification_action_receipts",
"birth_time_rectification_agent_runs",
"birth_time_rectification_billing",
@@ -190,6 +192,88 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.equal(inserted.error, null);
assert.deepEqual(inserted.data, { id: sessionId });
const rectificationSessionId = "22222222-2222-4222-8222-222222222222";
fixture.psql(`
update public.profiles
set birth_date = '1997-08-08',
reported_birth_time = '05:00',
birth_time_source = 'family_exact',
uncertainty_before_minutes = 10,
uncertainty_after_minutes = 10,
latitude = 36.420487,
longitude = 114.209936,
timezone_offset = 8,
birth_time_status = 'reported'
where id = '${userId}';
insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, updated_at)
values ('${rectificationSessionId}', '${userId}', 'Rectification', 'general', 'test-model', '[]', 'birth_time_rectification', now());
insert into public.agentic_rectification_results (
id, user_id, session_id, engine_result_id, canonical_input_hash, algorithm_version,
candidate_range, candidates, overall_confidence, selection_allowed, confirmation_allowed,
representative_time, baseline_birth_date, baseline_reported_birth_time, baseline_birth_time_source,
baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes, baseline_latitude,
baseline_longitude, baseline_timezone_offset
) values (
'33333333-3333-4333-8333-333333333333', '${userId}', '${rectificationSessionId}',
'engine-result-1', 'canonical-hash-1', 'test-v1', '{}',
'[{"time":"04:55","relative_support":60},{"time":"05:07","relative_support":40}]',
'medium', true, false, '04:55', '1997-08-08', '05:00', 'family_exact', 10, 10,
36.420487, 114.209936, 8
);
`);
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
`set role service_role;
select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent')
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55'
) as result
) accepted`,
),
"SET\n04:55:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') from public.profiles where id = '${userId}'`),
"04:55:accepted:05:00",
);
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
`set role service_role;
select result ->> 'idempotent'
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55'
) as result
) accepted`,
),
"SET\ntrue",
);
assert.equal(
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
);
fixture.psql(`update public.profiles set reported_birth_time = '05:01' where id = '${userId}'`);
assert.equal(
fixture.psql(`select invalidated_at is not null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
);
assert.throws(
() => fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
`set role service_role;
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55'
)`,
),
/agentic_rectification_candidate_expired/,
);
fixture.psql(`
insert into public.redemption_codes (code_hash, code_mask, credits)
values ('${"a".repeat(64)}', 'JYOTISH-****-TEST', 3)
@@ -25,7 +25,7 @@ test("opening is a server-owned operation rather than a hidden user prompt", ()
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
assert.match(chat, /action: "opening"/);
assert.match(route, /z\.literal\("opening"\)/);
assert.match(route, /parsed\.data\.action === "opening"/);
assert.match(route, /conversation\.action === "opening"/);
});
test("incomplete profiles stay in the shared onboarding flow", () => {
@@ -87,11 +87,37 @@ test("rectification messages survive remounts and suppress duplicate openings",
test("successful Agent turns are persisted by the authenticated rectification route", () => {
assert.match(route, /sessionId: z\.string\(\)\.uuid\(\)/);
assert.match(route, /\.from\("chat_sessions"\)[\s\S]*\.eq\("user_id", userId\)/);
assert.match(route, /parsed\.data\.action === "opening" && persistedMessages\.length > 0/);
assert.match(route, /conversation\.action === "opening" && persistedMessages\.length > 0/);
assert.match(route, /\.update\(\{ messages: nextMessages, updated_at:/);
assert.match(route, /if \(saveError \|\| !savedSession\) throw new Error\("RectificationSessionPersistenceError"\)/);
});
test("candidate results restore only through the authenticated rectification session", () => {
const getRoute = route.slice(
route.indexOf("export async function GET"),
route.indexOf("export async function POST"),
);
assert.match(getRoute, /sessionId/);
assert.match(getRoute, /\.eq\("user_id", user\.id\)/);
assert.match(getRoute, /session\.session_type !== "birth_time_rectification"/);
assert.match(getRoute, /loadLatestAgenticRectificationResult\(accounting, user\.id, sessionId\)/);
});
test("candidate acceptance is non-billable and happens before consultation credit reservation", () => {
const acceptance = route.indexOf('parsed.data.action === "accept_candidate"');
const reserve = route.indexOf('"begin_consultation_credit"');
assert.ok(acceptance >= 0 && reserve > acceptance);
});
test("candidate state streams before done and renders relative support controls", () => {
assert.match(route, /send\(\{ type: "candidates", result: candidateResult \}\)[\s\S]*send\(\{ type: "done", emitted: true \}\)/);
assert.match(chat, /fetch\(`\/api\/rectification\/agent\?sessionId=/);
assert.match(chat, /action: "accept_candidate"/);
assert.match(chat, /相对支持度仅用于本次候选比较,不是统计概率/);
assert.match(chat, /采用 \$\{candidate\.time\}/);
});
test("stream failures remove empty assistant placeholders", () => {
assert.match(chat, /streamFailed = true/);
assert.match(chat, /const succeeded = completed && !streamFailed && Boolean\(parsed\.text\)/);
@@ -3,11 +3,14 @@ import test from "node:test";
import {
AgenticRectificationProfileError,
acceptAgenticRectificationCandidate,
createAgenticRectificationContext,
loadAgenticRectificationProfile,
loadLatestAgenticRectificationResult,
} from "../src/lib/rectification-agentic/session.ts";
const userId = "00000000-0000-4000-8000-000000000001";
const sessionId = "00000000-0000-4000-8000-000000000002";
function fakeProfileRow(overrides: Record<string, unknown> = {}) {
return {
@@ -59,6 +62,7 @@ test("loadAgenticRectificationProfile derives birth fields, accuracy and baselin
assert.equal(profile.declaredAccuracy, "15min");
assert.equal(profile.timeSource, "family_vague");
assert.equal(profile.baselineActiveTime, "14:31");
assert.equal(profile.baselineBirthTimePeriod, null);
});
test("loadAgenticRectificationProfile normalizes a persisted ISO birth date", async () => {
@@ -103,6 +107,7 @@ test("loadAgenticRectificationProfile accepts a period-only declaration without
assert.equal(profile.reported_time, null);
assert.deepEqual(profile.candidateRange, { start_time: "23:00", end_time: "03:59" });
assert.equal(profile.declaredAccuracy, "unknown");
assert.equal(profile.baselineBirthTimePeriod, "late_night");
});
test("loadAgenticRectificationProfile accepts an unknown time as the full day", async () => {
@@ -139,7 +144,7 @@ test("loadAgenticRectificationProfile rejects a missing birth date", async () =>
test("applyConfirmedBirthTime calls the service-role RPC with the confirmed minute", async () => {
const { client, rpcCalls } = fakeAccounting(fakeProfileRow({ active_birth_time: "14:30:00" }));
const profile = await loadAgenticRectificationProfile(client as never, userId);
const ctx = createAgenticRectificationContext(client as never, userId, profile);
const ctx = createAgenticRectificationContext(client as never, userId, profile, sessionId);
const result = await ctx.applyConfirmedBirthTime("14:30");
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.saved_time, "14:30");
@@ -154,7 +159,7 @@ test("applyConfirmedBirthTime calls the service-role RPC with the confirmed minu
test("applyConfirmedBirthTime rejects a malformed time before calling the RPC", async () => {
const { client, rpcCalls } = fakeAccounting(fakeProfileRow());
const profile = await loadAgenticRectificationProfile(client as never, userId);
const ctx = createAgenticRectificationContext(client as never, userId, profile);
const ctx = createAgenticRectificationContext(client as never, userId, profile, sessionId);
const result = await ctx.applyConfirmedBirthTime("14:30:00");
assert.equal(result.ok, false);
assert.equal(rpcCalls.length, 0);
@@ -176,9 +181,143 @@ test("applyConfirmedBirthTime surfaces an RPC error as a failure", async () => {
},
};
const profile = await loadAgenticRectificationProfile(client as never, userId);
const ctx = createAgenticRectificationContext(client as never, userId, profile);
const ctx = createAgenticRectificationContext(client as never, userId, profile, sessionId);
const result = await ctx.applyConfirmedBirthTime("14:30");
assert.equal(result.ok, false);
assert.match(String(result.reason), /baseline_changed/);
assert.equal(rpcCalls.length, 1);
});
test("candidate persistence binds the engine result to user, session and profile baseline", async () => {
const { client: profileClient } = fakeAccounting(fakeProfileRow({
active_birth_time: "14:31:00",
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
}));
const profile = await loadAgenticRectificationProfile(profileClient as never, userId);
const writes: Record<string, unknown>[] = [];
let conflict = "";
const client = {
from: (table: string) => {
assert.equal(table, "agentic_rectification_results");
return {
upsert(values: Record<string, unknown>, options: { onConflict: string }) {
writes.push(values);
conflict = options.onConflict;
return {
select: () => ({
single: async () => ({ data: { id: "candidate-result-1" }, error: null }),
}),
};
},
};
},
};
const ctx = createAgenticRectificationContext(client as never, userId, profile, sessionId);
const result = await ctx.persistCandidateResult({
engineResultId: "engine-result-1",
canonicalInputHash: "fixture-hash",
algorithmVersion: "fixture-v1",
candidateRange: { start_time: "14:21", end_time: "14:41" },
candidates: [{ rank: 1, time: "14:31", relative_support: 100, tied_minute_count: 1 }],
overallConfidence: "medium",
marginPercent: 20,
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "14:31",
});
assert.deepEqual(result, { ok: true, result_id: "candidate-result-1" });
assert.equal(conflict, "user_id,session_id,engine_result_id");
assert.equal(writes[0]?.user_id, userId);
assert.equal(writes[0]?.session_id, sessionId);
assert.equal(writes[0]?.baseline_birth_date, "1990-05-12");
assert.equal(writes[0]?.baseline_reported_birth_time, "14:30");
assert.equal(writes[0]?.baseline_active_birth_time, "14:31");
assert.equal(writes[0]?.baseline_birth_time_period, null);
assert.equal(writes[0]?.baseline_uncertainty_before_minutes, 10);
assert.equal(writes[0]?.baseline_latitude, 31.23);
});
test("candidate acceptance calls the service-role RPC with exact ownership and result identity", async () => {
const calls: Array<{ name: string; args: Record<string, unknown> }> = [];
const client = {
rpc: async (name: string, args: Record<string, unknown>) => {
calls.push({ name, args });
return {
data: {
success: true,
saved_time: "14:31",
status: "accepted",
result_id: "candidate-result-1",
},
error: null,
};
},
};
const result = await acceptAgenticRectificationCandidate(
client as never,
userId,
sessionId,
"14:31",
"candidate-result-1",
);
assert.deepEqual(result, {
ok: true,
saved_time: "14:31",
status: "accepted",
result_id: "candidate-result-1",
});
assert.deepEqual(calls, [{
name: "accept_agentic_rectification_candidate",
args: {
p_user_id: userId,
p_session_id: sessionId,
p_result_id: "candidate-result-1",
p_time: "14:31",
},
}]);
});
test("latest candidate result maps persisted support and selection state for session recovery", async () => {
const query = {
select() { return this; },
eq() { return this; },
is() { return this; },
gt() { return this; },
order() { return this; },
limit() { return this; },
async maybeSingle() {
return {
data: {
id: "candidate-result-1",
candidates: [{ rank: 1, time: "14:31", relative_support: 64, tied_minute_count: 1 }],
overall_confidence: "medium",
margin_percent: 18,
selection_allowed: true,
confirmation_allowed: false,
representative_time: "14:31:00",
selected_time: "14:31:00",
selection_kind: "user_accepted",
},
error: null,
};
},
};
const client = { from: () => query };
const result = await loadLatestAgenticRectificationResult(client as never, userId, sessionId);
assert.deepEqual(result, {
resultId: "candidate-result-1",
candidates: [{ rank: 1, time: "14:31", relative_support: 64, tied_minute_count: 1 }],
overallConfidence: "medium",
marginPercent: 18,
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "14:31",
selectedTime: "14:31",
selectionStatus: "accepted",
});
});
@@ -47,15 +47,27 @@ const birth = {
tz: 8,
};
function makeCtx(applyConfirmedBirthTime?: AgenticRectificationContext["applyConfirmedBirthTime"]): AgenticRectificationContext {
function makeCtx(
acceptCandidate?: AgenticRectificationContext["acceptCandidate"],
persistCandidateResult?: AgenticRectificationContext["persistCandidateResult"],
): AgenticRectificationContext {
return {
userId: "user-1",
sessionId: "session-1",
engineBase: "http://engine.test",
birth,
candidateRange: { start_time: "14:00", end_time: "15:00" },
declaredAccuracy: "15min",
timeSource: "family_clear",
applyConfirmedBirthTime: applyConfirmedBirthTime ?? (async (time) => ({ ok: true as const, saved_time: time })),
persistCandidateResult: persistCandidateResult
?? (async () => ({ ok: true as const, result_id: "candidate-result-1" })),
acceptCandidate: acceptCandidate ?? (async (time) => ({
ok: true as const,
saved_time: time,
status: "confirmed" as const,
result_id: "candidate-result-1",
})),
applyConfirmedBirthTime: async (time) => ({ ok: true as const, saved_time: time }),
};
}
@@ -79,6 +91,8 @@ const confirmedEngineResponse = () => ({
success: true,
endpoint: "active_rectification_events",
result_id: "r1",
algorithm_version: "fixture",
canonical_input_hash: "fixture-hash",
confidence: "high",
event_count: 4,
domain_count: 3,
@@ -87,7 +101,10 @@ const confirmedEngineResponse = () => ({
technique_contract: { confirmation_allowed: true, decision: "confirm_minute" },
reasons: [],
missing_layers: [],
candidate_ranking_summary: [],
candidate_ranking_summary: [
{ rank: 1, time: "14:30", score: 30, tied_minute_count: 1 },
{ rank: 2, time: "14:31", score: 20, tied_minute_count: 1 },
],
boundary: "test",
},
});
@@ -281,7 +298,7 @@ test("save tool rejects before a confirmation gate exists", async () => {
const applied: string[] = [];
const tools = createAgenticRectificationTools(makeCtx(async (time) => {
applied.push(time);
return { ok: true as const, saved_time: time };
return { ok: true as const, saved_time: time, status: "confirmed" as const, result_id: "candidate-result-1" };
}));
const result = await runTool(tools, "rectification-save-birth-time", { time: "14:30" });
assert.equal(result.ok, false);
@@ -297,7 +314,7 @@ test("save tool rejects a time that does not equal the confirmed minute", async
const applied: string[] = [];
const tools = createAgenticRectificationTools(makeCtx(async (time) => {
applied.push(time);
return { ok: true as const, saved_time: time };
return { ok: true as const, saved_time: time, status: "confirmed" as const, result_id: "candidate-result-1" };
}));
await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
@@ -317,7 +334,7 @@ test("confirm then save with the matching minute applies the write", async () =>
const applied: string[] = [];
const tools = createAgenticRectificationTools(makeCtx(async (time) => {
applied.push(time);
return { ok: true as const, saved_time: time };
return { ok: true as const, saved_time: time, status: "confirmed" as const, result_id: "candidate-result-1" };
}));
const confirm = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
@@ -332,3 +349,50 @@ test("confirm then save with the matching minute applies the write", async () =>
assert.deepEqual(applied, ["14:30"]);
engine.restore();
});
test("confirm persists ranked candidates with relative support totaling 100", async () => {
const engine = installEngine([
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
]);
const persisted: unknown[] = [];
const tools = createAgenticRectificationTools(makeCtx(undefined, async (result) => {
persisted.push(result);
return { ok: true as const, result_id: "candidate-result-1" };
}));
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
});
assert.equal(result.selection_allowed, true);
assert.deepEqual(result.candidates, [
{ rank: 1, time: "14:30", relative_support: 60, tied_minute_count: 1 },
{ rank: 2, time: "14:31", relative_support: 40, tied_minute_count: 1 },
]);
assert.equal((result.candidates as Array<{ relative_support: number }>).reduce((sum, candidate) => sum + candidate.relative_support, 0), 100);
assert.equal(persisted.length, 1);
engine.restore();
});
test("accept candidate tool delegates the exact persisted candidate and preserves accepted status", async () => {
const calls: Array<{ time: string; resultId?: string }> = [];
const tools = createAgenticRectificationTools(makeCtx(async (time, resultId) => {
calls.push({ time, resultId });
return {
ok: true as const,
saved_time: time,
status: "accepted" as const,
result_id: "candidate-result-1",
};
}));
const result = await runTool(tools, "rectification-accept-candidate", { time: "14:31" });
assert.deepEqual(calls, [{ time: "14:31", resultId: undefined }]);
assert.deepEqual(result, {
ok: true,
saved_time: "14:31",
status: "accepted",
result_id: "candidate-result-1",
});
});