fix(rectification): replace v4 entry with agent opening

This commit is contained in:
Jesse_Chen
2026-08-03 15:11:19 +08:00
parent 36e72c9267
commit 1131e9eb3b
13 changed files with 420 additions and 319 deletions
@@ -15,7 +15,7 @@ import {
export const runtime = "nodejs";
export const maxDuration = 120;
const agenticRectificationRequestSchema = z.object({
const agenticRectificationRequestFields = {
requestId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().max(80).optional().default(""),
@@ -28,8 +28,21 @@ const agenticRectificationRequestSchema = z.object({
)
.max(30)
.default([]),
message: z.string().trim().min(1).max(4000),
}).strict();
};
const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
z.object({
...agenticRectificationRequestFields,
action: z.literal("opening"),
}).strict(),
z.object({
...agenticRectificationRequestFields,
action: z.literal("message"),
message: z.string().trim().min(1).max(4000),
}).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.";
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
@@ -99,7 +112,7 @@ export async function POST(request: Request) {
}
const promptSource = [
parsed.data.message,
parsed.data.action === "message" ? parsed.data.message : "",
...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(promptSource)) {
@@ -118,15 +131,19 @@ export async function POST(request: Request) {
profile = await loadAgenticRectificationProfile(accounting, userId);
} catch (error) {
if (error instanceof AgenticRectificationProfileError) {
const missingBirthTime = error.code === "missing_birth_time";
if (error.code === "profile_unavailable") {
return NextResponse.json(
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
{ status: 503 },
);
}
return NextResponse.json(
{
error: missingBirthTime ? "出生时间信息不完整" : "暂时无法核对出生资料",
message: missingBirthTime
? "请先在资料页保存出生日期、填报时间和出生地点后再开始校正。"
: "出生日期、时间或出生地点资料不完整,请重新保存后再试。",
code: "profile_incomplete",
error: "出生资料尚未完成",
message: "请先完成出生日期、出生时间线索和出生地点资料。",
},
{ status: 400 },
{ status: 409 },
);
}
return NextResponse.json(
@@ -206,7 +223,7 @@ export async function POST(request: Request) {
content: [
currentTimeContext(requestTime),
parsed.data.name ? `用户称呼:${parsed.data.name}` : "",
parsed.data.message,
parsed.data.action === "opening" ? openingContext : parsed.data.message,
].filter(Boolean).join("\n"),
},
]);
+25 -103
View File
@@ -15,7 +15,6 @@ import {
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import type { RectificationV4Continuation } from "@/components/rectification-v4-panel";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
@@ -54,7 +53,6 @@ import {
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client";
import {
createRectificationQuestionHandoffCoordinator,
} from "@/lib/rectification-question-handoff";
@@ -946,11 +944,9 @@ export default function Home() {
createBirthTimeConsultationConsentState,
);
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
const [rectificationContinuationPending, setRectificationContinuationPending] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [hydrated, setHydrated] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
@@ -990,7 +986,6 @@ export default function Home() {
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator<Theme>());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const rectificationContinuationInFlight = useRef(false);
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPending = useRef(false);
@@ -1016,7 +1011,6 @@ export default function Home() {
|| cancellationPending
|| creatingSession
|| rectificationMutationPending
|| rectificationContinuationPending
|| !account
|| !modelCatalog;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
@@ -1041,8 +1035,7 @@ export default function Home() {
|| activeSession.id === rectificationSessionId
|| rectificationLoading
|| rectificationMutationPending
|| rectificationContinuationPending
|| creatingSession
|| creatingSession
|| rectificationError) return;
resumeRectificationSession.current(activeSession);
}, [
@@ -1051,7 +1044,6 @@ export default function Home() {
creatingSession,
hydrated,
modelCatalog,
rectificationContinuationPending,
rectificationError,
rectificationLoading,
rectificationMutationPending,
@@ -2032,7 +2024,15 @@ export default function Home() {
sourceSessionOverride: ChatSession | null = null,
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|| rectificationMutationPending || rectificationContinuationInFlight.current) return;
|| rectificationMutationPending) return;
const missingStep = missingProfileStep(profile);
if (missingStep) {
setRectificationSessionId(null);
setRectificationPendingQuestion(null);
setOnboardingStep(missingStep);
setComposerNotice("请先完成出生资料,再开始生时校正。");
return;
}
const sourceSession = sourceSessionOverride ?? activeSession;
if (!sourceSession) return;
const existing = sourceSession.sessionType === "birth_time_rectification"
@@ -2048,7 +2048,6 @@ export default function Home() {
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id);
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
@@ -2073,6 +2072,20 @@ export default function Home() {
void openBirthTimeRectification(null, session);
};
function handleRectificationProfileIncomplete() {
setRectificationSessionId(null);
setRectificationPendingQuestion(null);
const missingStep = missingProfileStep(profile);
if (missingStep) {
setOnboardingStep(missingStep);
setComposerNotice("请先完成出生资料,再开始生时校正。");
return;
}
openAccountDialog("profile");
setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。");
void refreshAccount();
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
if (record.role !== "other") return;
if (synastryPendingId) return;
@@ -2585,96 +2598,6 @@ export default function Home() {
}
async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) {
const question = continuation.question;
if (rectificationContinuationInFlight.current || rectificationMutationPending
|| rectificationLoading || !activeSession || !account) return;
if (account.credits <= 0) {
openAccountDialog("redeem", creditTrigger.current);
return;
}
if (rectificationQuestionHandoff.current.peek()
&& !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) {
rectificationQuestionHandoff.current.clear();
}
const localHandoff = rectificationQuestionHandoff.current.peek();
const returnSession = (localHandoff
? sessions.find((session) => session.id === localHandoff.sessionId)
: null)
?? (rectificationReturnSessionId
? sessions.find((session) => session.id === rectificationReturnSessionId)
: null)
?? sessions.find((session) => session.sessionType === "consultation")
?? null;
if (!returnSession) {
setComposerNotice("没有找到原问题所在的会话,请从会话列表打开原问题后重试。");
return;
}
rectificationContinuationInFlight.current = true;
setRectificationContinuationPending(true);
setRectificationError("");
try {
const durableClaim = await claimRectificationV4Handoff({
caseId: continuation.caseId,
caseVersion: continuation.caseVersion,
question,
});
if (durableClaim.status === "in_progress") {
setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。");
return;
}
if (durableClaim.status === "consumed") {
activeSessionIdRef.current = returnSession.id;
setActiveSessionId(returnSession.id);
setRectificationPendingQuestion(null);
setComposerNotice("原问题已经继续回答,不会再次发送或扣点。");
return;
}
if (durableClaim.status !== "claimed") {
setComposerNotice("原问题仍保留,请刷新校正状态后重试。");
return;
}
const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion(
question,
{ sessionId: returnSession.id, theme: returnSession.theme },
async (context) => {
activeSessionIdRef.current = context.sessionId;
setActiveSessionId(context.sessionId);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(
current,
context.sessionId,
));
return send(
context.question,
context.theme,
null,
null,
context.sessionId,
{
protocol: "rectification-evidence-v4",
caseId: durableClaim.caseId,
caseVersion: durableClaim.caseVersion,
claimActionId: durableClaim.claimActionId,
requestId: durableClaim.requestId,
},
);
},
);
if (completed) {
setRectificationPendingQuestion(null);
setComposerNotice("已按候选范围边界继续回答原问题。");
} else {
setComposerNotice("原问题仍保留,可再次点击继续回答。");
}
} catch {
setComposerNotice("原问题仍保留,可再次点击继续回答。");
} finally {
rectificationContinuationInFlight.current = false;
setRectificationContinuationPending(false);
}
}
useGSAP(() => {
if (!starterHomeVisible || !starterWorkbench.current) return;
const motion = gsap.matchMedia();
@@ -3035,9 +2958,8 @@ export default function Home() {
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)}
onProfileIncomplete={handleRectificationProfileIncomplete}
onSaved={() => void refreshAccount()}
/>
)}
@@ -1,119 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { loadActiveRectificationV4, transitionRectificationV4 } from "../lib/rectification-v4/client.ts";
import type { RectificationV4ApiResponse } from "../lib/rectification-v4/contracts.ts";
import type { PublicLanguageModel } from "../lib/public-models.ts";
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
import { ChatMessageRow } from "./chat-message-row.tsx";
import {
RectificationV4Panel,
type RectificationV4Continuation,
} from "./rectification-v4-panel.tsx";
export type ConversationalBirthTimeRectificationProps = Readonly<{
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string) => void;
}>;
/**
* Birth-time rectification surface.
*
* Lets the user explicitly continue or end an existing v4 evidence case, and
* otherwise opens the agentic chat where the LLM drives the full Jyotish
* rectification methodology with the engine as its computation layer.
*/
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const [mode, setMode] = useState<"loading" | "choice" | "v4" | "agentic">("loading");
const [existing, setExisting] = useState<RectificationV4ApiResponse | null>(null);
const [switching, setSwitching] = useState(false);
const [switchError, setSwitchError] = useState("");
useEffect(() => {
let mounted = true;
void (async () => {
const existing = await loadActiveRectificationV4().catch(() => null);
if (mounted) {
setExisting(existing);
setMode(existing ? "choice" : "agentic");
}
})();
return () => { mounted = false; };
}, []);
if (mode === "loading") {
return (
<section className="conversation" aria-label="生时校正对话" aria-busy>
<div className="message-list" aria-live="polite">
<ChatLoadingRow />
<div />
</div>
</section>
);
}
if (mode === "choice" && existing) {
const startAgentic = async () => {
setSwitching(true);
setSwitchError("");
try {
await transitionRectificationV4(existing.case.id, existing.case.version, "abandon");
setMode("agentic");
} catch {
setSwitchError("无法结束旧版校正,请稍后再试。");
} finally {
setSwitching(false);
}
};
return (
<>
<section className="conversation" aria-label="生时校正版本选择" aria-busy={switching}>
<div className="message-list" aria-live="polite">
<ChatMessageRow
message={{
role: "assistant",
text: "检测到一段尚未结束的旧版生时校正。你可以继续保留进度,或结束旧版并使用新版 Agent 重新开始。",
renderKey: "rectification-version-choice",
state: "settled",
}}
/>
{switchError && <p className="error-message" role="alert">{switchError}</p>}
</div>
</section>
<div className="composer-wrap">
<div className="composer-suggestions" aria-label="选择生时校正版本">
<button type="button" disabled={switching} onClick={() => setMode("v4")}>继续旧版校正</button>
<button type="button" disabled={switching} onClick={() => void startAgentic()}>
{switching ? "正在结束旧版校正…" : "结束旧版并使用新版 Agent"}
</button>
</div>
</div>
</>
);
}
if (mode === "v4") {
return <RectificationV4Panel {...props} onUseAgentic={() => setMode("agentic")} />;
}
return <AgenticRectificationChat {...props} />;
}
function ChatLoadingRow() {
return (
<ChatMessageRow
message={{
role: "assistant",
text: "",
renderKey: "agentic-loading",
state: "thinking",
}}
/>
);
}
@@ -15,15 +15,19 @@ type AgenticRectificationChatProps = Readonly<{
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string) => void;
}>;
type RenderMessage = ChatMessageView;
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
const agenticOpeningInstruction = "用户刚进入生时校正会话。不要复述本指令;请先调用 rectification-gate 核对现有出生资料,然后用简体中文自然说明接下来的校正方式,并只提出一个最适合开始核对的人生事件问题。";
type AgenticRectificationRequest = Readonly<
| { action: "opening" }
| { action: "message"; message: string }
>;
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const {
@@ -32,6 +36,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
onSelectModel,
pendingConsultationQuestion,
onPendingChange,
onProfileIncomplete,
onSaved,
} = props;
const pendingQuestion = pendingConsultationQuestion?.trim();
@@ -64,9 +69,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
});
}, [busy, error, messages.length, savedTime]);
const send = useCallback(async (question: string, showUserMessage = true) => {
const trimmed = question.trim();
if (!trimmed || busy) return;
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
const trimmed = request.action === "message" ? request.message.trim() : "";
if ((request.action === "message" && !trimmed) || busy) return;
setError("");
setSavedTime(null);
setSuggestions([]);
@@ -83,7 +88,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
setMessages((current) => [
...current,
...(showUserMessage ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []),
...(showUserMessage && request.action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
]);
setDraft("");
@@ -93,11 +100,21 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requestId, modelId: selectedModelId, history, message: trimmed }),
body: JSON.stringify({
requestId,
modelId: selectedModelId,
history,
action: request.action,
...(request.action === "message" ? { message: trimmed } : {}),
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status})`;
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
if (response.status === 402) setError(`咨询点数不足:${message}`);
else if (response.status === 401) setError("请先登录。");
else setError(message);
@@ -156,17 +173,17 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
} finally {
setPending(false);
}
}, [busy, messages, onSaved, selectedModelId, setPending]);
}, [busy, messages, onProfileIncomplete, onSaved, selectedModelId, setPending]);
useEffect(() => {
if (openingStarted.current) return;
openingStarted.current = true;
void send(agenticOpeningInstruction, false);
void send({ action: "opening" }, false);
}, [send]);
async function submit(event: React.FormEvent) {
event.preventDefault();
await send(draft);
await send({ action: "message", message: draft });
}
const canSend = !busy;
@@ -190,7 +207,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
{suggestions.length > 0 && !busy && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{suggestions.map((question) => (
<button key={question} type="button" onClick={() => void send(question)}>{question}</button>
<button key={question} type="button" onClick={() => void send({ action: "message", message: question })}>{question}</button>
))}
</div>
)}
@@ -25,7 +25,8 @@ export class AgenticRectificationProfileError extends Error {
export type AgenticRectificationProfile = Readonly<{
birth_date: string;
reported_time: string;
reported_time: string | null;
candidateRange: AgenticRectificationContext["candidateRange"];
lat: number;
lon: number;
tz: number;
@@ -35,10 +36,59 @@ export type AgenticRectificationProfile = Readonly<{
}>;
const timeValue = (value: unknown): string | null => {
if (typeof value !== "string" || !value) return null;
return value.length >= 5 ? value.slice(0, 5) : null;
const time = typeof value === "string" ? value.slice(0, 5) : "";
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
};
const periodRanges = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
} as const;
function shiftedTime(time: string, offsetMinutes: number): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function candidateRangeFrom(input: {
activeTime: string | null;
reportedTime: string | null;
source: string;
period: unknown;
uncertaintyBefore: number | null;
uncertaintyAfter: number | null;
}): AgenticRectificationContext["candidateRange"] {
const referenceTime = input.activeTime ?? input.reportedTime;
if (referenceTime) {
const fallback = input.source === "hospital_record" || input.source === "hospital"
? 2
: input.source === "family_exact" || input.source === "family_clear"
? 15
: input.source === "approximate" || input.source === "family_vague"
? 60
: 2;
return {
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const period = typeof input.period === "string"
? periodRanges[input.period as keyof typeof periodRanges]
: null;
if (period) return period;
if (input.source === "period_only") throw new AgenticRectificationProfileError("missing_birth_time_period");
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
throw new AgenticRectificationProfileError("missing_birth_time");
}
function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter: number | null, timeSource: string | null): AgenticRectificationContext["declaredAccuracy"] {
const before = uncertaintyBefore ?? 0;
const after = uncertaintyAfter ?? 0;
@@ -50,16 +100,21 @@ function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter
return "unknown";
}
switch (timeSource) {
case "hospital": return "minute";
case "family_clear": return "15min";
case "family_vague": return "1hour";
case "hospital":
case "hospital_record": return "minute";
case "family_clear":
case "family_exact": return "15min";
case "family_vague":
case "approximate": return "1hour";
default: return "unknown";
}
}
function timeSourceFrom(value: unknown): AgenticRectificationContext["timeSource"] {
const source = typeof value === "string" ? value.trim() : "";
if (source === "hospital" || source === "family_clear" || source === "family_vague") return source;
if (source === "hospital" || source === "hospital_record") return "hospital";
if (source === "family_clear" || source === "family_exact") return "family_clear";
if (source === "family_vague" || source === "approximate" || source === "period_only") return "family_vague";
return "unknown";
}
@@ -82,10 +137,8 @@ export async function loadAgenticRectificationProfile(
if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) {
throw new AgenticRectificationProfileError("missing_birth_date");
}
const reportedTime = timeValue(data.active_birth_time ?? data.reported_birth_time);
if (!reportedTime || !/^\d{2}:\d{2}$/.test(reportedTime)) {
throw new AgenticRectificationProfileError("missing_birth_time");
}
const activeTime = timeValue(data.active_birth_time);
const reportedTime = timeValue(data.reported_birth_time);
const lat = numberOrNull(data.latitude);
const lon = numberOrNull(data.longitude);
const tz = numberOrNull(data.timezone_offset);
@@ -95,16 +148,25 @@ export async function loadAgenticRectificationProfile(
const timeSource = timeSourceFrom(data.birth_time_source);
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: reportedTime,
reported_time: activeTime ?? reportedTime,
candidateRange,
lat,
lon,
tz,
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, data.birth_time_source),
timeSource,
baselineActiveTime: timeValue(data.active_birth_time),
baselineActiveTime: activeTime,
};
}
@@ -122,6 +184,7 @@ export function createAgenticRectificationContext(
lon: profile.lon,
tz: profile.tz,
},
candidateRange: profile.candidateRange,
declaredAccuracy: profile.declaredAccuracy,
timeSource: profile.timeSource,
async applyConfirmedBirthTime(time) {
+1 -1
View File
@@ -12,7 +12,7 @@ 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.
- Workflow: run rectification-gate first to learn the starting accuracy and which dated events are most valuable. 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 to see how layers change minute-to-minute, 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.
- 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.
+71 -6
View File
@@ -23,16 +23,22 @@ const timePattern = /^\d{2}:\d{2}$/;
/** Birth fields supplied by the server from the user profile (never by the LLM). */
export type AgenticRectificationBirth = Readonly<{
birth_date: string;
reported_time: string;
reported_time: string | null;
lat: number;
lon: number;
tz: number;
}>;
export type AgenticRectificationCandidateRange = Readonly<{
start_time: string;
end_time: string;
}>;
export type AgenticRectificationContext = Readonly<{
userId: string;
engineBase?: string;
birth: AgenticRectificationBirth;
candidateRange: AgenticRectificationCandidateRange;
declaredAccuracy?: "minute" | "15min" | "1hour" | "unknown";
timeSource?: "hospital" | "family_clear" | "family_vague" | "unknown";
applyConfirmedBirthTime: (time: string) => Promise<Readonly<{
@@ -61,6 +67,33 @@ export const candidateRangeSchema = z.object({
end_time: z.string().regex(timePattern),
});
function clockMinute(value: string): number {
const [hour = 0, minute = 0] = value.split(":").map(Number);
return hour * 60 + minute;
}
function rangeScanWindow(range: AgenticRectificationCandidateRange) {
const start = clockMinute(range.start_time);
let end = clockMinute(range.end_time);
if (end < start) end += 1_440;
const width = end - start;
const center = Math.round((start + end) / 2) % 1_440;
return {
width,
centerTime: `${String(Math.floor(center / 60)).padStart(2, "0")}:${String(center % 60).padStart(2, "0")}`,
uncertaintyMinutes: Math.max(1, Math.ceil(width / 2)),
};
}
function requireServerCandidateRange(
requested: AgenticRectificationCandidateRange,
serverOwned: AgenticRectificationCandidateRange,
) {
if (requested.start_time !== serverOwned.start_time || requested.end_time !== serverOwned.end_time) {
throw new Error(`candidate_range_mismatch: use ${serverOwned.start_time}-${serverOwned.end_time}`);
}
}
const defaultEventKind: Record<RectificationDomain, string> = {
education: "education_milestone",
relocation: "relocation",
@@ -226,6 +259,24 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
time_source: z.enum(["hospital", "family_clear", "family_vague", "unknown"]).optional(),
}).strict(),
execute: async (input) => {
if (!ctx.birth.reported_time) {
return {
endpoint: "server_owned_rectification_preflight",
effective_accuracy: ctx.declaredAccuracy ?? "unknown",
candidate_range: ctx.candidateRange,
lagna_boundary: { is_sensitive: null, note: "requires_dated_event_scoring_across_candidate_range" },
enabled_vargas: {},
summary: {
headline: "broad_candidate_range",
enabled: [],
warned: ["candidate_range_requires_event_scoring"],
disabled: ["single_minute_precision_claims"],
confidence_floor: "low",
recommended_events: ["education", "career", "relationship", "relocation"],
next_action: "collect one clearly dated life event",
},
};
}
const body = {
year: Number(ctx.birth.birth_date.slice(0, 4)),
month: Number(ctx.birth.birth_date.slice(5, 7)),
@@ -245,6 +296,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
return {
endpoint: data.endpoint,
effective_accuracy: data.effective_accuracy,
candidate_range: ctx.candidateRange,
lagna_boundary: data.lagna_boundary,
enabled_vargas: data.enabled_vargas,
summary: {
@@ -263,22 +315,30 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
const scanTool = createTool({
id: "rectification-scan",
description:
"Scan how chart layers (D1/D4/D9/D10/D24/D30 ascendants, arudhas, KP cusps) change minute-to-minute across the candidate window around the reported birth time. Use this to understand which layers are sensitive and where transitions happen.",
"Scan how chart layers change minute-to-minute across the server-owned candidate range. Wide ranges are deferred until dated-event scoring narrows the evidence.",
inputSchema: z.object({
uncertainty_minutes: z.number().int().min(1).max(180).optional(),
step_minutes: z.number().int().min(1).max(30).optional(),
}).strict(),
execute: async (input) => {
const scanWindow = rangeScanWindow(ctx.candidateRange);
if (scanWindow.width > 360) {
return {
scope: "candidate_time_sensitivity_scan",
status: "deferred_wide_range",
candidate_range: ctx.candidateRange,
boundary: "Collect dated events and score the server-owned full range before running a local sensitivity scan.",
};
}
const body = {
year: Number(ctx.birth.birth_date.slice(0, 4)),
month: Number(ctx.birth.birth_date.slice(5, 7)),
day: Number(ctx.birth.birth_date.slice(8, 10)),
hour: Number(ctx.birth.reported_time.slice(0, 2)),
minute: Number(ctx.birth.reported_time.slice(3, 5)),
hour: Number(scanWindow.centerTime.slice(0, 2)),
minute: Number(scanWindow.centerTime.slice(3, 5)),
lat: ctx.birth.lat,
lon: ctx.birth.lon,
tz: ctx.birth.tz,
time_uncertainty_minutes: input.uncertainty_minutes,
time_uncertainty_minutes: scanWindow.uncertaintyMinutes,
step_minutes: input.step_minutes,
};
const data = await postEngine(base, "/api/rectification/sensitivity_scan", body);
@@ -286,6 +346,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
return {
scope: data.scope,
status: data.status,
candidate_range: ctx.candidateRange,
center_time: data.center_time,
uncertainty_minutes: data.uncertainty_minutes,
step_minutes: data.step_minutes,
@@ -326,6 +387,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
events: z.array(agenticRectificationEventSchema).min(1).max(40),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const data = await postEngine(base, "/api/rectification/v5/score", v5Request(ctx, input.candidate_range, input.events));
return compactScoreResult(data);
},
@@ -340,6 +402,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
events: z.array(agenticRectificationEventSchema).min(1).max(40),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const data = await postEngine(base, "/api/rectification/v5/diagnostics", v5Request(ctx, input.candidate_range, input.events));
const diagnostics = (data.diagnostics && typeof data.diagnostics === "object")
? data.diagnostics as Record<string, unknown>
@@ -375,6 +438,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
candidate_range: candidateRangeSchema,
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const data = await postEngine(base, "/api/rectification/v5/candidate-features", {
birth_date: ctx.birth.birth_date,
start_time: input.candidate_range.start_time,
@@ -408,6 +472,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
events: z.array(agenticRectificationEventSchema).min(1).max(40),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const body = {
birth_date: ctx.birth.birth_date,
start_time: input.candidate_range.start_time,