fix(rectification): replace v4 entry with agent opening
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user