feat: allow users to accept rectification candidates (#84)
This commit is contained in:
@@ -2039,3 +2039,18 @@
|
||||
- 防复发:公开回复必须同时满足“可见文本 + Session 持久化成功”才能发送完成事件;自动 opening 必须以服务端 Session 历史为准,不能只依赖组件内存。
|
||||
- 相关记录:BUG-113、BUG-114、BUG-115
|
||||
- 修复版本:空流修复 `e65c8eeda2ff5916f88f18dd345c02beff045e8b` / Session 持久化待本次 staging 发布
|
||||
|
||||
## BUG-117 | 用户采纳最强候选后无法保存为平台排盘时间
|
||||
|
||||
- 状态:resolved(local)
|
||||
- 首次发现: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/1221;lint 0 error、3 个既有 warning;production build 通过;本地 PostgreSQL 验证 `04:55` 写为 `accepted`、保留 `05:00` reported time、重复采纳幂等,并验证出生申报时间变化会使结果失效且拒绝再次采纳。
|
||||
- 相关记录:BUG-113、BUG-114、BUG-115、BUG-116
|
||||
- 修复版本:待提交与发布
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" : "");
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+263
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user