diff --git a/deploy/README.md b/deploy/README.md index 3b36b0af..67dc5ce7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -413,11 +413,14 @@ previous-revision smoke SHA must remain pending. If the create flag, migration flag, deployment SHA, or strict UUID allowlist is invalid, creation audience must be `paused`, including for the smoke account. -After the smoke sequence below passes, set +After the smoke sequence below passes, use the guarded rollout workflow to set `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character -lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and -restart the web container. Then fetch health again and -verify all of the following against the revision that passed validation: +lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, enable +`RECTIFICATION_AGENT_V5_ENABLED=true`, disable shadow mode, set the canary to +100 percent, and restart both the web and rectification worker containers. The +workflow writes these selectors together so public Case creation cannot silently +fall back to the fixed `v4_legacy` projector. Then fetch health again and verify +all of the following against the revision that passed validation: - `deployment.gitCommit` exactly equals the tested 40-character Git SHA; - `rollout.conversationalRectificationV3.protocol` is @@ -441,9 +444,9 @@ sequence. A plain HTTP `200` is not substitute evidence: event, then a clear event. Verify the ambiguous/future facts do not score. 4. Pause, reload, and resume from a second authenticated browser session. Verify no second rectification charge. -5. Reach a candidate, verify the prior active time is still in force, reject a - mismatched candidate confirmation, then explicitly confirm the exact - candidate. Verify the time changes atomically. +5. Reach a stable candidate range and verify the prior active time remains in + force. Confirm that no exact minute can be accepted and that rectification + does not write `profiles.active_birth_time`. 6. Explicitly continue the saved ordinary question. Verify one normal consultation reservation. Delete its chat and verify the account case still resumes/loads. diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh index c276877b..10ef3fd6 100755 --- a/deploy/configure-staging-rectification-rollout.sh +++ b/deploy/configure-staging-rectification-rollout.sh @@ -102,12 +102,16 @@ awk \ -v create="$creation_enabled" \ -v migrations="true" \ -v smoke_sha="$smoke_sha" \ - -v smoke_users="$smoke_user_ids" ' + -v smoke_users="$smoke_user_ids" \ + -v agent_enabled="$creation_enabled" ' BEGIN { values["RECTIFICATION_V3_CREATE_ENABLED"] = create values["RECTIFICATION_V3_MIGRATIONS_READY"] = migrations values["RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA"] = smoke_sha values["RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS"] = smoke_users + values["RECTIFICATION_AGENT_V5_ENABLED"] = agent_enabled + values["RECTIFICATION_AGENT_V5_SHADOW"] = "false" + values["RECTIFICATION_AGENT_V5_CANARY_PERCENT"] = "100" } { split($0, parts, "=") @@ -146,6 +150,18 @@ compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_f "${compose[@]}" config --quiet "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker +for service in web rectification-v4-worker; do + container="$(docker ps -q --filter 'label=com.docker.compose.project=jyotisha-staging' --filter "label=com.docker.compose.service=$service" | head -n 1)" + [ -n "$container" ] || { + echo "staging $service container is missing after rollout" >&2 + false + } + runtime_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")" + grep -Fxq "RECTIFICATION_AGENT_V5_ENABLED=$creation_enabled" <<<"$runtime_env" + grep -Fxq "RECTIFICATION_AGENT_V5_SHADOW=false" <<<"$runtime_env" + grep -Fxq "RECTIFICATION_AGENT_V5_CANARY_PERCENT=100" <<<"$runtime_env" +done + health="" for _ in $(seq 1 30); do health="$(curl --fail --silent --show-error "$STAGING_URL/api/health" 2>/dev/null || true)" diff --git a/deploy/sync-staging-tree.sh b/deploy/sync-staging-tree.sh index 005766fa..ccda6697 100755 --- a/deploy/sync-staging-tree.sh +++ b/deploy/sync-staging-tree.sh @@ -6,7 +6,16 @@ if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then exit 1 fi -rsync -az --delete \ +destination_deploy="$2/deploy" +if [ -d "$destination_deploy" ]; then + docker run --rm --pull never --network none --read-only --user 0:0 \ + --cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges \ + -v "$destination_deploy:/destination" postgres:17-alpine \ + chown -R "$(id -u):$(id -g)" /destination + chmod -R u+rwX "$destination_deploy" +fi + +rsync -az --delete --no-owner --no-group \ --exclude='/.git/' \ --exclude='/.env*' \ --exclude='/.docker/' \ diff --git a/frontend/src/app/api/rectification/v4/_server.ts b/frontend/src/app/api/rectification/v4/_server.ts index 32445068..6cd759d3 100644 --- a/frontend/src/app/api/rectification/v4/_server.ts +++ b/frontend/src/app/api/rectification/v4/_server.ts @@ -43,13 +43,33 @@ export async function calculationSpecForUser( userId: string, ): Promise { const { data, error } = await auth.from("profiles") - .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset") + .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_source,timezone_offset") .eq("id", userId).maybeSingle(); if (error) throw error; if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。"); - const profile = await resolveMissingBirthTimezoneOffset(data); + let resolvedLocalTimeStatus: CalculationSpec["localTimeStatus"] | undefined; + const profile = await resolveMissingBirthTimezoneOffset(data, { + fetchImpl: async (input, init) => { + const response = await fetch(input, init); + const payload = await response.clone().json().catch(() => null) as { localTimeStatus?: unknown } | null; + const status = payload?.localTimeStatus; + if (status === "resolved" || status === "not_provided" || status === "ambiguous" || status === "nonexistent") { + resolvedLocalTimeStatus = status; + } + return response; + }, + }); const assessment = parseBirthTimeProfile(profile); const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange; + const birthTimeSource = typeof data.birth_time_source === "string" && data.birth_time_source.trim() + ? data.birth_time_source.trim() as CalculationSpec["birthTimeSource"] + : undefined; + const timezoneId = typeof data.timezone_id === "string" && data.timezone_id.trim() + ? data.timezone_id.trim() + : undefined; + const timezoneSource = typeof data.timezone_source === "string" && data.timezone_source.trim() + ? data.timezone_source.trim() + : undefined; return { version: "rectification-calculation-spec-v4", birthDate: assessment.date, @@ -60,6 +80,10 @@ export async function calculationSpecForUser( latitude: assessment.location.lat, longitude: assessment.location.lon, timezoneOffsetHours: assessment.location.tz, + ...(birthTimeSource ? { birthTimeSource } : {}), + ...(timezoneId ? { timezoneId } : {}), + ...(timezoneSource ? { timezoneSource } : {}), + ...(resolvedLocalTimeStatus ? { localTimeStatus: resolvedLocalTimeStatus } : {}), ayanamsa: "lahiri", nodeMode: "mean", minuteStep: 1, diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts index 31955439..dd26fe02 100644 --- a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts"; -import { appendEventRevision } from "@/lib/rectification-v4/evidence-ledger"; +import { appendEventRevision, eventDateProvenance } from "@/lib/rectification-v4/evidence-ledger"; import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server"; export const runtime = "nodejs"; @@ -23,6 +23,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ cas summary: body.summary, rawText: body.rawText, dateRange: body.dateRange, + ...eventDateProvenance(body), scoreability: body.scoreability, }); return NextResponse.json(await context.service.reviseEvent({ diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts new file mode 100644 index 00000000..d98398c6 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts"; +import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + const body = await requestBody(request, caseActionRequestSchema); + const context = await rectificationV4Context(); + const result = await context.service.regenerateQuestion({ + ...body, + userId: context.userId, + caseId: routeId((await params).caseId), + }); + return result + ? NextResponse.json(result) + : NextResponse.json({ error: "当前问题不能重新生成,请刷新后重试。" }, { status: 409 }); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 853985d8..30f01106 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -607,6 +607,56 @@ button:disabled { cursor: default; opacity: .45; } .rectification-message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } .rectification-message-actions button:disabled { cursor: default; opacity: 0.32; } .rectification-message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; } +.rectification-analysis { + width: min(620px, calc(100% - 42px)); + margin: -2px 0 var(--space-1) 42px; + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.rectification-analysis > summary { + width: fit-content; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 30px; + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + color: var(--color-ink-tertiary); + cursor: pointer; + list-style: none; + transition: background-color 120ms ease-out, color 120ms ease-out; +} +.rectification-analysis > summary::-webkit-details-marker { display: none; } +.rectification-analysis > summary::before { + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + content: ""; + transform: rotate(-45deg); + transition: transform 120ms ease-out; +} +.rectification-analysis[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); } +.rectification-analysis > summary:hover { background: var(--color-canvas-muted); color: var(--color-ink-secondary); } +.rectification-analysis > summary:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } +.rectification-analysis > summary small { color: var(--color-ink-muted); font-size: inherit; } +.rectification-analysis-content { + display: grid; + gap: var(--space-3); + margin: var(--space-1) 0 var(--space-2); + padding: var(--space-3) var(--space-4); + border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-canvas-muted) 54%, transparent); +} +.rectification-analysis-content section { display: grid; gap: var(--space-2); } +.rectification-analysis-content h4 { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.rectification-analysis-content ol, +.rectification-analysis-content ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; } +.rectification-analysis-content li { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); } +.rectification-analysis-content li span { min-width: 0; color: var(--color-ink-secondary); } +.rectification-analysis-content li small { flex: 0 0 auto; color: var(--color-ink-muted); } +.rectification-analysis-content p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; } .message p, .message-markdown { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; word-break: auto-phrase; } .message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-muted); font-size: var(--type-body-sm); line-height: 1.5; } .message-user p { line-height: 1.55; color: var(--color-ink); font-size: var(--type-body-sm); } @@ -1555,6 +1605,11 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: max-width: 88%; } + .rectification-analysis { + width: calc(100% - 38px); + margin-left: 38px; + } + .conversation:not(.is-empty):not(.is-rectification) + .composer-wrap { padding-top: var(--space-3); padding-bottom: var(--space-3); diff --git a/frontend/src/components/rectification-v4-panel.tsx b/frontend/src/components/rectification-v4-panel.tsx index 53b64b3b..f59d8402 100644 --- a/frontend/src/components/rectification-v4-panel.tsx +++ b/frontend/src/components/rectification-v4-panel.tsx @@ -1,12 +1,13 @@ "use client"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { AgentActivityStatus } from "@/components/agent-activity-status"; import { useRectificationV4 } from "@/hooks/use-rectification-v4"; import type { ChatMessageView } from "@/lib/chat-message-view"; import type { PublicLanguageModel } from "@/lib/public-models"; -import type { RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts"; -import { ChatMessageRow } from "./chat-message-row"; +import type { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts"; +import { AgentAvatar, ChatMessageRow } from "./chat-message-row"; import { ModelSelector } from "./model-selector"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; @@ -29,11 +30,142 @@ type RectificationV4PanelProps = Readonly<{ onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void; }>; +type RectificationChatMessageView = ChatMessageView & Readonly<{ + analysisTrace?: RectificationAnalysisTrace; +}>; + +const phaseLabels = { + collecting_evidence: "正在准备继续收集经历…", + extracting_evidence: "正在整理你刚才提到的经历…", + scoring_candidates: "正在扫描候选时间…", + checking_robustness: "正在检查候选范围的稳定性…", + planning_question: "正在生成语义问题机会…", + reasoning: "正在选择下一步动作…", + rendering: "正在生成安全回复…", + complete: "分析已完成", +} as const; + +export function rectificationPhaseLabel( + phase: NonNullable["phase"], +): string { + return phaseLabels[phase]; +} + +function durationLabel(durationMs: number | null): string | null { + if (durationMs === null || durationMs < 0) return null; + return durationMs < 1_000 ? `${durationMs} 毫秒` : `${(durationMs / 1_000).toFixed(1)} 秒`; +} + +function publicStatusLabel(status: string): string { + return ({ + completed: "已完成", + succeeded: "已完成", + running: "进行中", + failed: "未完成", + skipped: "已跳过", + legacy: "历史记录", + } as Record)[status] ?? "已记录"; +} + +function RectificationAnalysisDetails({ trace }: Readonly<{ trace: RectificationAnalysisTrace }>) { + return ( +
+ + 分析过程 + {publicStatusLabel(trace.status)} + +
+ {trace.stages.length > 0 && ( +
+

执行阶段

+
    + {trace.stages.map((stage, index) => { + const duration = durationLabel(stage.durationMs); + return ( +
  1. + {stage.label} + {publicStatusLabel(stage.status)}{duration ? ` · ${duration}` : ""} +
  2. + ); + })} +
+
+ )} + {trace.toolCalls.length > 0 && ( +
+

实际调用

+
    + {trace.toolCalls.map((toolCall, index) => { + const duration = durationLabel(toolCall.durationMs); + return ( +
  • + {toolCall.label} + {publicStatusLabel(toolCall.outcome)}{duration ? ` · ${duration}` : ""} +
  • + ); + })} +
+
+ )} + {trace.techniques.length > 0 && ( +
+

实际使用的技法

+

{trace.techniques.join("、")}

+
+ )} + {trace.reasoningSource === "provider_summary" && trace.reasoningSummary && ( +
+

推理摘要

+

{trace.reasoningSummary}

+
+ )} +
+
+ ); +} + +function RectificationMessageRow({ message }: Readonly<{ message: RectificationChatMessageView }>) { + if (message.state !== "thinking" || !message.text) return ; + + return ( +
+ +
+
+ +
+
+
+ ); +} + +export function toggleRectificationFeedback( + current: "up" | "down" | undefined, + requested: "up" | "down", +): "up" | "down" | undefined { + return current === requested ? undefined : requested; +} + +export function canRegenerateRectificationMessage(input: Readonly<{ + message: ChatMessageView; + currentMessageKey: string | null; + deploymentMode: RectificationV4ApiResponse["case"]["deploymentMode"] | null; + busy: boolean; + canAnswer: boolean; +}>): boolean { + return input.deploymentMode === "v5_agent" + && input.message.role === "assistant" + && input.message.state === "settled" + && input.message.renderKey === input.currentMessageKey + && !input.busy + && input.canAnswer; +} + export function rectificationV4ChatMessages( data: RectificationV4ApiResponse | null, processing: boolean, pendingConsultationQuestion?: string | null, -): readonly ChatMessageView[] { +): readonly RectificationChatMessageView[] { if (!data) { return [{ role: "assistant", @@ -43,7 +175,13 @@ export function rectificationV4ChatMessages( }]; } - const messages: ChatMessageView[] = []; + const messages: RectificationChatMessageView[] = []; + const analysis = data.case.deploymentMode === "v5_agent" + ? (data as RectificationV4ApiResponse & { + readonly analysis?: readonly RectificationAnalysisItem[]; + }).analysis ?? [] + : []; + const analysisBySourceTurnId = new Map(analysis.map((item) => [item.sourceTurnId, item.trace])); if (pendingConsultationQuestion?.trim()) { messages.push({ role: "assistant", @@ -53,12 +191,14 @@ export function rectificationV4ChatMessages( }); } + let previousTurnId: string | null = null; for (const turn of data.turns) { messages.push({ role: "assistant", text: turn.question, renderKey: `rectification-question-${turn.id}`, state: "settled", + analysisTrace: previousTurnId ? analysisBySourceTurnId.get(previousTurnId) : undefined, }); if (turn.answer) { messages.push({ @@ -68,16 +208,20 @@ export function rectificationV4ChatMessages( state: "settled", }); } + previousTurnId = turn.id; } const caseValue = data.case; const primary = caseValue.latestSnapshot?.clusters[0]; + const latestTurnTrace = analysisBySourceTurnId.get(data.turns.at(-1)?.id ?? ""); + const terminalTrace = caseValue.currentQuestion ? undefined : latestTurnTrace; if (caseValue.acceptedRange) { messages.push({ role: "assistant", text: `候选范围已保存为 ${caseValue.acceptedRange.start}–${caseValue.acceptedRange.end}。这是校正得到的候选范围,原出生时间没有被自动改写。`, renderKey: `rectification-accepted-${caseValue.version}`, state: "settled", + analysisTrace: terminalTrace, }); } else if (caseValue.status === "range_ready" && primary) { messages.push({ @@ -85,6 +229,7 @@ export function rectificationV4ChatMessages( text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}–${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`, renderKey: `rectification-range-${caseValue.version}`, state: "settled", + analysisTrace: terminalTrace, }); } @@ -94,13 +239,14 @@ export function rectificationV4ChatMessages( text: caseValue.currentQuestion.prompt, renderKey: `rectification-current-${caseValue.currentQuestion.id}`, state: "settled", + analysisTrace: latestTurnTrace, }); } if (processing) { messages.push({ role: "assistant", - text: "", + text: rectificationPhaseLabel(data.job?.phase ?? caseValue.phase), renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`, state: "thinking", }); @@ -110,6 +256,7 @@ export function rectificationV4ChatMessages( text: "进度已经保存。准备好后,我们可以从这里继续。", renderKey: `rectification-paused-${caseValue.version}`, state: "settled", + analysisTrace: terminalTrace, }); } else if (caseValue.status === "abandoned") { messages.push({ @@ -117,6 +264,7 @@ export function rectificationV4ChatMessages( text: "这次校正已经结束,原出生时间没有被改写。", renderKey: `rectification-abandoned-${caseValue.version}`, state: "settled", + analysisTrace: terminalTrace, }); } @@ -129,6 +277,9 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { onPendingChange: props.onPendingChange, }); const [draft, setDraft] = useState(""); + const [feedback, setFeedback] = useState>({}); + const [copiedMessageKey, setCopiedMessageKey] = useState(null); + const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const composer = useRef(null); const conversationEnd = useRef(null); const caseValue = controller.data?.case; @@ -145,6 +296,10 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { && !processing && !controller.pending && ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? ""); + const currentMessageKey = caseValue?.currentQuestion + ? `rectification-current-${caseValue.currentQuestion.id}` + : null; + const busy = processing || controller.pending || regeneratingMessageKey !== null; const canAcceptRange = caseValue?.status === "range_ready" && Boolean(caseValue.latestSnapshot?.canAcceptRange) && !caseValue.acceptedRange; @@ -174,6 +329,27 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { if (result) setDraft(""); } + async function copyMessage(message: ChatMessageView) { + try { + await navigator.clipboard.writeText(message.text); + setCopiedMessageKey(message.renderKey); + window.setTimeout(() => setCopiedMessageKey((current) => ( + current === message.renderKey ? null : current + )), 1_500); + } catch { + // Clipboard permission failures must not interrupt the conversation. + } + } + + async function regenerateMessage(messageKey: string) { + setRegeneratingMessageKey(messageKey); + try { + await controller.regenerate(); + } finally { + setRegeneratingMessageKey((current) => current === messageKey ? null : current); + } + } + function continueOriginalQuestion() { if (!caseValue?.acceptedRange || !handoff) return; props.onContinueOriginalQuestion?.({ @@ -189,7 +365,74 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) { <>
- {messages.map((message) => )} + {messages.map((message) => { + const showActions = caseValue?.deploymentMode === "v5_agent" + && message.role === "assistant" + && message.state === "settled" + && Boolean(message.text); + const regenerating = regeneratingMessageKey === message.renderKey; + const canRegenerate = canRegenerateRectificationMessage({ + message, + currentMessageKey, + deploymentMode: caseValue?.deploymentMode ?? null, + busy, + canAnswer, + }); + return ( +
+ {message.role === "assistant" && message.state === "settled" && message.analysisTrace && ( + + )} + + {showActions && !regenerating && ( +
+ + + + +
+ )} +
+ ); + })} {controller.error &&

{controller.error}

}
diff --git a/frontend/src/hooks/use-rectification-v4.ts b/frontend/src/hooks/use-rectification-v4.ts index 981a1f9c..d6d314bd 100644 --- a/frontend/src/hooks/use-rectification-v4.ts +++ b/frontend/src/hooks/use-rectification-v4.ts @@ -16,6 +16,7 @@ import { loadRectificationV4, loadRectificationV4Handoff, loadRectificationV4Job, + regenerateRectificationV4Question, transitionRectificationV4, } from "@/lib/rectification-v4/client"; @@ -23,6 +24,16 @@ function friendly(error: unknown): string { return error instanceof Error ? error.message : "暂时无法处理,请稍后再试。"; } +export function applyRectificationV4JobUpdate( + data: RectificationV4ApiResponse | null, + job: RectificationV4Job, +): RectificationV4ApiResponse | null { + if (data?.job?.id !== job.id) return data; + if (["completed", "failed", "stale"].includes(data.job.status)) return data; + if (job.updatedAt < data.job.updatedAt) return data; + return { ...data, job }; +} + export function useRectificationV4(input: { readonly pendingConsultationQuestion?: string | null; readonly onPendingChange?: (pending: boolean) => void; @@ -30,13 +41,17 @@ export function useRectificationV4(input: { const onPendingChange = input.onPendingChange; const pendingConsultationQuestion = input.pendingConsultationQuestion?.trim() || null; const [data, setData] = useState(null); - const [job, setJob] = useState(null); const [handoff, setHandoff] = useState(null); const [loading, setLoading] = useState(true); const [pending, setPending] = useState(false); const [error, setError] = useState(""); const mounted = useRef(true); + const job = data?.job ?? null; + const jobId = job?.id ?? null; + const jobStatus = job?.status ?? null; + const caseId = data?.case.id ?? null; + const setBusy = useCallback((value: boolean) => { setPending(value); onPendingChange?.(value); @@ -46,7 +61,6 @@ export function useRectificationV4(input: { const result = caseId ? await loadRectificationV4(caseId) : await loadActiveRectificationV4(); if (mounted.current) { setData(result); - setJob(result?.job ?? null); } return result; }, []); @@ -73,7 +87,6 @@ export function useRectificationV4(input: { } if (mounted.current) { setData(result); - setJob(result.job); setHandoff(nextHandoff); } } catch (caught) { @@ -86,23 +99,30 @@ export function useRectificationV4(input: { }, [pendingConsultationQuestion]); useEffect(() => { - const jobId = job?.id; - if (!jobId || !["pending", "processing"].includes(job.status)) return; - const timer = window.setInterval(() => { - void loadRectificationV4Job(jobId).then(async (next) => { - if (!mounted.current) return; - setJob(next); - if (["completed", "failed", "stale"].includes(next.status) && data) { - window.clearInterval(timer); - const latest = await refresh(data.case.id); + if (!jobId || !jobStatus || !["pending", "processing"].includes(jobStatus)) return; + let cancelled = false; + let timer: number | null = null; + const poll = async () => { + try { + const next = await loadRectificationV4Job(jobId); + if (cancelled || !mounted.current) return; + setData((current) => applyRectificationV4JobUpdate(current, next)); + if (["completed", "failed", "stale"].includes(next.status) && caseId) { + const latest = await refresh(caseId); if (next.status === "failed" && latest) setError("这次比较没有完成,回答已经保留,请再试一次。"); + return; } - }).catch((caught) => { - if (mounted.current) setError(friendly(caught)); - }); - }, 1_000); - return () => window.clearInterval(timer); - }, [data, job, refresh]); + } catch (caught) { + if (!cancelled && mounted.current) setError(friendly(caught)); + } + if (!cancelled) timer = window.setTimeout(() => void poll(), 1_000); + }; + timer = window.setTimeout(() => void poll(), 1_000); + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + }; + }, [caseId, jobId, jobStatus, refresh]); const mutate = useCallback(async (operation: () => Promise) => { setBusy(true); @@ -111,7 +131,6 @@ export function useRectificationV4(input: { const result = await operation(); if (mounted.current) { setData(result); - setJob(result.job); } return result; } catch (caught) { @@ -136,6 +155,9 @@ export function useRectificationV4(input: { answer: (answer: string, modelId?: string | null) => data ? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer, modelId)) : Promise.resolve(null), + regenerate: () => data + ? mutate(() => regenerateRectificationV4Question(data.case.id, data.case.version)) + : Promise.resolve(null), pause: () => data ? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "pause")) : Promise.resolve(null), diff --git a/frontend/src/lib/birth-profile-timezone.ts b/frontend/src/lib/birth-profile-timezone.ts index c7f328a8..719aee26 100644 --- a/frontend/src/lib/birth-profile-timezone.ts +++ b/frontend/src/lib/birth-profile-timezone.ts @@ -21,6 +21,11 @@ function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function calendarDate(value: unknown): string | null { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10); + return text(value); +} + export function finiteBirthNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -66,7 +71,7 @@ export async function resolveMissingBirthTimezoneOffset( if (existingOffset !== null) return value; const latitude = finiteBirthNumber(profile.latitude); const longitude = finiteBirthNumber(profile.longitude); - const birthDate = text(valueFor(profile, "birth_date", "date")); + const birthDate = calendarDate(valueFor(profile, "birth_date", "date")); const timezoneId = text(valueFor(profile, "timezone_id", "timezoneId")); if (latitude === null || longitude === null || !birthDate || !timezoneId) return value; diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index d7ecd277..b9df6abf 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -35,7 +35,7 @@ const unresolvedRelativeTimePattern = /(?:来年|次年|第二年|翌年|后来| const leadingRelativeTimePattern = /^\s*(?:(?:来年|次年|第二年|翌年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/; const missingEventSummary = "事件内容待补充"; -function normalizedDate(value: string, asOfDate: string): ParsedDate | null { +export function parseDeclaredDateText(value: string, asOfDate: string): ParsedDate | null { const chinese = value.match(/^((?:1\d{3}|20\d{2}|\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/); const iso = value.match(/^((?:1\d{3}|20\d{2}))-(\d{2})(?:-(\d{2}))?$/); const match = chinese ?? iso; @@ -70,7 +70,7 @@ function datesIn(value: string, asOfDate: string): ParsedDate[] { const matches = [...value.matchAll(chineseDatePattern), ...value.matchAll(isoDatePattern)] .sort((left, right) => (left.index ?? 0) - (right.index ?? 0)); return matches.flatMap((match) => { - const parsed = normalizedDate(match[0], asOfDate); + const parsed = parseDeclaredDateText(match[0], asOfDate); return parsed ? [parsed] : []; }); } @@ -139,7 +139,7 @@ function classifyEvent(summary: string): EventSemantics { if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) { return { domain: "finance", eventKind: "finance_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; } - if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) { + if (/工作|实习|研究员|入职|离职|辞职|升职|创业|职业|职位|任职|负责|管理职责|公司|项目/.test(summary)) { return { domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; } return { domain: "other", eventKind: "other", subject: "other", relatedPerson: null, scoreability: "unsupported" }; @@ -269,3 +269,83 @@ export function extractLifeEventEvidence( } return coalesceSameEventDetails(input, events); } + + +export type ModelAssistedEventExtraction = Readonly<{ + sourceSpan: string; + summary: string; + domain: RectificationEvidenceDomain; + eventKind: string; + subject: "self" | "family" | "partner" | "other"; + relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null; + dateText: string | null; +}>; + +const allowedKindsByDomain: Readonly> = { + education: ["education_milestone"], + relocation: ["relocation"], + relationship: ["relationship_start", "relationship_end", "relationship_change"], + career: ["career_change"], + finance: ["finance_change"], + health_pressure: ["self_health_event"], + family: ["family_health_event", "family_bereavement", "family_event"], + other: ["other"], +}; + +const familyRelatedPeople = new Set([ + "father", + "mother", + "grandparent", + "sibling", +]); +const explicitFamilySubjectMarkers = [ + "父亲", "爸爸", "老爸", "母亲", "妈妈", "老妈", + "爷爷", "奶奶", "外公", "外婆", "祖父", "祖母", "外祖父", "外祖母", + "兄弟", "姐妹", "家里老人", "家中老人", +] as const; + +export function validatedModelAssistedEvidence(input: Readonly<{ + rawText: string; + sourceTurnId: string; + asOfDate: string; + extraction: ModelAssistedEventExtraction; +}>): ExtractedLifeEventEvidence | null { + const sourceSpan = input.extraction.sourceSpan.trim(); + const dateText = input.extraction.dateText?.trim() || null; + if (!sourceSpan || !input.rawText.includes(sourceSpan)) return null; + if (!dateText || !input.rawText.includes(dateText)) return null; + const date = parseDeclaredDateText(dateText.normalize("NFKC"), input.asOfDate); + if (!date || dateIsFuture(date, input.asOfDate)) return null; + if (!allowedKindsByDomain[input.extraction.domain]?.includes(input.extraction.eventKind)) return null; + const { subject, relatedPerson, domain } = input.extraction; + if (subject === "self" && relatedPerson !== null) return null; + if ((subject === "family") !== (domain === "family")) return null; + if (familyRelatedPeople.has(relatedPerson) && subject !== "family") return null; + if (relatedPerson === "partner" && (subject !== "partner" || domain !== "relationship")) return null; + if (subject === "partner" && (domain !== "relationship" || relatedPerson !== "partner")) return null; + if (explicitFamilySubjectMarkers.some((marker) => sourceSpan.includes(marker)) + && (subject !== "family" || domain !== "family")) return null; + const summary = eventSummary(sourceSpan); + if (summary === missingEventSummary) return null; + const familyContext = input.extraction.subject === "family" || input.extraction.domain === "family"; + const scoreability = familyContext + ? "context_only" as const + : input.extraction.subject === "self" || (input.extraction.subject === "partner" && input.extraction.domain === "relationship") + ? "scoreable" as const + : "unsupported" as const; + return { + id: evidenceId({ rawText: input.rawText, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }, 0, summary), + rawText: input.rawText, + domain: input.extraction.domain, + eventKind: input.extraction.eventKind, + subject: input.extraction.subject, + relatedPerson: input.extraction.relatedPerson, + eventSummary: summary, + dateValue: date.value, + datePrecision: date.precision, + extractionStatus: "clear", + scoreability, + scoreable: scoreability === "scoreable", + correctsEvidenceIds: [], + }; +} diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 840fe28d..569a390c 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -1,10 +1,13 @@ import { z } from "zod"; -import { clockTimeSchema, evidenceDomainSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts"; +import { clockTimeSchema, evidenceDomainSchema, rectificationAnalysisTraceSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts"; const uuid = z.string().uuid(); const hash = z.string().regex(/^[a-f0-9]{64}$/); const nonblank = (max: number) => z.string().trim().min(1).max(max); +export const CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const; +export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-agent-v6-1" as const; + export const rectificationDiagnosticSchema = z.enum([ "leave_one_event_out", "leave_one_domain_out", @@ -26,21 +29,41 @@ export const rectificationDecisionSchema = z.discriminatedUnion("action", [ ]); export type RectificationDecision = z.infer; -export const questionOpportunitySchema = z.object({ - opportunityId: uuid, - kind: z.enum([ - "clarify_intake", - "clarify_event_subject", - "refine_event_date", - "pair_related_event", - "ask_new_event", - "resolve_event_conflict", - "disambiguate_candidate_split", - ]), - domain: evidenceDomainSchema, - targetEventId: uuid.nullable(), - prompt: nonblank(1_000), - reason: nonblank(240), +export const semanticQuestionKindSchema = z.enum([ + "clarify_intake", + "clarify_event_subject", + "refine_event_date", + "pair_related_event", + "ask_new_event", + "resolve_event_conflict", + "disambiguate_candidate_split", +]); +export type SemanticQuestionKind = z.infer; + +export const requestedQuestionFieldSchema = z.enum([ + "event_year", + "event_month", + "event_day", + "event_range", + "event_subject", + "event_stage", + "new_dated_event", +]); +export type RequestedQuestionField = z.infer; + +export const forbiddenQuestionMoveSchema = z.enum([ + "switch_target_event", + "ask_multiple_questions", + "claim_exact_birth_minute", + "invent_event", + "invent_date", + "expose_private_score", + "expose_internal_id", + "expose_technique_trace", +]); +export type ForbiddenQuestionMove = z.infer; + +const opportunityMetrics = { expectedInformationGain: z.number().finite().min(0).max(1), dateSensitivity: z.number().finite().min(0).max(1), candidateSplitRelevance: z.number().finite().min(0).max(1), @@ -51,8 +74,103 @@ export const questionOpportunitySchema = z.object({ privacyCost: z.number().finite().min(0).max(1), utility: z.number().finite(), active: z.boolean(), +} as const; + +export const semanticQuestionOpportunitySchema = z.object({ + contractVersion: z.literal("semantic-question-v2"), + opportunityId: uuid, + kind: semanticQuestionKindSchema, + domain: evidenceDomainSchema, + targetEventId: uuid.nullable(), + goal: nonblank(500), + requestedFields: z.array(requestedQuestionFieldSchema).min(1).max(4), + anchors: z.array(nonblank(240)).max(8), + contextFacts: z.array(nonblank(500)).max(16), + forbiddenMoves: z.array(forbiddenQuestionMoveSchema).min(1).max(8), + fallbackPrompt: nonblank(1_000), + reason: nonblank(500), + ...opportunityMetrics, }).strict(); -export type QuestionOpportunity = z.infer; +export type SemanticQuestionOpportunity = z.infer; + +const legacyQuestionOpportunitySchema = z.object({ prompt: nonblank(1_000) }).passthrough(); + +function legacyUuid(value: string): string { + let hashValue = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hashValue ^= value.charCodeAt(index); + hashValue = Math.imul(hashValue, 16777619); + } + const block = (hashValue >>> 0).toString(16).padStart(8, "0"); + return `${block}-${block.slice(0, 4)}-4${block.slice(1, 4)}-8${block.slice(1, 4)}-${block}${block.slice(0, 4)}`; +} + +const defaultForbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ + "switch_target_event", + "ask_multiple_questions", + "claim_exact_birth_minute", + "invent_event", + "invent_date", + "expose_private_score", + "expose_internal_id", + "expose_technique_trace", +]; + +function numberFrom(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function requestedFieldsFor(kind: SemanticQuestionKind): SemanticQuestionOpportunity["requestedFields"] { + if (kind === "clarify_event_subject") return ["event_subject"]; + if (kind === "refine_event_date") return ["event_month"]; + if (kind === "disambiguate_candidate_split") return ["event_stage"]; + if (kind === "ask_new_event" || kind === "pair_related_event") return ["new_dated_event"]; + return ["event_range"]; +} + +export function normalizeQuestionOpportunity(value: unknown): SemanticQuestionOpportunity { + const semantic = semanticQuestionOpportunitySchema.safeParse(value); + if (semantic.success) return semantic.data; + const legacy = legacyQuestionOpportunitySchema.parse(value) as Record & { prompt: string }; + const kind = semanticQuestionKindSchema.safeParse(legacy.kind).success + ? semanticQuestionKindSchema.parse(legacy.kind) + : "clarify_intake"; + const domain = evidenceDomainSchema.safeParse(legacy.domain).success + ? evidenceDomainSchema.parse(legacy.domain) + : "other"; + const targetEventId = uuid.safeParse(legacy.targetEventId).success ? uuid.parse(legacy.targetEventId) : null; + const reason = typeof legacy.reason === "string" && legacy.reason.trim() ? legacy.reason.trim().slice(0, 500) : "历史问题机会兼容读取。"; + return semanticQuestionOpportunitySchema.parse({ + contractVersion: "semantic-question-v2", + opportunityId: uuid.safeParse(legacy.opportunityId).success ? legacy.opportunityId : legacyUuid(legacy.prompt), + kind, + domain, + targetEventId, + goal: reason, + requestedFields: requestedFieldsFor(kind), + anchors: [], + contextFacts: [], + forbiddenMoves: defaultForbiddenMoves, + fallbackPrompt: legacy.prompt, + reason, + expectedInformationGain: numberFrom(legacy.expectedInformationGain, .5), + dateSensitivity: numberFrom(legacy.dateSensitivity, .5), + candidateSplitRelevance: numberFrom(legacy.candidateSplitRelevance, .5), + domainCoverageGain: numberFrom(legacy.domainCoverageGain, 0), + recallEase: numberFrom(legacy.recallEase, .5), + novelty: numberFrom(legacy.novelty, .5), + repetitionPenalty: numberFrom(legacy.repetitionPenalty, 0), + privacyCost: numberFrom(legacy.privacyCost, 0), + utility: numberFrom(legacy.utility, .5), + active: typeof legacy.active === "boolean" ? legacy.active : true, + }); +} + +export const questionOpportunitySchema = z.union([ + semanticQuestionOpportunitySchema, + legacyQuestionOpportunitySchema, +]).transform(normalizeQuestionOpportunity); +export type QuestionOpportunity = z.output; export const eventDateSensitivitySchema = z.object({ eventId: uuid, @@ -70,6 +188,38 @@ export const candidateSplitSchema = z.object({ eventIds: z.array(uuid).max(100), }).strict(); +export const vedAstroCandidateMetricSchema = z.object({ + role: z.enum(["primary", "runner_up"]), + requestedEventCount: z.number().int().nonnegative().max(20), + successfulEventCount: z.number().int().nonnegative().max(20), + matchedEventCount: z.number().int().nonnegative().max(20), + eventHitCount: z.number().int().nonnegative(), + signalLift: z.number().finite(), +}).strict(); + +export const vedAstroPostValidationSchema = z.object({ + contractVersion: z.literal("vedastro-post-validation-v1"), + provider: z.literal("vedastro_official"), + status: z.enum(["pass", "blocked", "not_validated"]), + providerStatus: nonblank(80), + blockers: z.array(nonblank(120)).max(20), + primaryCandidateTime: clockTimeSchema.nullable(), + runnerUpCandidateTime: clockTimeSchema.nullable(), + eligibleEventCount: z.number().int().nonnegative().max(100), + selectedEventCount: z.number().int().nonnegative().max(20), + unsupportedEventCount: z.number().int().nonnegative().max(100), + candidateMetrics: z.array(vedAstroCandidateMetricSchema).max(2), + minuteSensitiveValidation: z.object({ + comparisonReady: z.boolean(), + discriminated: z.boolean(), + discriminatedLayers: z.array(nonblank(80)).max(10), + }).strict(), + validationHash: hash, + validatedAt: z.string().datetime({ offset: true }), + canConfirmExactMinute: z.literal(false), +}).strict(); +export type VedAstroPostValidation = z.infer; + export const diagnosticsSummarySchema = z.object({ id: uuid, caseId: uuid, @@ -85,6 +235,7 @@ export const diagnosticsSummarySchema = z.object({ mostDiscriminatingLayers: z.array(nonblank(80)).max(40), eventDateSensitivity: z.array(eventDateSensitivitySchema).max(100), candidateSplits: z.array(candidateSplitSchema).max(20), + externalValidation: vedAstroPostValidationSchema.optional(), calculationHash: hash, createdAt: z.string().datetime({ offset: true }), }).strict(); @@ -136,6 +287,11 @@ export const publicMessageSchema = z.object({ }).strict(); export type PublicMessage = z.infer; +export const storedPublicMessageSchema = publicMessageSchema.extend({ + analysisTrace: rectificationAnalysisTraceSchema.optional(), +}).strict(); +export type StoredPublicMessage = z.infer; + export const agentRunSchema = z.object({ id: uuid, caseId: uuid, diff --git a/frontend/src/lib/rectification-agent/event-extractor-agent.ts b/frontend/src/lib/rectification-agent/event-extractor-agent.ts new file mode 100644 index 00000000..bfe02192 --- /dev/null +++ b/frontend/src/lib/rectification-agent/event-extractor-agent.ts @@ -0,0 +1,68 @@ +import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import { + eventKindSchema, + eventSubjectSchema, + evidenceDomainSchema, + relatedPersonSchema, +} from "../rectification-v4/contracts.ts"; +import { + validatedModelAssistedEvidence, + type ExtractedLifeEventEvidence, + type ModelAssistedEventExtraction, +} from "../conversational-rectification/evidence-extractor.ts"; + +export const modelAssistedEventExtractionSchema = z.object({ + sourceSpan: z.string().trim().min(1).max(4_000), + summary: z.string().trim().min(1).max(1_000), + domain: evidenceDomainSchema, + eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), + dateText: z.string().trim().min(1).max(80).nullable(), +}).strict(); + +export type EventExtractorGenerator = (prompt: string) => Promise>; + +export async function extractEventWithModel(input: Readonly<{ + rawText: string; + sourceTurnId: string; + asOfDate: string; + modelId?: string | null; + timeoutMs?: number; + generateExtraction?: EventExtractorGenerator; +}>): Promise { + const model = (input.modelId ? resolveLanguageModel(input.modelId) : null) ?? defaultLanguageModel(); + if (!model && !input.generateExtraction) return null; + const agent = model ? new Agent({ + id: `rectification-event-extractor-${model.id}`, + name: "Restricted Rectification Event Extractor", + model: model.model, + instructions: "Extract at most one explicitly stated dated life event. sourceSpan and dateText must be exact continuous substrings of the user text. Never infer or invent a date, normalized range, candidate time, score, id, or profile value. Return strict JSON only.", + }) : null; + const generate = input.generateExtraction ?? (async (prompt: string) => { + if (!agent) throw new Error("event_extractor_model_unavailable"); + return agent.generate(prompt, { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 10_000), + structuredOutput: { schema: modelAssistedEventExtractionSchema, jsonPromptInjection: "inline" }, + }); + }); + try { + const result = await generate(JSON.stringify({ + task: "Extract one event that deterministic parsing could not classify. Use only literal text from userText.", + userText: input.rawText, + asOfDate: input.asOfDate, + allowedOutput: ["sourceSpan", "summary", "domain", "eventKind", "subject", "relatedPerson", "dateText"], + })); + const extraction = modelAssistedEventExtractionSchema.parse(result.object) as ModelAssistedEventExtraction; + return validatedModelAssistedEvidence({ + rawText: input.rawText, + sourceTurnId: input.sourceTurnId, + asOfDate: input.asOfDate, + extraction, + }); + } catch { + return null; + } +} diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts index 8dd73b4f..9f433547 100644 --- a/frontend/src/lib/rectification-agent/opportunity-builder.ts +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -1,8 +1,28 @@ import { createHash } from "node:crypto"; import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; -import type { DiagnosticsSummary, QuestionOpportunity } from "./contracts.ts"; +import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts"; +import type { TargetDisposition } from "../rectification-v4/extraction.ts"; +import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts"; -const domains: readonly EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure"]; +const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ + "switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", + "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace", +]; + +const domainPolicy: Readonly, Readonly<{ + goal: string; + fallbackPrompt: (anchor: string | null) => string; + keywords: RegExp; + recallEase: number; + privacyCost: number; +}>>> = { + education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?` : "你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 }, + relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,你还记得哪次独立搬家或迁居大概发生在哪年哪月?` : "你还记得哪次搬家或迁居大概发生在哪年哪月?", keywords: /搬家|搬到|搬去|迁居|迁到|迁往|移居|定居|长期居住/, recallEase: .78, privacyCost: .04 }, + relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,如果你愿意,哪次关系变化的大概年月还记得?` : "如果你愿意,哪次关系变化的大概年月还记得?", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 }, + career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,哪次工作或职责明显变化的年月你还记得?` : "哪次工作或职责明显变化的年月你还记得?", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 }, + finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,如果方便,哪次财务状况明显变化的年月你还记得?` : "如果方便,哪次财务状况明显变化的年月你还记得?", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 }, + health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,如果方便,你本人哪次健康变化的大概年月还记得?` : "如果方便,你本人哪次健康变化的大概年月还记得?", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 }, +}; function stableUuid(value: string): string { const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split(""); @@ -21,7 +41,9 @@ const routingValue: Record = { ask_new_event: 0, }; -function utility(value: Omit): number { +type OpportunityInput = Omit; + +function utility(value: OpportunityInput): number { return Number(( .35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance + .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty @@ -29,8 +51,30 @@ function utility(value: Omit): QuestionOpportunity { - const result = { ...input, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.prompt}`), utility: utility(input), active: true }; +function opportunity(caseId: string, input: OpportunityInput): QuestionOpportunity { + return { + contractVersion: "semantic-question-v2", + ...input, + forbiddenMoves, + opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.goal}:${input.fallbackPrompt}`), + utility: utility(input), + active: true, + }; +} + +function daysWide(event: LifeEventRevision): number { + return Math.floor((Date.parse(`${event.dateRange.end}T00:00:00Z`) - Date.parse(`${event.dateRange.start}T00:00:00Z`)) / 86_400_000) + 1; +} + +function anchorFor(event: LifeEventRevision): string { + return event.summary.replace(/[“”"']/g, "").trim().slice(0, 80); +} + +function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): ReadonlySet { + const result = new Set(); + for (const turn of turns) { + if (turn.questionDomain && /不想说|不方便说|不想回答|跳过|这个不说|换个方向|不聊这个/.test(turn.answer)) result.add(turn.questionDomain); + } return result; } @@ -40,74 +84,124 @@ export function buildQuestionOpportunities(input: Readonly<{ turns: readonly RectificationV4Turn[]; snapshot: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; + targetDisposition?: TargetDisposition; retryTargetEventIds?: readonly string[]; }>): readonly QuestionOpportunity[] { - const attempted = new Set(input.turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : [])); + const targetAttempts = new Map(); + for (const turn of input.turns) { + if (turn.questionTargetEventId) targetAttempts.set(turn.questionTargetEventId, (targetAttempts.get(turn.questionTargetEventId) ?? 0) + 1); + } const retryTargets = new Set(input.retryTargetEventIds ?? []); const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain)); + const refusedDomains = declinedSensitiveDomains(input.turns); + const latestEvent = chronologicalEvents(input.events).at(-1); + const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? ""; const opportunities: QuestionOpportunity[] = []; - for (const eventId of retryTargets) { - const event = input.events.find((value) => value.eventId === eventId); - if (!event) continue; - opportunities.push(opportunity(input.caseId, { - kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId, - prompt: `你刚才补充的新经历已经另行保存。关于“${event.summary}”的时间仍没有确定;如果记不清,可以直接说不知道。`, - reason: "用户补充了另一件事,原事件的日期或主体仍待确认。", - expectedInformationGain: .85, dateSensitivity: .75, candidateSplitRelevance: .6, domainCoverageGain: 0, recallEase: .8, novelty: .7, repetitionPenalty: .15, privacyCost: .05, - })); - } - if (opportunities.length > 0) { - return opportunities.sort((left, right) => - right.utility - left.utility - || left.opportunityId.localeCompare(right.opportunityId)); + + if (input.targetDisposition === "answered_other_event") { + for (const eventId of retryTargets) { + const event = input.events.find((value) => value.eventId === eventId); + if (!event || (targetAttempts.get(eventId) ?? 0) > 1) continue; + const anchor = anchorFor(event); + opportunities.push(opportunity(input.caseId, { + kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId, + goal: `温和确认“${anchor}”尚缺的日期或主体;允许用户直接跳过。`, + requestedFields: ["event_range"], anchors: [anchor], contextFacts: [`用户刚补充了另一件完整事件。`, `同一目标最多补问一次。`], + fallbackPrompt: `关于“${anchor}”,如果还记得大概时间范围,可以补充一下吗?`, + reason: "用户回答了另一件新事件,原目标只允许一次温和补问。", + expectedInformationGain: .78, dateSensitivity: .7, candidateSplitRelevance: .55, domainCoverageGain: 0, + recallEase: .72, novelty: .55, repetitionPenalty: .25, privacyCost: .05, + })); + } } + + const targetClosed = input.targetDisposition === "unknown" + || input.targetDisposition === "declined" + || input.targetDisposition === "direction_change"; for (const event of input.events) { - if (retryTargets.has(event.eventId)) continue; - if ((event.scoreability === "pending_review" || event.subject === "other") && !attempted.has(event.eventId)) { + if (targetClosed && retryTargets.has(event.eventId)) continue; + const attemptCount = targetAttempts.get(event.eventId) ?? 0; + const anchor = anchorFor(event); + if ((event.scoreability === "pending_review" || event.subject === "other") && attemptCount === 0) { opportunities.push(opportunity(input.caseId, { kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId, - prompt: `你刚才提到“${event.summary}”,这件事主要发生在你本人,还是家人或伴侣身上?`, reason: "事件主体决定是否允许进入个人分盘评分。", - expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: .05, - })); - } - if (event.scoreability === "scoreable" && event.dateRange.precision !== "day" && !attempted.has(event.eventId)) { - const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId); - opportunities.push(opportunity(input.caseId, { - kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId, - prompt: `关于“${event.summary}”,你还记得更具体的月份或日期吗?不确定也可以只说大概范围。`, reason: "日期采样显示这件事的时间精度可能影响候选排序。", - expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .72, - dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .7, - candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: .72, novelty: .8, repetitionPenalty: 0, privacyCost: .05, + goal: `确认“${anchor}”发生在本人、家人还是伴侣。`, requestedFields: ["event_subject"], + anchors: [anchor], contextFacts: [`当前主体为 ${event.subject}。`], + fallbackPrompt: `“${anchor}”主要发生在你本人、家人还是伴侣身上?`, + reason: "事件主体决定是否允许进入个人评分。", + expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, + recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: event.domain === "health_pressure" || event.domain === "family" ? .24 : .05, })); } + if (event.scoreability !== "scoreable" || event.dateRange.precision === "day" || attemptCount > 0) continue; + const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId); + const dateSensitive = Boolean(sensitivity && (sensitivity.winnerRetentionRate < .65 || sensitivity.candidateClusterRetentionRate < .65)); + const precision = event.dateRange.precision; + const shouldRefine = precision === "quarter" || precision === "year" || (precision === "month" && dateSensitive) + || (precision === "range" && daysWide(event) > 120 && dateSensitive); + if (!shouldRefine) continue; + const requestedFields: SemanticQuestionOpportunity["requestedFields"] = precision === "year" || precision === "quarter" + ? ["event_month"] + : precision === "range" ? ["event_range"] : ["event_day"]; + const fallbackPrompt = precision === "year" || precision === "quarter" + ? `“${anchor}”大概发生在哪个月,或一年中的哪个时间段?` + : precision === "range" + ? `“${anchor}”的时间范围还能再缩小一些吗?` + : `关于“${anchor}”,你还记得大概哪一天吗?`; + opportunities.push(opportunity(input.caseId, { + kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId, + goal: `仅在必要精度上细化“${anchor}”的日期。`, requestedFields, anchors: [anchor], + contextFacts: [`现有精度为 ${precision}。`, ...(sensitivity ? [`候选保持率 ${sensitivity.candidateClusterRetentionRate}。`] : [])], + fallbackPrompt, reason: dateSensitive ? "日期敏感性诊断显示该事件可能改变候选排序。" : "当前日期范围较宽。", + expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .66, + dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .55, + candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: precision === "year" ? .8 : .62, + novelty: .78, repetitionPenalty: 0, privacyCost: .05, + })); } + const split = input.diagnostics?.candidateSplits[0]; if (split) { - const target = input.events.find((event) => split.eventIds.includes(event.eventId)); + const target = input.events.find((event) => split.eventIds.includes(event.eventId) + && (targetAttempts.get(event.eventId) ?? 0) === 0 + && !(targetClosed && retryTargets.has(event.eventId))); + const anchor = target ? anchorFor(target) : null; opportunities.push(opportunity(input.caseId, { kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null, - prompt: target ? `围绕“${target.summary}”,当时最明显的转折是事情开始、达到高峰,还是正式结束?` : "剩余候选在同一事件的阶段上有差异:你记得当时更接近开始、达到高峰,还是正式结束吗?", - reason: `候选簇在 ${split.techniqueLayers.slice(0, 3).join("、") || "技术层"} 上出现可检验分歧。`, - expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: target && attempted.has(target.eventId) ? .35 : 0, privacyCost: .1, + goal: target ? `确认“${anchor}”更接近开始、高峰还是正式结束。` : "确认一件现有事件的发生阶段。", + requestedFields: ["event_stage"], anchors: anchor ? [anchor] : [], + contextFacts: [`候选分歧涉及 ${split.techniqueLayers.length} 个已计算技术层。`], + fallbackPrompt: target ? `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?` : "那件经历更接近开始、达到高峰,还是正式结束?", + reason: "候选簇在现有诊断中出现可检验分歧。", + expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, + recallEase: .65, novelty: .9, repetitionPenalty: 0, privacyCost: .1, })); } - const missingDomain = domains.find((domain) => !scoreableDomains.has(domain)); - if (missingDomain) { - const prompts: Record = { - education: "你人生中有没有一次入学、毕业、考试或专业变化,时间大致在什么时候?", - relocation: "你有没有一次印象深刻的搬家、离乡或长期迁居?大致在什么时候?", - relationship: "你有没有一段关系正式开始、结束或进入婚姻的明确时间点?", - career: "你有没有一次入职、离职、升职、转行或创业的明确时间点?", - finance: "你有没有一次收入、投资、负债或资产状况明显改变的时间点?", - health_pressure: "你本人有没有一次住院、手术、事故或明显健康转折?大致在什么时候?", - family: "请补充一个家庭事件。", other: "请补充一个有明确时间的重要人生事件。", - }; + + const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length; + for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude, (typeof domainPolicy)[Exclude]][]) { + if (refusedDomains.has(domain)) continue; + const covered = scoreableDomains.has(domain); + const themeBonus = latestEvent + ? latestEvent.domain === domain ? .22 : 0 + : policy.keywords.test(latestContext) ? .22 : 0; + const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId); + const latestAnchor = latestEvent ? anchorFor(latestEvent) : null; opportunities.push(opportunity(input.caseId, { - kind: "ask_new_event", domain: missingDomain, targetEventId: null, prompt: prompts[missingDomain], reason: "当前证据领域覆盖不足。", - expectedInformationGain: .7, dateSensitivity: .45, candidateSplitRelevance: .5, domainCoverageGain: 1, recallEase: .7, novelty: 1, repetitionPenalty: 0, privacyCost: missingDomain === "health_pressure" ? .2 : .08, + kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal, + requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [], + contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}。`, "需要另一件独立事件,不要把最新事件换词重问。"], + fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。", + expectedInformationGain: covered ? .54 + themeBonus : .65 + themeBonus / 2, + dateSensitivity: input.snapshot ? .5 : .35, + candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42, + domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15, + recallEase: policy.recallEase, novelty: alreadyAsked ? .35 : .9, + repetitionPenalty: alreadyAsked ? .3 : 0, privacyCost: policy.privacyCost, })); } - return opportunities.sort((left, right) => - right.utility - left.utility - || left.opportunityId.localeCompare(right.opportunityId)); + + return opportunities + .sort((left, right) => right.utility - left.utility || left.opportunityId.localeCompare(right.opportunityId)) + .slice(0, 5); } diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index 29e2085c..13073858 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -1,9 +1,10 @@ import { createHash, randomUUID } from "node:crypto"; -import type { RectificationV4CandidateEngine } from "../rectification-v4/candidate-engine.ts"; +import type { CandidateEngineResult, RectificationV4CandidateEngine } from "../rectification-v4/candidate-engine.ts"; import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.ts"; -import type { CandidateSnapshot, RectificationV4Question } from "../rectification-v4/contracts.ts"; +import type { CandidateSnapshot, RectificationAnalysisTrace, RectificationV4Question } from "../rectification-v4/contracts.ts"; import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts"; import { reconcileV4Evidence } from "../rectification-v4/extraction.ts"; +import { extractEventWithModel } from "./event-extractor-agent.ts"; import { evidenceSetHash } from "../rectification-v4/fingerprints.ts"; import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts"; import { projectLegacyV4Turn } from "../rectification-v4/legacy-projector.ts"; @@ -16,18 +17,93 @@ import { recordRectificationAgentTelemetry } from "./telemetry.ts"; import { candidateFeatureSnapshotSchema, diagnosticsSummarySchema, + vedAstroPostValidationSchema, validateRectificationDecision, type AgentRun, type CandidateFeatureSnapshot, type DiagnosticsSummary, - type PublicMessage, + type StoredPublicMessage, type ValidatedDecision, + type VedAstroPostValidation, } from "./contracts.ts"; function hash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +function vedAstroCandidateTimes(snapshot: CandidateSnapshot): readonly [string, string] | null { + const primary = snapshot.clusters[0]?.representativeTime; + if (!primary) return null; + const runnerUp = snapshot.clusters[1]?.representativeTime + ?? [...snapshot.candidates].sort((left, right) => right.score - left.score).find((candidate) => candidate.time !== primary)?.time; + return runnerUp && runnerUp !== primary ? [primary, runnerUp] : null; +} + +function blockedVedAstroValidation(now: Date, blocker: string, candidateTimes: readonly [string, string] | null): VedAstroPostValidation { + const safe = { + contractVersion: "vedastro-post-validation-v1" as const, + provider: "vedastro_official" as const, + status: "blocked" as const, + providerStatus: "unavailable", + blockers: [blocker], + primaryCandidateTime: candidateTimes?.[0] ?? null, + runnerUpCandidateTime: candidateTimes?.[1] ?? null, + eligibleEventCount: 0, + selectedEventCount: 0, + unsupportedEventCount: 0, + candidateMetrics: [], + minuteSensitiveValidation: { comparisonReady: false, discriminated: false, discriminatedLayers: [] }, + canConfirmExactMinute: false as const, + }; + return vedAstroPostValidationSchema.parse({ + ...safe, + validationHash: hash(safe), + validatedAt: now.toISOString(), + }); +} + +const analysisPhaseLabels = { + extracting_evidence: "整理用户经历", + scoring_candidates: "扫描候选分钟", + checking_robustness: "检查候选稳定性", + planning_question: "生成语义问题机会", + reasoning: "选择下一步动作", + rendering: "生成安全回复", +} as const; + +const diagnosticLabels = { + leave_one_event_out: "留一事件稳定性", + leave_one_domain_out: "留一领域稳定性", + date_sensitivity: "日期敏感性", + neighbor_stability: "相邻分钟稳定性", + candidate_split: "候选分裂诊断", +} as const; + +type AnalysisPhase = keyof typeof analysisPhaseLabels; + +export function publicRectificationTechniques(result: CandidateEngineResult | null): string[] { + if (!result) return []; + const techniques = new Set(); + const add = (value: string) => { + const normalized = value.toLocaleLowerCase(); + if (normalized.includes("vim")) techniques.add("Vimshottari Dasha"); + if (normalized.includes("narayana")) techniques.add("Narayana Dasha"); + if (normalized.includes("controlled_transit")) techniques.add("木星/土星受控行运"); + if (normalized.includes("ashtakavarga")) techniques.add("Ashtakavarga"); + if (normalized.includes("shadbala")) techniques.add("Shadbala 已验证分量"); + for (const layer of ["D2", "D4", "D9", "D10", "D11", "D24", "D30"] as const) { + if (new RegExp(`(?:^|[^0-9])${layer}(?:$|[^0-9])`, "i").test(value)) techniques.add(layer); + } + }; + for (const candidate of Object.values(result.contributionMatrix)) { + for (const contribution of Object.values(candidate)) { + contribution.rule_ids.forEach(add); + contribution.technique_layers.forEach(add); + } + } + return [...techniques]; +} + export async function processRectificationAgentTurn(input: Readonly<{ claimed: ClaimedRectificationV4Job; engine: RectificationV4CandidateEngine; @@ -40,23 +116,65 @@ export async function processRectificationAgentTurn(input: Readonly<{ diagnostics: DiagnosticsSummary | null; featureSnapshot: CandidateFeatureSnapshot | null; validatedDecision: ValidatedDecision; - publicMessage: PublicMessage; + publicMessage: StoredPublicMessage; nextQuestion: RectificationV4Question | null; agentRun: AgentRun; status: "awaiting_answer" | "range_ready" | "paused"; phase: "collecting_evidence" | "complete"; }>> { const { claimed, now } = input; - await input.onPhase?.("extracting_evidence"); - const reconciliation = claimed.turn.answer ? reconcileV4Evidence({ + const stages: RectificationAnalysisTrace["stages"] = []; + let activePhase: AnalysisPhase | null = null; + let activePhaseStarted = 0; + const finishPhase = (status: "completed" | "failed" = "completed") => { + if (!activePhase) return; + stages.push({ + phase: activePhase, + label: analysisPhaseLabels[activePhase], + status, + durationMs: Math.max(0, Date.now() - activePhaseStarted), + }); + activePhase = null; + }; + const enterPhase = async (phase: AnalysisPhase) => { + finishPhase(); + activePhase = phase; + activePhaseStarted = Date.now(); + await input.onPhase?.(phase); + }; + await enterPhase("extracting_evidence"); + const asOfDate = now.toISOString().slice(0, 10); + let reconciliation = claimed.turn.answer ? reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, - asOfDate: now.toISOString().slice(0, 10), + asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now, - }) : { revisions: [], pending: [], unansweredTargetEventId: null }; + }) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const }; + const needsAssistance = claimed.case.deploymentMode !== "v4_legacy" && ( + reconciliation.pending.some((event) => event.reasonCode === "event_unparsed") + || reconciliation.revisions.some((event) => event.scoreability === "pending_review" || event.scoreability === "unsupported") + ); + if (needsAssistance) { + const assisted = await extractEventWithModel({ + rawText: claimed.turn.answer, + sourceTurnId: claimed.turn.id, + asOfDate, + modelId: claimed.case.orchestrationModelId, + }); + if (assisted) reconciliation = reconcileV4Evidence({ + caseId: claimed.case.id, + answer: claimed.turn.answer, + sourceTurnId: claimed.turn.id, + asOfDate, + existing: claimed.events, + targetEventId: claimed.turn.questionTargetEventId, + assistedEvidence: [assisted], + now, + }); + } const extracted = reconciliation.revisions; const events = latestEventRevisions([...claimed.events, ...extracted]); const scoreable = scoreableEvents(events); @@ -64,15 +182,21 @@ export async function processRectificationAgentTurn(input: Readonly<{ let snapshot: CandidateSnapshot | null = null; let diagnostics: DiagnosticsSummary | null = null; let featureSnapshot: CandidateFeatureSnapshot | null = null; + let engineResult: CandidateEngineResult | null = null; + const analysisToolCalls: RectificationAnalysisTrace["toolCalls"] = []; if (scoreable.length >= 3 && domains.size >= 2) { - await input.onPhase?.("scoring_candidates"); + await enterPhase("scoring_candidates"); + const engineStarted = Date.now(); const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable }); - await input.onPhase?.("checking_robustness"); + engineResult = scored; + analysisToolCalls.push({ category: "candidate_engine", label: "候选分钟扫描与稳定性诊断", outcome: "succeeded", durationMs: Date.now() - engineStarted }); + await enterPhase("checking_robustness"); const clusters = buildCandidateClusters(scored.candidates); const robustness = { neighborSupportMinutes: scored.robustness.neighborSupportMinutes, leaveOneOutRetentionRate: scored.robustness.leaveOneOutRetentionRate, + leaveOneDomainOutRetentionRate: scored.robustness.leaveOneDomainOutRetentionRate, dateSensitivityRetentionRate: scored.robustness.dateSensitivityRetentionRate, calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash, }; @@ -80,7 +204,8 @@ export async function processRectificationAgentTurn(input: Readonly<{ clusters, robustness, scoreableEventCount: scoreable.length, - scoreableDomainCount: domains.size, + scoreableDomains: [...domains], + missingTechniqueLayers: scored.missingLayers, }); snapshot = { id: scored.resultId, @@ -94,7 +219,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ robustness, canConfirmExactMinute: false, canAcceptRange: gate.canAcceptRange, - gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)], + gateReasons: [...gate.reasons], createdAt: now.toISOString(), }; diagnostics = diagnosticsSummarySchema.parse({ @@ -148,6 +273,50 @@ export async function processRectificationAgentTurn(input: Readonly<{ }); } + if (snapshot?.canAcceptRange && diagnostics && claimed.case.deploymentMode === "v5_agent") { + const candidateTimes = vedAstroCandidateTimes(snapshot); + const validationStarted = Date.now(); + let externalValidation: VedAstroPostValidation; + let outcome: "succeeded" | "failed" | "rejected"; + if (!candidateTimes) { + externalValidation = blockedVedAstroValidation(now, "vedastro_runner_up_candidate_missing", null); + outcome = "rejected"; + } else if (!input.engine.validateWithVedAstro) { + externalValidation = blockedVedAstroValidation(now, "vedastro_validator_unavailable", candidateTimes); + outcome = "failed"; + } else { + try { + externalValidation = await input.engine.validateWithVedAstro({ + calculationSpec: claimed.case.calculationSpec, + events: scoreable, + candidateTimes, + }); + outcome = externalValidation.status === "pass" ? "succeeded" : "rejected"; + } catch { + externalValidation = blockedVedAstroValidation(now, "vedastro_validation_failed", candidateTimes); + outcome = "failed"; + } + } + diagnostics = diagnosticsSummarySchema.parse({ ...diagnostics, externalValidation }); + analysisToolCalls.push({ + category: "diagnostic", + label: "VedAstro 事后校验", + outcome, + durationMs: Date.now() - validationStarted, + }); + if (externalValidation.status !== "pass") { + snapshot = { + ...snapshot, + canAcceptRange: false, + gateReasons: [...new Set([ + ...snapshot.gateReasons, + "vedastro_validation_not_passed", + ...externalValidation.blockers, + ])].slice(0, 20), + }; + } + } + const safeDiagnostics = diagnostics ?? diagnosticsSummarySchema.parse({ id: randomUUID(), caseId: claimed.case.id, @@ -167,21 +336,31 @@ export async function processRectificationAgentTurn(input: Readonly<{ createdAt: now.toISOString(), }); - await input.onPhase?.("planning_question"); + await enterPhase("planning_question"); const opportunities = buildQuestionOpportunities({ caseId: claimed.case.id, events, turns: claimed.turns, snapshot, diagnostics, + targetDisposition: reconciliation.targetDisposition, retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [], }); - await input.onPhase?.("reasoning"); + await enterPhase("reasoning"); const reasoned = await runBoundedReasoner({ caseValue: claimed.case, snapshot, diagnostics: safeDiagnostics, opportunities, + recentTurns: claimed.turns, + recentEvents: events, + currentTarget: claimed.turn.questionTargetEventId + ? events.find((event) => event.eventId === claimed.turn.questionTargetEventId) ?? null + : null, + targetDisposition: reconciliation.targetDisposition, + pendingEvidence: reconciliation.pending, + candidateRangeChanged: claimed.case.latestSnapshot?.clusters[0]?.startTime !== snapshot?.clusters[0]?.startTime + || claimed.case.latestSnapshot?.clusters[0]?.endTime !== snapshot?.clusters[0]?.endTime, enabled: claimed.case.deploymentMode !== "v4_legacy", }); const rawDecision = reasoned.decision; @@ -225,7 +404,7 @@ export async function processRectificationAgentTurn(input: Readonly<{ selectedOpportunity, }; - await input.onPhase?.("rendering"); + await enterPhase("rendering"); const legacyProjection = projectLegacyV4Turn({ events, newEvents: extracted, @@ -234,21 +413,41 @@ export async function processRectificationAgentTurn(input: Readonly<{ snapshot, }); const agentVisible = claimed.case.deploymentMode === "v5_agent"; - const publicMessage = agentVisible + const renderedMessage = agentVisible ? await renderPublicTurn({ caseValue: claimed.case, latestAnswer: claimed.turn.answer, acceptedEvents: extracted, pendingEvidence: reconciliation.pending, snapshot, + previousSnapshot: claimed.case.latestSnapshot, validated: validatedDecision, }) : legacyProjection.publicMessage; + finishPhase(); + for (const call of reasoned.toolCalls) { + analysisToolCalls.push({ + category: "agent_diagnostic", + label: call.diagnostic ? diagnosticLabels[call.diagnostic] : "只读诊断", + outcome: call.outcome, + durationMs: call.durationMs, + }); + } + const reasoningSummary = reasoned.mode === "agent" && !fallbackReason ? reasoned.reasoningSummary : null; + const analysisTrace: RectificationAnalysisTrace = { + status: claimed.case.deploymentMode === "v4_legacy" ? "legacy" : "completed", + stages, + toolCalls: analysisToolCalls, + techniques: publicRectificationTechniques(engineResult), + reasoningSummary, + reasoningSource: reasoningSummary ? "provider_summary" : "none", + }; + const publicMessage: StoredPublicMessage = { ...renderedMessage, analysisTrace }; const nextQuestion = agentVisible && selectedOpportunity ? { id: randomUUID(), domain: selectedOpportunity.domain, targetEventId: selectedOpportunity.targetEventId, - prompt: selectedOpportunity.prompt, + prompt: publicMessage.question ?? selectedOpportunity.fallbackPrompt, recallCost: selectedOpportunity.privacyCost >= .2 ? "high" as const : selectedOpportunity.recallEase < .6 diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts index 18eec113..a493161f 100644 --- a/frontend/src/lib/rectification-agent/reasoner-agent.ts +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -3,7 +3,9 @@ import { Agent } from "@mastra/core/agent"; import { createTool } from "@mastra/core/tools"; import { z } from "zod"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; -import type { CandidateSnapshot, RectificationV4Case } from "../rectification-v4/contracts.ts"; +import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts"; +import type { TargetDisposition } from "../rectification-v4/extraction.ts"; import { deterministicDecision } from "./fallback-policy.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; import { @@ -18,12 +20,40 @@ import { const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); type Usage = Readonly<{ inputTokens?: number; outputTokens?: number }>; -type GeneratedDecision = Readonly<{ object: unknown; totalUsage?: Usage | Promise }>; +type GeneratedDecision = Readonly<{ + object: unknown; + totalUsage?: Usage | Promise; + reasoningSummary?: string | null; + reasoningSource?: "provider_summary" | null; +}>; export type RectificationReasonerGenerator = ( prompt: string, phase: "initial" | "after_diagnostic", ) => Promise; +const unsafeReasoningPattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|(?:[01]\d|2[0-3]):[0-5]\d|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?[零〇一二两三四五六七八九十百\d]{1,4}[点时](?:[零〇一二两三四五六七八九十百\d]{1,4}分?)?|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|tool[ _-]?call|score|diagnostic|rule[ _-]?id|贡献矩阵|内部字段|权重|保留率|比例|百分之|分数|得分|阈值|边际|cluster|D\d{1,2})/iu; + +function compactText(value: string): string { + return value.replace(/\s+/gu, "").toLocaleLowerCase(); +} + +export function sanitizeReasoningSummary(value: unknown, sensitiveTexts: readonly string[] = []): string | null { + if (typeof value !== "string") return null; + const text = value.replace(/\s+/gu, " ").trim(); + if (!text || unsafeReasoningPattern.test(text)) return null; + const compact = compactText(text); + for (const sensitiveText of sensitiveTexts) { + const source = compactText(sensitiveText); + if (source.length < 2) continue; + const overlapLength = Math.min(4, source.length); + for (let index = 0; index <= source.length - overlapLength; index += 1) { + if (compact.includes(source.slice(index, index + overlapLength))) return null; + } + } + const sentences = text.match(/[^。!?!?]+[。!?!?]?/gu)?.slice(0, 2).join("").trim() ?? text; + return sentences.slice(0, 240).trim() || null; +} + function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: DiagnosticsSummary) { switch (diagnostic) { case "leave_one_event_out": return { retentionRate: summary.leaveOneEventOutRetentionRate, unstableEventIds: summary.unstableEventIds }; @@ -34,11 +64,53 @@ function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: Diagnos } } +export function buildReasonerState(input: Readonly<{ + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary; + opportunities: readonly QuestionOpportunity[]; + recentTurns?: readonly RectificationV4Turn[]; + recentEvents?: readonly LifeEventRevision[]; + currentTarget?: LifeEventRevision | null; + targetDisposition?: TargetDisposition; + pendingEvidence?: readonly PendingEvidence[]; + candidateRangeChanged?: boolean; +}>) { + return { + task: "Choose the next bounded rectification action.", + currentSnapshotId: input.snapshot?.id ?? null, + canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false, + hasCandidateRange: Boolean(input.snapshot?.clusters[0]), + candidateRangeChanged: input.candidateRangeChanged ?? false, + latestAnswer: input.recentTurns?.at(-1)?.answer ?? "", + recentTurns: (input.recentTurns ?? []).slice(-6).map((turn) => ({ question: turn.question, answer: turn.answer })), + recentEvents: chronologicalEvents(input.recentEvents ?? []).slice(-5).map((event) => ({ summary: event.summary, date: event.dateRange.label, domain: event.domain, subject: event.subject })), + currentTarget: input.currentTarget ? { summary: input.currentTarget.summary, date: input.currentTarget.dateRange.label, domain: input.currentTarget.domain } : null, + targetDisposition: input.targetDisposition ?? "not_applicable", + pendingEvidence: { + count: input.pendingEvidence?.length ?? 0, + reasons: [...new Set((input.pendingEvidence ?? []).map((item) => item.reasonCode))], + }, + compactDiagnostics: { + primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate, + mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers, + }, + opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, goal, requestedFields, anchors, utility, reason }) => ({ + opportunityId, kind, targetEventId, goal, requestedFields, anchors, utility, reason, + })), + }; +} + export async function runBoundedReasoner(input: Readonly<{ caseValue: RectificationV4Case; snapshot: CandidateSnapshot | null; diagnostics: DiagnosticsSummary; opportunities: readonly QuestionOpportunity[]; + recentTurns?: readonly RectificationV4Turn[]; + recentEvents?: readonly LifeEventRevision[]; + currentTarget?: LifeEventRevision | null; + targetDisposition?: TargetDisposition; + pendingEvidence?: readonly PendingEvidence[]; + candidateRangeChanged?: boolean; maxToolCalls?: number; timeoutMs?: number; enabled?: boolean; @@ -51,6 +123,7 @@ export async function runBoundedReasoner(input: Readonly<{ inputTokenCount: number | null; outputTokenCount: number | null; latencyMs: number; + reasoningSummary: string | null; }>> { const started = Date.now(); const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; @@ -72,6 +145,7 @@ export async function runBoundedReasoner(input: Readonly<{ inputTokenCount: usageObserved ? inputTokenCount : null, outputTokenCount: usageObserved ? outputTokenCount : null, latencyMs: Date.now() - started, + reasoningSummary: null, }; }; if (input.enabled === false) return fallback("deployment_mode_legacy"); @@ -116,13 +190,30 @@ export async function runBoundedReasoner(input: Readonly<{ tools: { run_rectification_diagnostics: diagnosticsTool }, instructions: "Choose one server-owned action. Never create an event id, candidate, score, date, question, calculation input, or birth minute. Ask only by opportunityId. Candidate ranges may only use currentSnapshotId. You may request or call one diagnostic, then must return a final non-diagnostic action. Return strict structured output.", }) : null; + const isOpenAiProvider = model?.mode === "openai"; const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => { if (!agent) throw new Error("reasoner_model_unavailable"); - return agent.generate(prompt, { + let reasoningSummary = ""; + const stream = await agent.stream(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000), maxSteps: maxToolCalls + 2, + providerOptions: isOpenAiProvider + ? { openai: { reasoningEffort: "high", reasoningSummary: "auto" } } + : undefined, structuredOutput: { schema: rectificationDecisionSchema, jsonPromptInjection: "inline" }, }); + for await (const chunk of stream.fullStream) { + const isOpenAiSummary = isOpenAiProvider && chunk.type === "reasoning-delta"; + if (isOpenAiSummary && reasoningSummary.length < 2_000) { + reasoningSummary += chunk.payload.text.slice(0, 2_000 - reasoningSummary.length); + } + } + return { + object: await stream.object, + totalUsage: stream.totalUsage, + reasoningSummary, + reasoningSource: reasoningSummary ? "provider_summary" : null, + }; }); const addUsage = async (result: GeneratedDecision) => { if (!result.totalUsage) return; @@ -131,21 +222,23 @@ export async function runBoundedReasoner(input: Readonly<{ outputTokenCount += Math.max(0, Math.trunc(usage.outputTokens ?? 0)); usageObserved = true; }; - const baseState = { - task: "Choose the next bounded rectification action.", - currentSnapshotId: input.snapshot?.id ?? null, - canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false, - compactDiagnostics: { - primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate, - mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers, - }, - opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, utility, reason }) => ({ opportunityId, kind, targetEventId, utility, reason })), - }; + const baseState = buildReasonerState(input); + const sensitiveTexts = [ + baseState.latestAnswer, + ...baseState.recentTurns.flatMap((turn) => [turn.question, turn.answer]), + ...baseState.recentEvents.flatMap((event) => [event.summary, event.date]), + ...(baseState.currentTarget ? [baseState.currentTarget.summary, baseState.currentTarget.date] : []), + ...baseState.opportunities.flatMap((opportunity) => opportunity.anchors), + ]; + let reasoningSummary: string | null = null; recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "started", modelId, toolName: null, decisionAction: null, durationMs: null, errorCode: null, deploymentSha }); try { const first = await generate(JSON.stringify(baseState), "initial"); await addUsage(first); + reasoningSummary = first.reasoningSource === "provider_summary" + ? sanitizeReasoningSummary(first.reasoningSummary, sensitiveTexts) + : null; let decision = rectificationDecisionSchema.parse(first.object); if (decision.action === "run_diagnostic") { const result = await readDiagnostic(decision.diagnostic); @@ -155,6 +248,9 @@ export async function runBoundedReasoner(input: Readonly<{ diagnosticResult: { diagnostic: decision.diagnostic, result }, }), "after_diagnostic"); await addUsage(second); + reasoningSummary = second.reasoningSource === "provider_summary" + ? sanitizeReasoningSummary(second.reasoningSummary, sensitiveTexts) ?? reasoningSummary + : reasoningSummary; decision = rectificationDecisionSchema.parse(second.object); if (decision.action === "run_diagnostic") return fallback("reasoner_returned_nonfinal_diagnostic"); } @@ -165,6 +261,7 @@ export async function runBoundedReasoner(input: Readonly<{ inputTokenCount: usageObserved ? inputTokenCount : null, outputTokenCount: usageObserved ? outputTokenCount : null, latencyMs, + reasoningSummary, }; } catch (error) { const reason = error instanceof DOMException && error.name === "TimeoutError" ? "reasoner_timeout" diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 553c779c..c5ef92c7 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -1,47 +1,198 @@ import path from "node:path"; import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts"; -import { publicMessageSchema, type PublicMessage, type ValidatedDecision } from "./contracts.ts"; +import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts"; import { recordRectificationAgentTelemetry } from "./telemetry.ts"; const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); const agents = new Map(); +const bannedAcknowledgement = /(?:这个信息很有用|它不是单纯的|而是把|接下来最有价值的是|这样可以避免|已记录[::]?|我记下了)/; +const overinterpretedAcknowledgement = /(?:职业方向正式落地|人生意义|意味着你|说明你(?:已经|开始|正式)|标志着你)/; +const internalTerms = /(?:opportunityId|snapshotId|eventId|targetEventId|requestedFields|fallbackPrompt|tool\s*call|tool_call|score|评分|模型名|opportunity|snapshot|D\d{1,2}|KP\b|Vimshottari)/i; +const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便|再告诉我)/; +const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']?,?请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对|以[“\"']?.{0,80}[”\"']?为(?:时间)?参照|搬到新城市|长期离乡)/; +const exactClockMinute = /(?:[01]?\d|2[0-3])[::][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/; +const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/; +const distinctEventMove = /(?:除了|另一(?:件|次)|下(?:一|1)次|之后|后来|此后|还记得)/; +const explicitAnchorReference = /(?:这次经历|这段经历|刚才那段|刚才这段|你刚说的|你刚提到的|刚说的|刚提到的|前面那段|这件事)/; +const newEventDomainTerms: Readonly>> = { + education: /(?:入学|升学|毕业|学校|大学|专业|考试|读书)/, + relocation: /(?:搬家|搬到|搬去|迁居|迁到|迁往|移居|定居)/, + relationship: /(?:恋爱|关系|结婚|离婚|分手|伴侣|对象)/, + career: /(?:工作|实习|公司|研究院|职业|入职|离职|创业|职责|负责)/, + finance: /(?:收入|负债|投资|资产|财务|买房|卖房)/, + health_pressure: /(?:住院|手术|事故|健康|生病|确诊|康复)/, +}; +const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict(); + function agentFor(modelId: string | null): { id: string; agent: Agent } | null { const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); if (!selected) return null; const cached = agents.get(selected.id); if (cached) return { id: selected.id, agent: cached }; const agent = new Agent({ - id: `rectification-v5-renderer-${selected.id}`, name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath], - instructions: "Write concise natural Simplified Chinese. Acknowledge the latest experience, state uncertainty honestly, and never expose ids, scores, internal domains, representative minutes, model/tool details, or claim an exact birth minute. Return strict JSON only.", + id: `rectification-v6-renderer-${selected.id}`, + name: "Birth Time Rectification Response Renderer", + model: selected.model, + skills: [skillPath], + instructions: "Write concise natural Simplified Chinese. Realize exactly one question from the supplied semantic opportunity. Do not invent events or dates, switch targets, interpret the life meaning of an experience, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.", }); agents.set(selected.id, agent); return { id: selected.id, agent }; } -function deterministic(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision }): PublicMessage { - const latest = input.acceptedEvents.at(-1); - const acknowledgement = latest - ? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。` - : input.pendingEvidence.length - ? "我保留了你刚才的原始描述;其中的日期或事件关系还不能安全进入评分。" - : input.latestAnswer - ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" - : "我会继续根据已确认的人生事件比较候选范围。"; - const primary = input.snapshot?.clusters[0]; - const candidateUpdate = primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null; - const limitation = input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我不会把不稳定结果包装成确定时间。" : null; - return { acknowledgement, candidateUpdate, limitation, question: input.validated.selectedOpportunity?.prompt ?? null }; +function normalized(value: string): string { + return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, ""); } -export function enforceServerQuestion(value: unknown, question: string | null): PublicMessage { - return { ...publicMessageSchema.parse(value), question }; +function matchingAnchorFragment(question: string, anchor: string): string | null { + const normalizedQuestion = normalized(question); + const normalizedAnchor = normalized(anchor); + if (!normalizedAnchor) return null; + if (normalizedQuestion.includes(normalizedAnchor)) return normalizedAnchor; + for (let length = Math.min(normalizedQuestion.length, normalizedAnchor.length); length >= 4; length -= 1) { + for (let start = 0; start <= normalizedAnchor.length - length; start += 1) { + const fragment = normalizedAnchor.slice(start, start + length); + if (normalizedQuestion.includes(fragment)) return fragment; + } + } + return null; +} + +function includesStrictAnchor(question: string, anchor: string): boolean { + const normalizedAnchor = normalized(anchor); + return normalizedAnchor.length > 0 && normalized(question).includes(normalizedAnchor); +} + +function withoutMatchedAnchors(question: string, anchors: readonly string[]): string { + let remaining = normalized(question); + for (const anchor of anchors) { + const matched = matchingAnchorFragment(remaining, anchor); + if (matched) remaining = remaining.replace(matched, ""); + } + return remaining; +} + +function visibleTextSafetyIssues(value: string): string[] { + const issues: string[] = []; + if (internalTerms.test(value)) issues.push("internal_information_exposed"); + if (exactClockMinute.test(value) || exactMinuteClaim.test(value)) issues.push("birth_minute_injected"); + return issues; +} + +export function validateQuestionRealization(question: unknown, opportunity: QuestionOpportunity): Readonly<{ valid: boolean; issues: readonly string[] }> { + if (typeof question !== "string") return { valid: false, issues: ["question_missing"] }; + const value = question.trim(); + const issues: string[] = []; + if (value.length < 8 || value.length > 180) issues.push("question_length_invalid"); + if ((value.match(/[??]/g) ?? []).length > 1) issues.push("multiple_question_marks"); + if ((value.match(/[。.!!??]/g) ?? []).length > 2) issues.push("too_many_sentences"); + if (/\n\s*(?:[-*•]|\d+[.)、])/.test(value)) issues.push("question_list_forbidden"); + issues.push(...visibleTextSafetyIssues(value)); + if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction"); + if (cannedQuestion.test(value)) issues.push("canned_question_forbidden"); + if (opportunity.targetEventId) { + if (!opportunity.anchors.some((anchor) => includesStrictAnchor(value, anchor))) issues.push("target_anchor_missing"); + } else if (opportunity.kind === "ask_new_event") { + const anchorMatched = opportunity.anchors.some((anchor) => matchingAnchorFragment(value, anchor) !== null); + if (opportunity.anchors.length > 0 && !anchorMatched && !explicitAnchorReference.test(value)) issues.push("target_anchor_missing"); + if (opportunity.anchors.length > 0 && !distinctEventMove.test(value)) issues.push("new_event_not_distinct"); + const domainTerms = newEventDomainTerms[opportunity.domain]; + const questionWithoutAnchor = withoutMatchedAnchors(value, opportunity.anchors); + if (domainTerms && !domainTerms.test(questionWithoutAnchor)) issues.push("new_event_domain_mismatch"); + } + for (const field of opportunity.requestedFields) { + if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested"); + if (field === "event_month" && !/(?:月份|哪个月|几月|大概月份|时间段)/.test(value)) issues.push("event_month_not_requested"); + if (field === "event_day" && !/(?:哪一天|几号|具体日期|大概日期)/.test(value)) issues.push("event_day_not_requested"); + if (field === "event_range" && !/(?:大概时间|时间范围|什么时候|哪个时间|哪一段时间)/.test(value)) issues.push("event_range_not_requested"); + if (field === "event_stage" && !/(?:开始|高峰|结束|正式发生)/.test(value)) issues.push("event_stage_not_requested"); + if (field === "new_dated_event" && !/(?:哪次|哪件|一件|经历|变化|转折|发生)/.test(value)) issues.push("new_event_not_requested"); + if (field === "new_dated_event" && !/(?:时间|日期|什么时候|哪年|哪月|几月)/.test(value)) issues.push("new_event_date_not_requested"); + if (field === "event_year" && !/(?:哪年|年份|哪一年)/.test(value)) issues.push("event_year_not_requested"); + } + return { valid: issues.length === 0, issues }; +} + +function primaryRange(snapshot: CandidateSnapshot | null): string | null { + const primary = snapshot?.clusters[0]; + return primary ? `${primary.startTime}–${primary.endTime}` : null; +} + +export function candidateUpdateFor(input: Readonly<{ + snapshot: CandidateSnapshot | null; + previousSnapshot: CandidateSnapshot | null; + decisionAction: ValidatedDecision["decision"]["action"]; +}>): string | null { + if (!input.snapshot?.canAcceptRange) return null; + const current = primaryRange(input.snapshot); + if (!current) return null; + const previous = primaryRange(input.previousSnapshot); + const firstStable = !input.previousSnapshot?.canAcceptRange; + const changed = previous !== current; + if (!firstStable && !changed) return null; + return `目前通过稳定性门的候选范围是 ${current};它仍是待验证范围,不代表其中某一分钟已被确认。`; +} + +function naturalAcknowledgement(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[] }): string { + const latest = input.acceptedEvents.at(-1); + if (latest) return `你提到的是 ${latest.dateRange.label} 的“${latest.summary}”。`; + if (input.pendingEvidence.length) return "这段经历的事件或日期目前还不足以安全进入评分。"; + if (input.latestAnswer) return "我会保留你刚才的原始说法,不补写你没有确认的信息。"; + return "我们继续用时间相对明确的经历比较候选范围。"; +} + +function deterministic(input: { + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; + snapshot: CandidateSnapshot | null; + previousSnapshot: CandidateSnapshot | null; + validated: ValidatedDecision; +}): PublicMessage { + return { + acknowledgement: naturalAcknowledgement(input), + candidateUpdate: candidateUpdateFor({ snapshot: input.snapshot, previousSnapshot: input.previousSnapshot, decisionAction: input.validated.decision.action }), + limitation: input.validated.decision.action === "stop_low_confidence" + ? "现有证据不足以安全缩小范围,我会在这里停下,不把不稳定结果包装成确定时间。" + : null, + question: input.validated.selectedOpportunity?.fallbackPrompt ?? null, + }; +} + +export function realizePublicMessage(value: unknown, input: Parameters[0]): PublicMessage { + const parsed = publicMessageSchema.parse(value); + const opportunity = input.validated.selectedOpportunity; + const fallback = deterministic(input); + const acknowledgement = visibleTextSafetyIssues(parsed.acknowledgement).length > 0 + || bannedAcknowledgement.test(parsed.acknowledgement) + || overinterpretedAcknowledgement.test(parsed.acknowledgement) + || (parsed.acknowledgement.match(/[。.!!??]/g) ?? []).length > 2 + || (input.acceptedEvents.at(-1) && !normalized(parsed.acknowledgement).includes(normalized(input.acceptedEvents.at(-1)!.summary))) + ? fallback.acknowledgement + : parsed.acknowledgement; + const question = opportunity + ? validateQuestionRealization(parsed.question, opportunity).valid ? parsed.question : opportunity.fallbackPrompt + : null; + return { + acknowledgement, + candidateUpdate: fallback.candidateUpdate, + limitation: fallback.limitation ?? (parsed.limitation && visibleTextSafetyIssues(parsed.limitation).length === 0 ? parsed.limitation : null), + question, + }; } export async function renderPublicTurn(input: Readonly<{ - caseValue: RectificationV4Case; latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; - pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision; timeoutMs?: number; + caseValue: RectificationV4Case; + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; + snapshot: CandidateSnapshot | null; + previousSnapshot: CandidateSnapshot | null; + validated: ValidatedDecision; + timeoutMs?: number; }>): Promise { const started = Date.now(); const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; @@ -53,14 +204,30 @@ export async function renderPublicTurn(input: Readonly<{ } recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha }); try { + const opportunity = input.validated.selectedOpportunity; const result = await selected.agent.generate(JSON.stringify({ - task: "Render the public turn. The server-owned question must not be changed.", latestAnswer: input.latestAnswer, + task: "Render one public turn and naturally realize the semantic question contract.", + latestAnswer: input.latestAnswer, acceptedEvents: input.acceptedEvents.slice(-3).map((event) => ({ summary: event.summary, date: event.dateRange.label, subject: event.subject })), - pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ rawText: event.rawText, reasonCode: event.reasonCode })), - candidateRange: input.snapshot?.clusters[0] ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } : null, - action: input.validated.decision.action, exactQuestion: input.validated.selectedOpportunity?.prompt ?? null, + pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ reasonCode: event.reasonCode })), + action: input.validated.decision.action, + selectedOpportunity: opportunity ? { + kind: opportunity.kind, + goal: opportunity.goal, + requestedFields: opportunity.requestedFields, + anchors: opportunity.anchors, + contextFacts: opportunity.contextFacts, + forbiddenMoves: opportunity.forbiddenMoves, + } : null, }), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } }); - const message = enforceServerQuestion(result.object, input.validated.selectedOpportunity?.prompt ?? null); + const generated = publicMessageSchema.parse(result.object); + const questionValidation = opportunity ? validateQuestionRealization(generated.question, opportunity) : null; + const message = realizePublicMessage(generated, input); + if (questionValidation && !questionValidation.valid) { + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "rejected", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: questionValidation.issues[0] ?? "renderer_question_rejected", deploymentSha }); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_question_rejected", deploymentSha }); + return message; + } recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha }); return message; } catch { @@ -69,3 +236,42 @@ export async function renderPublicTurn(input: Readonly<{ return fallback; } } + +export async function regenerateQuestionRealization(input: Readonly<{ + caseValue: RectificationV4Case; + currentPrompt: string; + latestAnswer: string; + acceptedEvents: readonly LifeEventRevision[]; + opportunity: QuestionOpportunity; + timeoutMs?: number; +}>): Promise { + const selected = agentFor(input.caseValue.narrationModelId); + if (!selected) return input.currentPrompt; + try { + const result = await selected.agent.generate(JSON.stringify({ + task: "Rewrite the current question naturally without changing its semantic target. Return one question only.", + currentPrompt: input.currentPrompt, + latestAnswer: input.latestAnswer, + recentEvents: input.acceptedEvents.slice(-5).map((event) => ({ + summary: event.summary, + date: event.dateRange.label, + subject: event.subject, + })), + selectedOpportunity: { + kind: input.opportunity.kind, + goal: input.opportunity.goal, + requestedFields: input.opportunity.requestedFields, + anchors: input.opportunity.anchors, + contextFacts: input.opportunity.contextFacts, + forbiddenMoves: input.opportunity.forbiddenMoves, + }, + }), { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), + structuredOutput: { schema: questionRealizationSchema, jsonPromptInjection: "inline" }, + }); + const question = questionRealizationSchema.parse(result.object).question; + return validateQuestionRealization(question, input.opportunity).valid ? question : input.currentPrompt; + } catch { + return input.currentPrompt; + } +} diff --git a/frontend/src/lib/rectification-v4/candidate-clusters.ts b/frontend/src/lib/rectification-v4/candidate-clusters.ts index 1b32637d..d3138a5f 100644 --- a/frontend/src/lib/rectification-v4/candidate-clusters.ts +++ b/frontend/src/lib/rectification-v4/candidate-clusters.ts @@ -24,6 +24,9 @@ export function buildCandidateClusters( if (group && nextMinute(group.at(-1)!.time, candidate.time)) group.push(candidate); else groups.push([candidate]); } + if (groups.length > 1 && nextMinute(groups.at(-1)!.at(-1)!.time, groups[0]![0]!.time)) { + groups[0] = [...groups.pop()!, ...groups[0]!]; + } return groups.map((group) => { const peakScore = Math.max(...group.map((candidate) => candidate.score)); const peakCandidate = group.find((candidate) => candidate.score === peakScore)!; diff --git a/frontend/src/lib/rectification-v4/candidate-engine.ts b/frontend/src/lib/rectification-v4/candidate-engine.ts index 499cfed3..2f04deaf 100644 --- a/frontend/src/lib/rectification-v4/candidate-engine.ts +++ b/frontend/src/lib/rectification-v4/candidate-engine.ts @@ -1,6 +1,8 @@ +import { createHash } from "node:crypto"; import { z } from "zod"; import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "./contracts.ts"; import { rectificationV4AlgorithmVersion } from "./contracts.ts"; +import { vedAstroPostValidationSchema, type VedAstroPostValidation } from "../rectification-agent/contracts.ts"; const uuid = z.string().uuid(); const hash = z.string().regex(/^[a-f0-9]{64}$/); @@ -45,6 +47,34 @@ const featureSchema = z.object({ fingerprints: z.record(z.string(), z.string()), }).passthrough()), }).passthrough(); +const vedAstroResponseSchema = z.object({ + status: z.enum(["pass", "fail"]), + passed: z.boolean(), + can_confirm_exact_minute: z.literal(false), + candidate_times: z.object({ primary: z.string(), runner_up: z.string() }).strict(), + blockers: z.array(z.string()), + minute_sensitive_validation: z.object({ + comparison_ready: z.boolean(), + discriminated: z.boolean(), + discriminated_layers: z.array(z.string()), + }).passthrough(), + event_validation: z.object({ + eligible_event_count: z.number().int().nonnegative(), + supported_event_count: z.number().int().nonnegative(), + unsupported_events: z.array(z.unknown()), + candidates: z.array(z.object({ + role: z.enum(["primary", "runner_up"]), + metric: z.object({ + requested_event_count: z.number().int().nonnegative(), + successful_event_count: z.number().int().nonnegative(), + matched_event_count: z.number().int().nonnegative(), + event_hit_count: z.number().int().nonnegative(), + signal_lift: z.number().finite(), + }).strict(), + }).passthrough()).max(2), + }).passthrough(), +}).passthrough(); + const responseSchema = z.object({ result_id: uuid, algorithm_version: z.literal(rectificationV4AlgorithmVersion), @@ -79,8 +109,71 @@ export type CandidateEngineResult = Readonly<{ missingLayers: readonly string[]; }>; +function rectificationRequestBody(calculationSpec: CalculationSpec, events: readonly LifeEventRevision[]) { + return { + birth_date: calculationSpec.birthDate, + start_time: calculationSpec.candidateRange.start, + end_time: calculationSpec.candidateRange.end, + lat: calculationSpec.latitude, + lon: calculationSpec.longitude, + tz: calculationSpec.timezoneOffsetHours, + ...(Object.hasOwn(calculationSpec, "birthTimeSource") ? { birth_time_source: calculationSpec.birthTimeSource } : {}), + ...(Object.hasOwn(calculationSpec, "timezoneId") ? { timezone_id: calculationSpec.timezoneId } : {}), + ...(Object.hasOwn(calculationSpec, "timezoneSource") ? { timezone_source: calculationSpec.timezoneSource } : {}), + ...(Object.hasOwn(calculationSpec, "localTimeStatus") ? { local_time_status: calculationSpec.localTimeStatus } : {}), + events: events.map((event) => ({ + id: event.eventId, + domain: event.domain, + event_kind: event.eventKind, + date_start: event.dateRange.start, + date_end: event.dateRange.end, + precision: event.dateRange.precision, + summary: event.summary, + ...(Object.hasOwn(event, "dateSource") ? { date_source: event.dateSource } : {}), + ...(Object.hasOwn(event, "dateReliability") ? { date_reliability: event.dateReliability } : {}), + ...(Object.hasOwn(event, "dateCorroboration") ? { date_corroboration: event.dateCorroboration } : {}), + ...(Object.hasOwn(event, "dateConflictStatus") ? { date_conflict_status: event.dateConflictStatus } : {}), + })), + }; +} + +function projectVedAstroValidation(payload: z.infer): VedAstroPostValidation { + const safe = { + contractVersion: "vedastro-post-validation-v1" as const, + provider: "vedastro_official" as const, + status: payload.passed && payload.status === "pass" ? "pass" as const : "blocked" as const, + providerStatus: payload.status, + blockers: payload.blockers, + primaryCandidateTime: payload.candidate_times.primary, + runnerUpCandidateTime: payload.candidate_times.runner_up, + eligibleEventCount: payload.event_validation.eligible_event_count, + selectedEventCount: payload.event_validation.supported_event_count, + unsupportedEventCount: payload.event_validation.unsupported_events.length, + candidateMetrics: payload.event_validation.candidates.map((candidate) => ({ + role: candidate.role, + requestedEventCount: candidate.metric.requested_event_count, + successfulEventCount: candidate.metric.successful_event_count, + matchedEventCount: candidate.metric.matched_event_count, + eventHitCount: candidate.metric.event_hit_count, + signalLift: candidate.metric.signal_lift, + })), + minuteSensitiveValidation: { + comparisonReady: payload.minute_sensitive_validation.comparison_ready, + discriminated: payload.minute_sensitive_validation.discriminated, + discriminatedLayers: payload.minute_sensitive_validation.discriminated_layers, + }, + canConfirmExactMinute: false as const, + }; + return vedAstroPostValidationSchema.parse({ + ...safe, + validationHash: createHash("sha256").update(JSON.stringify(safe)).digest("hex"), + validatedAt: new Date().toISOString(), + }); +} + export interface RectificationV4CandidateEngine { score(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[] }): Promise; + validateWithVedAstro?(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[]; readonly candidateTimes: readonly [string, string] }): Promise; } export function createRectificationV4CandidateEngine(options: { readonly apiBase: string; readonly fetchImpl?: typeof fetch }): RectificationV4CandidateEngine { @@ -88,14 +181,7 @@ export function createRectificationV4CandidateEngine(options: { readonly apiBase return { async score({ calculationSpec, events }) { const response = await fetchImpl(`${options.apiBase}/api/rectification/v5/score`, { method: "POST", headers: { "content-type": "application/json" }, signal: AbortSignal.timeout(5 * 60_000), - body: JSON.stringify({ - birth_date: calculationSpec.birthDate, start_time: calculationSpec.candidateRange.start, end_time: calculationSpec.candidateRange.end, - lat: calculationSpec.latitude, lon: calculationSpec.longitude, tz: calculationSpec.timezoneOffsetHours, - events: events.map((event) => ({ - id: event.eventId, domain: event.domain, event_kind: event.eventKind, - date_start: event.dateRange.start, date_end: event.dateRange.end, precision: event.dateRange.precision, summary: event.summary, - })), - }), + body: JSON.stringify(rectificationRequestBody(calculationSpec, events)), }); const payload: unknown = await response.json(); if (!response.ok) throw new Error(`rectification_v5_engine_${response.status}`); @@ -115,5 +201,15 @@ export function createRectificationV4CandidateEngine(options: { readonly apiBase contributionMatrix: parsed.event_contribution_matrix, missingLayers: parsed.missing_layers, }; + }, async validateWithVedAstro({ calculationSpec, events, candidateTimes }) { + const response = await fetchImpl(`${options.apiBase}/api/rectification/v5/vedastro-validate`, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(90_000), + body: JSON.stringify({ ...rectificationRequestBody(calculationSpec, events), candidate_times: candidateTimes }), + }); + const payload: unknown = await response.json(); + if (!response.ok) throw new Error(`rectification_v5_vedastro_${response.status}`); + return projectVedAstroValidation(vedAstroResponseSchema.parse(payload)); }}; } diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 886c63c9..b187c891 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -7,23 +7,41 @@ import type { } from "./contracts.ts"; import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts"; import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; +import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts"; +import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { openingQuestion } from "./opening-question.ts"; import type { RectificationV4Store } from "./store.ts"; -export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) { +const regenerationInFlight = new Map>(); + +export function createRectificationV4CaseService( + store: RectificationV4Store, + options: { + readonly now?: () => Date; + readonly regenerateQuestion?: typeof regenerateQuestionRealization; + } = {}, +) { const now = options.now ?? (() => new Date()); + const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization; async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise { - const [events, turns] = await Promise.all([ + const [events, turns, analysis, job] = await Promise.all([ store.loadEvents(userId, caseValue.id), store.loadTurns(userId, caseValue.id), + caseValue.deploymentMode === "v5_agent" + ? store.loadAnalysisMessages(userId, caseValue.id) + : Promise.resolve([]), + jobId + ? store.loadJob(userId, jobId) + : caseValue.status === "processing" ? store.loadActiveJob(userId, caseValue.id) : null, ]); return { case: caseValue, - job: jobId ? await store.loadJob(userId, jobId) : null, + job, events: [...events], turns: [...turns], + analysis: [...analysis], }; } @@ -45,8 +63,8 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op latestSnapshot: null, orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null, narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null, - skillVersion: "birth-time-rectification-v5", - promptVersion: "rectification-agent-v5-1", + skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION, + promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, algorithmVersion: rectificationV4AlgorithmVersion, deploymentMode, agentMode: "deterministic_fallback", @@ -87,6 +105,53 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op return response(input.userId, saved.case, saved.job.id); }, + async regenerateQuestion(input: { + readonly userId: string; + readonly caseId: string; + readonly actionId: string; + readonly expectedCaseVersion: number; + }) { + const replay = await store.loadActionCase(input.userId, input.actionId); + if (replay) return response(input.userId, replay); + + const key = `${input.userId}:${input.actionId}`; + let pending = regenerationInFlight.get(key); + if (!pending) { + pending = (async () => { + const secondReplay = await store.loadActionCase(input.userId, input.actionId); + if (secondReplay) return secondReplay; + const current = await store.loadCase(input.userId, input.caseId); + if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null; + const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId); + const opportunity = validated?.selectedOpportunity; + if (!opportunity) return null; + const [events, turns] = await Promise.all([ + store.loadEvents(input.userId, input.caseId), + store.loadTurns(input.userId, input.caseId), + ]); + const prompt = await realizeQuestion({ + caseValue: current, + currentPrompt: current.currentQuestion.prompt, + latestAnswer: turns.at(-1)?.answer ?? "", + acceptedEvents: events, + opportunity, + }); + return store.replaceCurrentQuestion({ + ...input, + question: { ...current.currentQuestion, id: randomUUID(), prompt }, + now: now().toISOString(), + }); + })(); + regenerationInFlight.set(key, pending); + } + try { + const saved = await pending; + return saved ? response(input.userId, saved) : null; + } finally { + if (regenerationInFlight.get(key) === pending) regenerationInFlight.delete(key); + } + }, + async reviseEvent(input: { readonly userId: string; readonly caseId: string; diff --git a/frontend/src/lib/rectification-v4/client.ts b/frontend/src/lib/rectification-v4/client.ts index 5c8af417..b0fe65ff 100644 --- a/frontend/src/lib/rectification-v4/client.ts +++ b/frontend/src/lib/rectification-v4/client.ts @@ -63,6 +63,12 @@ export function answerRectificationV4(caseId: string, expectedCaseVersion: numbe }); } +export function regenerateRectificationV4Question(caseId: string, expectedCaseVersion: number) { + return post(`/api/rectification/v4/cases/${caseId}/regenerate`, { + actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, + }); +} + export function transitionRectificationV4( caseId: string, expectedCaseVersion: number, diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index 4d18165c..e9d91f0d 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -82,6 +82,13 @@ export type RelatedPerson = z.infer; export const scoreabilitySchema = z.enum(["scoreable", "context_only", "pending_review", "unsupported"]); export type Scoreability = z.infer; +const eventDateProvenanceFields = { + dateSource: z.string().trim().min(1).max(120).nullable().optional(), + dateReliability: z.string().trim().min(1).max(120).nullable().optional(), + dateCorroboration: z.string().trim().min(1).max(1_000).nullable().optional(), + dateConflictStatus: z.string().trim().min(1).max(120).nullable().optional(), +} as const; + export const lifeEventRevisionSchema = z.object({ id: z.string().uuid(), eventId: z.string().uuid(), @@ -93,6 +100,7 @@ export const lifeEventRevisionSchema = z.object({ summary: z.string().trim().min(1).max(1_000), rawText: z.string().trim().min(1).max(4_000), dateRange: eventDateRangeSchema, + ...eventDateProvenanceFields, scoreability: scoreabilitySchema, supersedesRevisionId: z.string().uuid().nullable(), createdAt: z.string().datetime({ offset: true }), @@ -119,6 +127,17 @@ export const calculationSpecSchema = z.object({ latitude: z.number().finite().min(-90).max(90), longitude: z.number().finite().min(-180).max(180), timezoneOffsetHours: z.number().finite().min(-14).max(14), + birthTimeSource: z.enum([ + "hospital_record", + "family_exact", + "approximate", + "period_only", + "unknown", + "legacy_import", + ]).nullable().optional(), + timezoneId: z.string().trim().min(1).max(120).nullable().optional(), + timezoneSource: z.string().trim().min(1).max(80).nullable().optional(), + localTimeStatus: z.enum(["resolved", "not_provided", "ambiguous", "nonexistent"]).nullable().optional(), ayanamsa: z.literal("lahiri"), nodeMode: z.literal("mean"), minuteStep: z.literal(1), @@ -144,15 +163,20 @@ export const candidateClusterSchema = z.object({ }).strict(); export type CandidateCluster = z.infer; -export const robustnessSchema = z.object({ +const robustnessValueSchema = z.object({ neighborSupportMinutes: z.number().int().nonnegative(), leaveOneOutRetentionRate: z.number().finite().min(0).max(1), + leaveOneDomainOutRetentionRate: z.number().finite().min(0).max(1), dateSensitivityRetentionRate: z.number().finite().min(0).max(1), calculationSpecHashMatched: z.boolean(), }).strict(); -export type Robustness = z.infer; +export type Robustness = z.infer; +export const robustnessSchema: z.ZodType = z.preprocess((value) => { + if (!value || typeof value !== "object" || Array.isArray(value) || "leaveOneDomainOutRetentionRate" in value) return value; + return { ...value, leaveOneDomainOutRetentionRate: 0.8 }; +}, robustnessValueSchema) as z.ZodType; -export const candidateSnapshotSchema = z.object({ +const candidateSnapshotBaseSchema = z.object({ id: z.string().uuid(), caseId: z.string().uuid(), caseVersion: z.number().int().nonnegative(), @@ -167,7 +191,19 @@ export const candidateSnapshotSchema = z.object({ gateReasons: z.array(z.string().trim().min(1).max(120)).max(20), createdAt: z.string().datetime({ offset: true }), }).strict(); -export type CandidateSnapshot = z.infer; + +export type CandidateSnapshot = z.infer; +export const candidateSnapshotSchema: z.ZodType = candidateSnapshotBaseSchema.transform((snapshot) => { + if (snapshot.robustness.leaveOneDomainOutRetentionRate >= 0.8) return snapshot; + const reason = "leave_one_domain_out_not_stable"; + return { + ...snapshot, + canAcceptRange: false, + gateReasons: snapshot.gateReasons.includes(reason) + ? snapshot.gateReasons + : [...snapshot.gateReasons, reason].slice(0, 20), + }; +}); export const rectificationV4QuestionSchema = z.object({ id: z.string().uuid(), @@ -243,6 +279,7 @@ export const reviseEventRequestSchema = z.object({ summary: z.string().trim().min(1).max(1_000), rawText: z.string().trim().min(1).max(4_000), dateRange: eventDateRangeSchema, + ...eventDateProvenanceFields, scoreability: scoreabilitySchema.optional(), }).strict(); @@ -270,11 +307,49 @@ export const rectificationV4JobSchema = z.object({ }).strict(); export type RectificationV4Job = z.infer; +export const rectificationAnalysisStageSchema = z.object({ + phase: z.enum([ + "extracting_evidence", + "scoring_candidates", + "checking_robustness", + "planning_question", + "reasoning", + "rendering", + ]), + label: z.string().trim().min(1).max(120), + status: z.enum(["completed", "failed"]), + durationMs: z.number().int().min(0).max(300_000).nullable(), +}).strict(); + +export const rectificationAnalysisToolCallSchema = z.object({ + category: z.enum(["candidate_engine", "diagnostic", "agent_diagnostic"]), + label: z.string().trim().min(1).max(120), + outcome: z.enum(["succeeded", "failed", "rejected"]), + durationMs: z.number().int().min(0).max(300_000).nullable(), +}).strict(); + +export const rectificationAnalysisTraceSchema = z.object({ + status: z.enum(["completed", "failed", "legacy"]), + stages: z.array(rectificationAnalysisStageSchema).max(12), + toolCalls: z.array(rectificationAnalysisToolCallSchema).max(16), + techniques: z.array(z.string().trim().min(1).max(120)).max(24), + reasoningSummary: z.string().trim().min(1).max(500).nullable(), + reasoningSource: z.enum(["provider_summary", "none"]), +}).strict(); +export type RectificationAnalysisTrace = z.infer; + +export const rectificationAnalysisItemSchema = z.object({ + sourceTurnId: z.string().uuid(), + trace: rectificationAnalysisTraceSchema, +}).strict(); +export type RectificationAnalysisItem = z.infer; + export const rectificationV4ApiResponseSchema = z.object({ case: rectificationV4CaseSchema, job: rectificationV4JobSchema.nullable(), events: z.array(lifeEventRevisionSchema), turns: z.array(rectificationV4TurnSchema), + analysis: z.array(rectificationAnalysisItemSchema).optional(), }).strict(); export type RectificationV4ApiResponse = z.infer; diff --git a/frontend/src/lib/rectification-v4/decision-gate.ts b/frontend/src/lib/rectification-v4/decision-gate.ts index 5c0a6519..e4cd896e 100644 --- a/frontend/src/lib/rectification-v4/decision-gate.ts +++ b/frontend/src/lib/rectification-v4/decision-gate.ts @@ -1,4 +1,5 @@ -import type { CandidateCluster, Robustness } from "./contracts.ts"; +import type { CandidateCluster, EvidenceDomain, Robustness } from "./contracts.ts"; +import { classifyMissingTechniqueLayers } from "./technique-layer-policy.ts"; export type DecisionGateResult = Readonly<{ canConfirmExactMinute: false; @@ -10,18 +11,28 @@ export function evaluateDecisionGate(input: { readonly clusters: readonly CandidateCluster[]; readonly robustness: Robustness; readonly scoreableEventCount: number; - readonly scoreableDomainCount: number; + readonly scoreableDomains: readonly EvidenceDomain[]; + readonly missingTechniqueLayers?: readonly string[]; }): DecisionGateResult { const reasons: string[] = []; const primary = input.clusters[0]; if (!primary) reasons.push("no_primary_candidate_cluster"); if (input.scoreableEventCount < 5) reasons.push("insufficient_scoreable_events"); - if (input.scoreableDomainCount < 3) reasons.push("insufficient_scoreable_domains"); + if (new Set(input.scoreableDomains).size < 3) reasons.push("insufficient_scoreable_domains"); if ((primary?.widthMinutes ?? 0) < 2) reasons.push("single_minute_cluster_not_acceptable"); if ((primary?.widthMinutes ?? Number.POSITIVE_INFINITY) > 15) reasons.push("primary_cluster_too_wide"); if (input.robustness.neighborSupportMinutes < 2) reasons.push("neighbor_support_not_passed"); if (input.robustness.leaveOneOutRetentionRate < 0.8) reasons.push("leave_one_out_not_stable"); + if (input.robustness.leaveOneDomainOutRetentionRate < 0.8) reasons.push("leave_one_domain_out_not_stable"); if (input.robustness.dateSensitivityRetentionRate < 0.8) reasons.push("date_range_sensitivity_not_stable"); if (!input.robustness.calculationSpecHashMatched) reasons.push("calculation_spec_changed"); + + const missing = classifyMissingTechniqueLayers( + input.missingTechniqueLayers ?? [], + input.scoreableDomains, + ); + reasons.push(...missing.required.map((layer) => `missing_required_layer:${layer}`)); + reasons.push(...missing.unclassified.map((layer) => `missing_unclassified_layer:${layer}`)); + return { canConfirmExactMinute: false, canAcceptRange: reasons.length === 0, reasons }; } diff --git a/frontend/src/lib/rectification-v4/evidence-ledger.ts b/frontend/src/lib/rectification-v4/evidence-ledger.ts index dba76a92..3870bba8 100644 --- a/frontend/src/lib/rectification-v4/evidence-ledger.ts +++ b/frontend/src/lib/rectification-v4/evidence-ledger.ts @@ -6,6 +6,20 @@ export type NewEventRevision = Omit; + +export function eventDateProvenance(value: Partial): Partial { + return { + ...(Object.prototype.hasOwnProperty.call(value, "dateSource") ? { dateSource: value.dateSource } : {}), + ...(Object.prototype.hasOwnProperty.call(value, "dateReliability") ? { dateReliability: value.dateReliability } : {}), + ...(Object.prototype.hasOwnProperty.call(value, "dateCorroboration") ? { dateCorroboration: value.dateCorroboration } : {}), + ...(Object.prototype.hasOwnProperty.call(value, "dateConflictStatus") ? { dateConflictStatus: value.dateConflictStatus } : {}), + }; +} + export function latestEventRevisions(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] { const latest = new Map(); for (const revision of revisions) { @@ -15,6 +29,12 @@ export function latestEventRevisions(revisions: readonly LifeEventRevision[]): r return [...latest.values()].sort((left, right) => left.eventId.localeCompare(right.eventId)); } +export function chronologicalEvents(events: readonly LifeEventRevision[]): readonly LifeEventRevision[] { + return [...events].sort((left, right) => left.createdAt.localeCompare(right.createdAt) + || left.eventId.localeCompare(right.eventId) + || left.revision - right.revision); +} + export function appendEventRevision( revisions: readonly LifeEventRevision[], input: NewEventRevision, diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index e6ecb0c2..998df44d 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -10,13 +10,32 @@ import type { Scoreability, } from "./contracts.ts"; import { dateRangeFromDeclared } from "./date-range.ts"; -import { appendEventRevision, latestEventRevisions } from "./evidence-ledger.ts"; +import { appendEventRevision, eventDateProvenance, latestEventRevisions } from "./evidence-ledger.ts"; const allowedKinds = new Set([ "education_milestone", "relocation", "relationship_start", "relationship_end", "relationship_change", "career_change", "finance_change", "self_health_event", "family_health_event", "family_bereavement", "family_event", "other", ]); const missingEventSummary = "事件内容待补充"; +const directionChangePattern = /(?:换一个|换个问题|问别的|换个方向|都不符合|不是这个|不聊这个)/; +const declinedPattern = /(?:不想说|不方便说|不想回答|跳过|这个不说)/; +const unknownPattern = /(?:不知道|不清楚|记不清|不确定|没印象|忘了|想不起来)/; + +export type TargetDisposition = + | "resolved" + | "unknown" + | "declined" + | "direction_change" + | "answered_other_event" + | "unresolved" + | "not_applicable"; + +function explicitDisposition(answer: string): TargetDisposition | null { + if (directionChangePattern.test(answer)) return "direction_change"; + if (declinedPattern.test(answer)) return "declined"; + if (unknownPattern.test(answer)) return "unknown"; + return null; +} function normalizeKind(domain: EvidenceDomain, value: string, summary: string): EventKind { if (allowedKinds.has(value as EventKind)) return value as EventKind; @@ -92,6 +111,7 @@ function subjectRevision(answer: string, target: LifeEventRevision, existing: re summary: target.summary, rawText: answer, dateRange: target.dateRange, + ...eventDateProvenance(target), scoreability, }, { now }); } @@ -100,6 +120,7 @@ export type ReconciledV4Evidence = Readonly<{ revisions: readonly LifeEventRevision[]; pending: readonly PendingEvidence[]; unansweredTargetEventId: string | null; + targetDisposition: TargetDisposition; }>; export function reconcileV4Evidence(input: { @@ -109,13 +130,16 @@ export function reconcileV4Evidence(input: { readonly asOfDate: string; readonly existing: readonly LifeEventRevision[]; readonly targetEventId?: string | null; + readonly assistedEvidence?: readonly ExtractedLifeEventEvidence[]; readonly now?: Date; }): ReconciledV4Evidence { - const extracted = extractLifeEventEvidence({ rawText: input.answer, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }); + const deterministic = extractLifeEventEvidence({ rawText: input.answer, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }); + const extracted = [...(input.assistedEvidence ?? []), ...deterministic]; const target = input.targetEventId ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null : null; if (input.targetEventId && !target) throw new Error("rectification_v4_target_event_not_found"); const revisions: LifeEventRevision[] = []; + const consumed = new Set(); let unresolvedReason: PendingEvidence["reasonCode"] | null = null; let targetResolved = !target; @@ -138,8 +162,10 @@ export function reconcileV4Evidence(input: { summary: target.summary, rawText: input.answer, dateRange, + ...eventDateProvenance(target), scoreability: target.scoreability, }, { id: targetAnswer.id, now: input.now })); + consumed.add(targetAnswer.id); targetResolved = true; } } @@ -147,17 +173,27 @@ export function reconcileV4Evidence(input: { } for (const event of extracted) { - if (revisions.some((revision) => revision.id === event.id)) continue; + if (consumed.has(event.id) || revisions.some((revision) => revision.id === event.id)) continue; const revision = newRevision(event, [...input.existing, ...revisions], input.now); if (revision && revision.dateRange.start <= input.asOfDate) { - revisions.push(revision); + if (!revisions.some((value) => value.eventId === revision.eventId)) revisions.push(revision); continue; } unresolvedReason = event.datePrecision === "unknown" ? "date_unresolved" : "event_unparsed"; } - if (extracted.length === 0) unresolvedReason = "event_unparsed"; - const pending = unresolvedReason ? [ + const explicit = explicitDisposition(input.answer); + const addedOtherEvent = target + ? revisions.some((revision) => revision.eventId !== target.eventId) + : false; + const targetDisposition: TargetDisposition = explicit ?? (!target + ? "not_applicable" + : targetResolved ? "resolved" : addedOtherEvent ? "answered_other_event" : "unresolved"); + const suppressPending = targetDisposition === "unknown" + || targetDisposition === "declined" + || targetDisposition === "direction_change"; + if (extracted.length === 0 && !suppressPending) unresolvedReason = "event_unparsed"; + const pending = unresolvedReason && !suppressPending ? [ pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, @@ -171,7 +207,8 @@ export function reconcileV4Evidence(input: { return { revisions, pending, - unansweredTargetEventId: target && !targetResolved ? target.eventId : null, + unansweredTargetEventId: target && (targetDisposition === "unresolved" || targetDisposition === "answered_other_event") ? target.eventId : null, + targetDisposition, }; } diff --git a/frontend/src/lib/rectification-v4/fingerprints.ts b/frontend/src/lib/rectification-v4/fingerprints.ts index 2912313c..dbca91e3 100644 --- a/frontend/src/lib/rectification-v4/fingerprints.ts +++ b/frontend/src/lib/rectification-v4/fingerprints.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { CalculationSpec, LifeEventRevision } from "./contracts.ts"; -import { latestEventRevisions } from "./evidence-ledger.ts"; +import { eventDateProvenance, latestEventRevisions } from "./evidence-ledger.ts"; function canonical(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonical); @@ -28,6 +28,7 @@ export function evidenceSetHash(revisions: readonly LifeEventRevision[]): string subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, + ...eventDateProvenance(event), scoreability: event.scoreability, }))); } diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index aef2c457..bee09ec4 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -1,4 +1,4 @@ -import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, PublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; +import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, StoredPublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; import type { LifeEventRevision, PendingEvidence, @@ -20,7 +20,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { readonly diagnostics: Map; readonly featureSnapshots: Map; readonly agentRuns: Map; - readonly publicMessages: Map; + readonly publicMessages: Map; readonly validatedDecisions: Map; readonly pendingEvidence: Map; } { @@ -32,7 +32,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { const diagnostics = new Map(); const featureSnapshots = new Map(); const agentRuns = new Map(); - const publicMessages = new Map(); + const publicMessages = new Map(); const validatedDecisions = new Map(); const pendingEvidence = new Map(); @@ -69,6 +69,25 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { .filter((turn) => turn.caseId === caseId) .sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt)); }, + async loadAnalysisMessages(userId, caseId) { + owned(userId, caseId); + return [...jobs.values()] + .filter((job) => job.caseId === caseId && publicMessages.get(job.id)?.analysisTrace) + .sort((left, right) => turns.get(left.turnId)!.caseVersion - turns.get(right.turnId)!.caseVersion) + .map((job) => ({ sourceTurnId: job.turnId, trace: publicMessages.get(job.id)!.analysisTrace! })); + }, + async loadLatestValidatedDecision(userId, caseId) { + const caseValue = cases.get(caseId); + if (!caseValue || caseValue.userId !== userId) return null; + const latest = [...agentRuns.values()] + .filter((run) => run.caseId === caseId) + .sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0]; + return latest ? validatedDecisions.get(latest.jobId) ?? latest.validatedDecision : null; + }, + async loadActionCase(userId, actionId) { + const replay = actionResults.get(`${userId}:${actionId}`); + return replay ? owned(userId, replay.caseId) : null; + }, async createCase(input) { const replay = actionResults.get(`${input.case.userId}:${input.actionId}`); if (replay) return owned(input.case.userId, replay.caseId); @@ -91,6 +110,29 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: input.case.id, jobId: null }); return input.case; }, + async replaceCurrentQuestion(input) { + const key = `${input.userId}:${input.actionId}`; + const replay = actionResults.get(key); + if (replay) return owned(input.userId, replay.caseId); + const current = owned(input.userId, input.caseId); + if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version"); + if (current.deploymentMode !== "v5_agent" + || !["awaiting_answer", "range_ready"].includes(current.status) + || !current.currentQuestion) throw new RectificationV4StoreError("invalid_state"); + const updated: RectificationV4Case = { + ...current, + version: current.version + 1, + currentQuestion: { + ...input.question, + domain: current.currentQuestion.domain, + targetEventId: current.currentQuestion.targetEventId, + }, + updatedAt: input.now, + }; + cases.set(current.id, updated); + actionResults.set(key, { caseId: current.id, jobId: null }); + return updated; + }, async submitAnswer(input) { const key = `${input.userId}:${input.actionId}`; const replay = actionResults.get(key); @@ -188,6 +230,12 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { owned(userId, job.caseId); return job; }, + async loadActiveJob(userId, caseId) { + owned(userId, caseId); + return [...jobs.values()] + .filter((job) => job.caseId === caseId && ["pending", "processing"].includes(job.status)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null; + }, async updateJobPhase(input) { const job = jobs.get(input.jobId); if (!job || job.workerId !== input.workerId || job.status !== "processing") throw new RectificationV4StoreError("lease_lost"); diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 812d3dcc..37151885 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -1,8 +1,9 @@ -import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, PublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; +import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, StoredPublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, + RectificationAnalysisItem, RectificationV4Case, RectificationV4Job, RectificationV4Phase, @@ -33,7 +34,7 @@ export type CompleteRectificationV4JobInput = Readonly<{ diagnostics: DiagnosticsSummary | null; featureSnapshot: CandidateFeatureSnapshot | null; validatedDecision: ValidatedDecision; - publicMessage: PublicMessage; + publicMessage: StoredPublicMessage; agentRun: AgentRun; nextQuestion: RectificationV4Question | null; status: RectificationV4Case["status"]; @@ -45,7 +46,18 @@ export interface RectificationV4Store { loadCase(userId: string, caseId: string): Promise; loadEvents(userId: string, caseId: string): Promise; loadTurns(userId: string, caseId: string): Promise; + loadAnalysisMessages(userId: string, caseId: string): Promise; + loadLatestValidatedDecision(userId: string, caseId: string): Promise; + loadActionCase(userId: string, actionId: string): Promise; createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise; + replaceCurrentQuestion(input: { + readonly userId: string; + readonly caseId: string; + readonly actionId: string; + readonly expectedCaseVersion: number; + readonly question: RectificationV4Question; + readonly now: string; + }): Promise; submitAnswer(input: { readonly userId: string; readonly caseId: string; @@ -78,6 +90,7 @@ export interface RectificationV4Store { readonly now: string; }): Promise; loadJob(userId: string, jobId: string): Promise; + loadActiveJob(userId: string, caseId: string): Promise; updateJobPhase(input: { readonly workerId: string; readonly jobId: string; readonly phase: RectificationV4Phase; readonly now: string }): Promise; claimNextJob(workerId: string, now: string): Promise; completeJob(input: CompleteRectificationV4JobInput, now: string): Promise; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 045bd25e..4928bea5 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -1,12 +1,15 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { storedPublicMessageSchema, validatedDecisionSchema, type ValidatedDecision } from "../rectification-agent/contracts.ts"; import { candidateSnapshotSchema, lifeEventRevisionSchema, + rectificationAnalysisItemSchema, rectificationV4CaseSchema, rectificationV4JobSchema, rectificationV4TurnSchema, type CandidateSnapshot, type LifeEventRevision, + type RectificationAnalysisItem, type RectificationV4Case, type RectificationV4Job, type RectificationV4Turn, @@ -21,6 +24,24 @@ import { evidenceSetHash, rectificationFingerprint } from "./fingerprints.ts"; type Row = Record; +export function projectAnalysisMessages( + publicMessageRows: readonly Readonly[], + jobRows: readonly Readonly[], +): readonly RectificationAnalysisItem[] { + const turnByJob = new Map(jobRows.map((row) => [String(row.id), row.turn_id])); + return [...publicMessageRows] + .sort((left, right) => timestamp(left.created_at).localeCompare(timestamp(right.created_at))) + .flatMap((row) => { + const message = storedPublicMessageSchema.safeParse(row.message); + if (!message.success || !message.data.analysisTrace) return []; + const item = rectificationAnalysisItemSchema.safeParse({ + sourceTurnId: turnByJob.get(String(row.job_id)), + trace: message.data.analysisTrace, + }); + return item.success ? [item.data] : []; + }); +} + function timestamp(value: unknown): string { return value instanceof Date ? value.toISOString() : String(value); } @@ -87,7 +108,10 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific }); } -function eventRevision(row: Row): LifeEventRevision { +export function rectificationEventRevisionFromRow(row: Row): LifeEventRevision { + const provenance = row.date_provenance && typeof row.date_provenance === "object" && !Array.isArray(row.date_provenance) + ? row.date_provenance as Row + : null; return lifeEventRevisionSchema.parse({ id: row.id, eventId: row.event_id, @@ -104,6 +128,10 @@ function eventRevision(row: Row): LifeEventRevision { precision: row.date_precision, label: row.date_label, }, + ...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateSource") ? { dateSource: provenance.dateSource } : {}), + ...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateReliability") ? { dateReliability: provenance.dateReliability } : {}), + ...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateCorroboration") ? { dateCorroboration: provenance.dateCorroboration } : {}), + ...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateConflictStatus") ? { dateConflictStatus: provenance.dateConflictStatus } : {}), scoreability: row.scoreability, supersedesRevisionId: row.supersedes_revision_id, createdAt: timestamp(row.created_at), @@ -170,7 +198,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re .select("*").eq("case_id", caseId).eq("user_id", userId) .order("created_at", { ascending: true }); if (error) throw storeError(error); - return ((data ?? []) as Row[]).map(eventRevision); + return ((data ?? []) as Row[]).map(rectificationEventRevisionFromRow); } async function loadTurnsByCase(userId: string, caseId: string): Promise { @@ -182,6 +210,21 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re return ((data ?? []) as Row[]).map(turnValue); } + async function loadAnalysisMessagesByCase(userId: string, caseId: string): Promise { + if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found"); + const { data, error } = await supabase.from("birth_time_rectification_public_messages") + .select("job_id,message,created_at").eq("case_id", caseId).eq("user_id", userId) + .order("created_at", { ascending: true }); + if (error) throw storeError(error); + const rows = (data ?? []) as Row[]; + if (rows.length === 0) return []; + const jobIds = rows.map((row) => String(row.job_id)); + const { data: jobData, error: jobError } = await supabase.from("birth_time_rectification_v4_jobs") + .select("id,turn_id").eq("case_id", caseId).eq("user_id", userId).in("id", jobIds); + if (jobError) throw storeError(jobError); + return projectAnalysisMessages(rows, (jobData ?? []) as Row[]); + } + async function rpc(name: string, args: Row): Promise { const { data, error } = await supabase.rpc(name, args); if (error) throw storeError(error); @@ -199,6 +242,21 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re loadCase: loadCaseById, loadEvents: loadEventsByCase, loadTurns: loadTurnsByCase, + loadAnalysisMessages: loadAnalysisMessagesByCase, + async loadLatestValidatedDecision(userId, caseId): Promise { + const { data, error } = await supabase.from("birth_time_rectification_agent_runs") + .select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId) + .order("case_version", { ascending: false }).order("created_at", { ascending: false }) + .limit(1).maybeSingle(); + if (error) throw storeError(error); + return data ? validatedDecisionSchema.parse((data as Row).validated_decision_json) : null; + }, + async loadActionCase(userId, actionId) { + const { data, error } = await supabase.from("birth_time_rectification_v4_actions") + .select("case_id").eq("user_id", userId).eq("action_id", actionId).maybeSingle(); + if (error) throw storeError(error); + return data ? loadCaseById(userId, String((data as Row).case_id)) : null; + }, async createCase(input) { const id = String(await rpc("create_birth_time_rectification_v5_case", { p_user_id: input.case.userId, @@ -222,6 +280,19 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re if (!value) throw new RectificationV4StoreError("not_found"); return value; }, + async replaceCurrentQuestion(input) { + const id = String(await rpc("replace_birth_time_rectification_v4_current_question", { + p_user_id: input.userId, + p_case_id: input.caseId, + p_action_id: input.actionId, + p_expected_version: input.expectedCaseVersion, + p_question: input.question, + p_now: input.now, + })); + const value = await loadCaseById(input.userId, id); + if (!value) throw new RectificationV4StoreError("not_found"); + return value; + }, async submitAnswer(input) { const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", { p_user_id: input.userId, @@ -281,6 +352,14 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re if (!row || row.user_id !== userId) return null; return jobValue(row); }, + async loadActiveJob(userId, caseId) { + const { data, error } = await supabase.from("birth_time_rectification_v4_jobs") + .select("*").eq("user_id", userId).eq("case_id", caseId) + .in("status", ["pending", "processing"]) + .order("created_at", { ascending: false }).limit(1).maybeSingle(); + if (error) throw storeError(error); + return data ? jobValue(data as Row) : null; + }, async updateJobPhase(input) { await rpc("update_birth_time_rectification_v4_job_phase", { p_worker_id: input.workerId, diff --git a/frontend/src/lib/rectification-v4/technique-layer-policy.ts b/frontend/src/lib/rectification-v4/technique-layer-policy.ts new file mode 100644 index 00000000..eff4c57d --- /dev/null +++ b/frontend/src/lib/rectification-v4/technique-layer-policy.ts @@ -0,0 +1,43 @@ +import type { EvidenceDomain } from "./contracts.ts"; +import { domainScorerRegistry } from "./domain-scorers.ts"; + +export type MissingTechniqueLayerClassification = Readonly<{ + required: readonly string[]; + optional: readonly string[]; + referenceOnly: readonly string[]; + unclassified: readonly string[]; +}>; + +const aliases: Readonly> = { + Vimshottari_MD_AD_PD: "vimshottari", + Narayana_MD_AD: "narayana", +}; +const optionalLayers = new Set(["KP_cusps", "A7", "Ashtakavarga", "Shadbala"]); +const referenceOnlyLayers = new Set(["D60"]); +const knownDomainLayers = new Set( + Object.values(domainScorerRegistry).flatMap((policy) => policy.techniqueLayers), +); + +export function classifyMissingTechniqueLayers( + missingLayers: readonly string[], + activeScoreableDomains: readonly EvidenceDomain[], +): MissingTechniqueLayerClassification { + const requiredLayers = new Set( + activeScoreableDomains.flatMap((domain) => domainScorerRegistry[domain].techniqueLayers), + ); + const classified = { + required: [] as string[], + optional: [] as string[], + referenceOnly: [] as string[], + unclassified: [] as string[], + }; + + for (const layer of [...new Set(missingLayers)]) { + const canonical = aliases[layer] ?? layer; + if (referenceOnlyLayers.has(canonical)) classified.referenceOnly.push(layer); + else if (requiredLayers.has(canonical)) classified.required.push(layer); + else if (optionalLayers.has(canonical) || knownDomainLayers.has(canonical)) classified.optional.push(layer); + else classified.unclassified.push(layer); + } + return classified; +} diff --git a/frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql b/frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql new file mode 100644 index 00000000..beb9fc6c --- /dev/null +++ b/frontend/supabase/migrations/20260729010000_rectification_agent_v6_versions.sql @@ -0,0 +1,14 @@ +-- New cases use the semantic-question V6 prompt contract by default. +alter table public.birth_time_rectification_v4_cases + alter column skill_version set default 'birth-time-rectification-v6', + alter column prompt_version set default 'rectification-agent-v6-1'; + +-- Advance only unfinished rectification cases to the semantic-question V6 prompt contract. +-- Historical completed, abandoned, range-ready, and audit artifacts remain immutable. +update public.birth_time_rectification_v4_cases +set skill_version = 'birth-time-rectification-v6', + prompt_version = 'rectification-agent-v6-1', + updated_at = greatest(updated_at, now()) +where status in ('awaiting_answer', 'processing', 'paused') + and (skill_version is distinct from 'birth-time-rectification-v6' + or prompt_version is distinct from 'rectification-agent-v6-1'); diff --git a/frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql b/frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql new file mode 100644 index 00000000..210d837d --- /dev/null +++ b/frontend/supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql @@ -0,0 +1,80 @@ +begin; + +create or replace function public.replace_birth_time_rectification_v4_current_question( + p_user_id uuid, + p_case_id uuid, + p_action_id uuid, + p_expected_version bigint, + p_question jsonb, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_case public.birth_time_rectification_v4_cases%rowtype; + v_case_id uuid; + v_question jsonb; +begin + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.id = p_case_id and value.user_id = p_user_id + for update; + if not found then raise exception 'rectification_v4_case_not_found'; end if; + + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if; + if v_case.deployment_mode <> 'v5_agent' + or v_case.status not in ('awaiting_answer', 'range_ready') + or v_case.current_question is null then + raise exception 'rectification_v4_question_not_regenerable'; + end if; + if p_question is null or pg_catalog.jsonb_typeof(p_question) <> 'object' then + raise exception 'invalid_rectification_v4_question'; + end if; + if nullif(pg_catalog.btrim(p_question->>'id'), '') is null + or (p_question->>'id') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + or nullif(pg_catalog.btrim(p_question->>'prompt'), '') is null + or pg_catalog.length(pg_catalog.btrim(p_question->>'prompt')) > 1000 + or p_question->>'recallCost' not in ('low', 'medium', 'high') + or nullif(pg_catalog.btrim(p_question->>'reason'), '') is null + or pg_catalog.length(pg_catalog.btrim(p_question->>'reason')) > 240 then + raise exception 'invalid_rectification_v4_question'; + end if; + + v_question := p_question || pg_catalog.jsonb_build_object( + 'domain', v_case.current_question->'domain', + 'targetEventId', v_case.current_question->'targetEventId' + ); + + update public.birth_time_rectification_v4_cases + set version = p_expected_version + 1, + current_question = v_question, + updated_at = p_now + where id = p_case_id; + + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, p_case_id, p_now + ); + + return p_case_id; +end; +$$; + +revoke all on function public.replace_birth_time_rectification_v4_current_question( + uuid, uuid, uuid, bigint, jsonb, timestamptz +) from public, anon, authenticated, service_role; +grant execute on function public.replace_birth_time_rectification_v4_current_question( + uuid, uuid, uuid, bigint, jsonb, timestamptz +) to service_role; + +commit; diff --git a/frontend/supabase/migrations/20260730010000_rectification_provenance.sql b/frontend/supabase/migrations/20260730010000_rectification_provenance.sql new file mode 100644 index 00000000..d6dba0bb --- /dev/null +++ b/frontend/supabase/migrations/20260730010000_rectification_provenance.sql @@ -0,0 +1,390 @@ +begin; + +alter table public.birth_time_rectification_v4_event_revisions + add column if not exists date_provenance jsonb; + +alter table public.birth_time_rectification_v4_event_revisions + drop constraint if exists birth_time_rectification_v4_event_revisions_date_provenance_check; +alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v4_event_revisions_date_provenance_check + check (date_provenance is null or pg_catalog.jsonb_typeof(date_provenance) = 'object'); + +create or replace function public.revise_birth_time_rectification_v4_event( + p_user_id uuid, p_case_id uuid, p_action_id uuid, p_expected_version bigint, + p_revision jsonb, p_output_evidence_set_hash text, p_turn_id uuid, p_job_id uuid, p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare v_case public.birth_time_rectification_v4_cases%rowtype; v_job_id uuid; v_event_id uuid; +begin + select action.job_id into v_job_id from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_job_id is not null then return v_job_id; end if; + select value.* into v_case from public.birth_time_rectification_v4_cases value + where value.id = p_case_id and value.user_id = p_user_id for update; + if not found then raise exception 'rectification_v4_case_not_found'; end if; + if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if; + if v_case.status in ('processing', 'abandoned', 'paused') then raise exception 'rectification_v4_case_invalid_state'; end if; + if jsonb_typeof(p_revision) <> 'object' then raise exception 'invalid_rectification_v4_event_revision'; end if; + v_event_id = (p_revision->>'eventId')::uuid; + insert into public.birth_time_rectification_v4_events(id, case_id, user_id, created_at) + values (v_event_id, p_case_id, p_user_id, p_now) on conflict (id) do nothing; + insert into public.birth_time_rectification_v4_event_revisions( + id, event_id, case_id, user_id, revision, domain, event_kind, summary, raw_text, + date_start, date_end, date_precision, date_label, date_provenance, scoreability, supersedes_revision_id, created_at + ) values ( + (p_revision->>'id')::uuid, v_event_id, p_case_id, p_user_id, + (p_revision->>'revision')::integer, p_revision->>'domain', p_revision->>'eventKind', + p_revision->>'summary', p_revision->>'rawText', + (p_revision#>>'{dateRange,start}')::date, (p_revision#>>'{dateRange,end}')::date, + p_revision#>>'{dateRange,precision}', p_revision#>>'{dateRange,label}', + (select pg_catalog.jsonb_object_agg(entry.key, entry.value) + from pg_catalog.jsonb_each(p_revision) entry + where entry.key in ('dateSource', 'dateReliability', 'dateCorroboration', 'dateConflictStatus')), + p_revision->>'scoreability', nullif(p_revision->>'supersedesRevisionId', '')::uuid, + (p_revision->>'createdAt')::timestamptz + ); + insert into public.birth_time_rectification_v4_turns( + id, case_id, user_id, case_version, question, answer, action_id, created_at + ) values (p_turn_id, p_case_id, p_user_id, p_expected_version + 1, '修订事件', '', p_action_id, p_now); + update public.birth_time_rectification_v4_cases set + version = p_expected_version + 1, status = 'processing', phase = 'scoring_candidates', + evidence_set_hash = p_output_evidence_set_hash, current_question = null, updated_at = p_now + where id = p_case_id; + insert into public.birth_time_rectification_v4_jobs( + id, case_id, user_id, turn_id, status, phase, expected_case_version, + evidence_set_hash, calculation_spec_hash, created_at, updated_at + ) values ( + p_job_id, p_case_id, p_user_id, p_turn_id, 'pending', 'scoring_candidates', p_expected_version + 1, + p_output_evidence_set_hash, v_case.calculation_spec_hash, p_now, p_now + ); + insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, job_id, created_at) + values (p_user_id, p_action_id, p_case_id, p_job_id, p_now); + return p_job_id; +end; +$$; + +create or replace function public.complete_birth_time_rectification_v5_job( + p_worker_id uuid, + p_job_id uuid, + p_expected_case_version bigint, + p_input_evidence_set_hash text, + p_output_evidence_set_hash text, + p_calculation_spec_hash text, + p_completion_payload_hash text, + p_event_revisions jsonb, + p_pending_evidence jsonb, + p_snapshot jsonb, + p_diagnostics jsonb, + p_feature_snapshot jsonb, + p_validated_decision jsonb, + p_public_message jsonb, + p_agent_run jsonb, + p_next_question jsonb, + p_status text, + p_phase text, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_job public.birth_time_rectification_v4_jobs%rowtype; + v_case public.birth_time_rectification_v4_cases%rowtype; + v_existing_run public.birth_time_rectification_agent_runs%rowtype; + v_existing_message public.birth_time_rectification_public_messages%rowtype; + item jsonb; + v_snapshot_id uuid; + v_feature_id uuid; + v_diagnostics_id uuid; + v_event_id uuid; + v_supersedes_id uuid; + v_pending_count integer; +begin + if jsonb_typeof(p_event_revisions) is distinct from 'array' + or jsonb_typeof(p_pending_evidence) is distinct from 'array' + or jsonb_typeof(p_validated_decision) is distinct from 'object' + or jsonb_typeof(p_public_message) is distinct from 'object' + or jsonb_typeof(p_agent_run) is distinct from 'object' + or p_output_evidence_set_hash !~ '^[a-f0-9]{64}$' + or p_calculation_spec_hash !~ '^[a-f0-9]{64}$' + or p_completion_payload_hash !~ '^[a-f0-9]{64}$' then + raise exception 'invalid_rectification_v5_completion_payload'; + end if; + + select value.* into v_job + from public.birth_time_rectification_v4_jobs value + where value.id = p_job_id + for update; + if not found then raise exception 'rectification_v4_job_lease_lost'; end if; + + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.id = v_job.case_id + for update; + if not found then raise exception 'rectification_v4_case_not_found'; end if; + + -- A network retry after commit is an idempotent read, never a second artifact write. + if v_job.status = 'completed' then + select value.* into v_existing_run + from public.birth_time_rectification_agent_runs value + where value.job_id = p_job_id; + select value.* into v_existing_message + from public.birth_time_rectification_public_messages value + where value.job_id = p_job_id; + select count(*) into v_pending_count + from public.birth_time_rectification_pending_evidence value + where value.turn_id = v_job.turn_id; + if v_existing_run.id is null + or v_existing_run.id is distinct from (p_agent_run->>'id')::uuid + or v_existing_run.case_id is distinct from v_case.id + or v_existing_run.case_version is distinct from p_expected_case_version + or v_existing_run.validated_decision_json is distinct from p_validated_decision + or v_existing_message.job_id is null + or v_existing_message.message is distinct from p_public_message + or v_job.completion_payload_hash is distinct from p_completion_payload_hash + or v_pending_count is distinct from pg_catalog.jsonb_array_length(p_pending_evidence) then + raise exception 'rectification_v5_replay_payload_mismatch'; + end if; + for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop + if not exists ( + select 1 from public.birth_time_rectification_pending_evidence value + where value.id = (item->>'id')::uuid + and value.case_id = v_case.id + and value.user_id = v_case.user_id + and value.turn_id = v_job.turn_id + and value.target_event_id is not distinct from nullif(item->>'targetEventId', '')::uuid + and value.raw_text = item->>'rawText' + and value.reason_code = item->>'reasonCode' + and value.resolved_event_id is not distinct from nullif(item->>'resolvedEventId', '')::uuid + and value.created_at = (item->>'createdAt')::timestamptz + and value.resolved_at is not distinct from nullif(item->>'resolvedAt', '')::timestamptz + ) then + raise exception 'rectification_v5_replay_payload_mismatch'; + end if; + end loop; + return v_case.id; + end if; + + if v_job.worker_id is distinct from p_worker_id + or v_job.status <> 'processing' + or v_job.lease_expires_at <= p_now then + raise exception 'rectification_v4_job_lease_lost'; + end if; + if v_case.version is distinct from p_expected_case_version + or v_case.evidence_set_hash is distinct from p_input_evidence_set_hash + or v_case.calculation_spec_hash is distinct from p_calculation_spec_hash + or v_job.expected_case_version is distinct from p_expected_case_version + or v_job.evidence_set_hash is distinct from p_input_evidence_set_hash + or v_job.calculation_spec_hash is distinct from p_calculation_spec_hash then + raise exception 'stale_rectification_v4_job'; + end if; + + if (p_agent_run->>'caseId')::uuid is distinct from v_case.id + or (p_agent_run->>'jobId')::uuid is distinct from p_job_id + or (p_agent_run->>'caseVersion')::bigint is distinct from p_expected_case_version + or p_agent_run->>'deploymentMode' is distinct from v_case.deployment_mode + or p_agent_run->'validatedDecision' is distinct from p_validated_decision + or jsonb_typeof(p_agent_run->'toolCalls') is distinct from 'array' + or pg_catalog.jsonb_array_length(p_agent_run->'toolCalls') > 8 + or p_validated_decision->>'mode' not in ('agent', 'deterministic_fallback') then + raise exception 'invalid_rectification_v5_agent_run'; + end if; + + for item in select value from pg_catalog.jsonb_array_elements(p_event_revisions) loop + v_event_id := (item->>'eventId')::uuid; + v_supersedes_id := nullif(item->>'supersedesRevisionId', '')::uuid; + if (item->>'caseId') is not null and (item->>'caseId')::uuid is distinct from v_case.id then + raise exception 'rectification_v5_event_case_mismatch'; + end if; + insert into public.birth_time_rectification_v4_events( + id, case_id, user_id, created_at + ) values ( + v_event_id, v_case.id, v_case.user_id, (item->>'createdAt')::timestamptz + ) on conflict (id) do nothing; + if not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = v_event_id and value.case_id = v_case.id and value.user_id = v_case.user_id + ) then + raise exception 'rectification_v5_event_case_mismatch'; + end if; + if v_supersedes_id is not null and not exists ( + select 1 from public.birth_time_rectification_v4_event_revisions value + where value.id = v_supersedes_id and value.event_id = v_event_id and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_superseded_revision_mismatch'; + end if; + insert into public.birth_time_rectification_v4_event_revisions( + id, event_id, case_id, user_id, revision, domain, event_kind, subject, + related_person, summary, raw_text, date_start, date_end, date_precision, + date_label, date_provenance, scoreability, supersedes_revision_id, created_at + ) values ( + (item->>'id')::uuid, v_event_id, v_case.id, v_case.user_id, + (item->>'revision')::integer, item->>'domain', item->>'eventKind', item->>'subject', + nullif(item->>'relatedPerson', ''), item->>'summary', item->>'rawText', + (item#>>'{dateRange,start}')::date, (item#>>'{dateRange,end}')::date, + item#>>'{dateRange,precision}', item#>>'{dateRange,label}', + (select pg_catalog.jsonb_object_agg(entry.key, entry.value) + from pg_catalog.jsonb_each(item) entry + where entry.key in ('dateSource', 'dateReliability', 'dateCorroboration', 'dateConflictStatus')), + item->>'scoreability', v_supersedes_id, (item->>'createdAt')::timestamptz + ); + end loop; + + for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop + if (item->>'caseId')::uuid is distinct from v_case.id + or (item->>'turnId')::uuid is distinct from v_job.turn_id + or item->>'reasonCode' not in ('date_unresolved', 'event_unparsed') + or nullif(btrim(item->>'rawText'), '') is null + or (nullif(item->>'resolvedEventId', '') is null) is distinct from (nullif(item->>'resolvedAt', '') is null) then + raise exception 'invalid_rectification_v5_pending_evidence'; + end if; + if nullif(item->>'targetEventId', '') is not null and not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = (item->>'targetEventId')::uuid and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_pending_target_event_mismatch'; + end if; + if nullif(item->>'resolvedEventId', '') is not null and not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = (item->>'resolvedEventId')::uuid and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_pending_resolved_event_mismatch'; + end if; + insert into public.birth_time_rectification_pending_evidence( + id, case_id, user_id, turn_id, target_event_id, raw_text, reason_code, + resolved_event_id, created_at, resolved_at + ) values ( + (item->>'id')::uuid, v_case.id, v_case.user_id, (item->>'turnId')::uuid, + nullif(item->>'targetEventId', '')::uuid, item->>'rawText', item->>'reasonCode', + nullif(item->>'resolvedEventId', '')::uuid, (item->>'createdAt')::timestamptz, + nullif(item->>'resolvedAt', '')::timestamptz + ); + end loop; + + if p_snapshot is not null then + if jsonb_typeof(p_snapshot) is distinct from 'object' + or coalesce((p_snapshot->>'canConfirmExactMinute')::boolean, false) then + raise exception 'exact_minute_confirmation_forbidden'; + end if; + v_snapshot_id := (p_snapshot->>'id')::uuid; + if (p_snapshot->>'caseId')::uuid is distinct from v_case.id + or (p_snapshot->>'caseVersion')::bigint is distinct from p_expected_case_version + or p_snapshot->>'evidenceSetHash' is distinct from p_output_evidence_set_hash + or p_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash + or p_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then + raise exception 'rectification_v5_snapshot_mismatch'; + end if; + insert into public.birth_time_rectification_v4_candidate_snapshots( + id, case_id, user_id, case_version, evidence_set_hash, calculation_spec_hash, + algorithm_version, candidates, clusters, robustness, can_confirm_exact_minute, + can_accept_range, gate_reasons, created_at + ) values ( + v_snapshot_id, v_case.id, v_case.user_id, (p_snapshot->>'caseVersion')::bigint, + p_snapshot->>'evidenceSetHash', p_snapshot->>'calculationSpecHash', + p_snapshot->>'algorithmVersion', p_snapshot->'candidates', p_snapshot->'clusters', + p_snapshot->'robustness', false, (p_snapshot->>'canAcceptRange')::boolean, + p_snapshot->'gateReasons', (p_snapshot->>'createdAt')::timestamptz + ); + end if; + + if p_feature_snapshot is not null then + if jsonb_typeof(p_feature_snapshot) is distinct from 'object' then + raise exception 'invalid_rectification_v5_feature_snapshot'; + end if; + v_feature_id := (p_feature_snapshot->>'id')::uuid; + if (p_feature_snapshot->>'caseId')::uuid is distinct from v_case.id + or p_feature_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash + or p_feature_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then + raise exception 'rectification_v5_feature_snapshot_mismatch'; + end if; + insert into public.birth_time_rectification_candidate_feature_snapshots( + id, case_id, user_id, calculation_spec_hash, algorithm_version, + candidate_count, feature_hash, features, created_at + ) values ( + v_feature_id, v_case.id, v_case.user_id, + p_feature_snapshot->>'calculationSpecHash', p_feature_snapshot->>'algorithmVersion', + (p_feature_snapshot->>'candidateCount')::integer, p_feature_snapshot->>'featureHash', + p_feature_snapshot->'features', (p_feature_snapshot->>'createdAt')::timestamptz + ); + end if; + + if p_diagnostics is not null then + if jsonb_typeof(p_diagnostics) is distinct from 'object' or v_snapshot_id is null then + raise exception 'invalid_rectification_v5_diagnostics'; + end if; + v_diagnostics_id := (p_diagnostics->>'id')::uuid; + if (p_diagnostics->>'caseId')::uuid is distinct from v_case.id + or (p_diagnostics->>'snapshotId')::uuid is distinct from v_snapshot_id then + raise exception 'rectification_v5_diagnostics_mismatch'; + end if; + insert into public.birth_time_rectification_diagnostics( + id, case_id, user_id, snapshot_id, summary, calculation_hash, created_at + ) values ( + v_diagnostics_id, v_case.id, v_case.user_id, v_snapshot_id, + p_diagnostics, p_diagnostics->>'calculationHash', + (p_diagnostics->>'createdAt')::timestamptz + ); + end if; + + if (p_diagnostics is null) is distinct from (p_snapshot is null) + or (p_feature_snapshot is null) is distinct from (p_snapshot is null) then + raise exception 'rectification_v5_artifact_set_incomplete'; + end if; + + insert into public.birth_time_rectification_agent_runs( + id, case_id, job_id, user_id, case_version, model_id, skill_version, + prompt_version, deployment_sha, deployment_mode, decision_json, + validated_decision_json, tool_calls_json, tool_call_count, fallback_reason, + input_token_count, output_token_count, latency_ms, created_at + ) values ( + (p_agent_run->>'id')::uuid, v_case.id, p_job_id, v_case.user_id, + (p_agent_run->>'caseVersion')::bigint, nullif(p_agent_run->>'modelId', ''), + p_agent_run->>'skillVersion', p_agent_run->>'promptVersion', + nullif(p_agent_run->>'deploymentSha', ''), p_agent_run->>'deploymentMode', + p_agent_run->'decision', p_validated_decision, p_agent_run->'toolCalls', + pg_catalog.jsonb_array_length(p_agent_run->'toolCalls'), + nullif(p_agent_run->>'fallbackReason', ''), + nullif(p_agent_run->>'inputTokenCount', '')::integer, + nullif(p_agent_run->>'outputTokenCount', '')::integer, + (p_agent_run->>'latencyMs')::integer, + (p_agent_run->>'createdAt')::timestamptz + ); + insert into public.birth_time_rectification_public_messages( + job_id, case_id, user_id, message, created_at + ) values ( + p_job_id, v_case.id, v_case.user_id, p_public_message, p_now + ); + + update public.birth_time_rectification_v4_cases + set version = p_expected_case_version + 1, + evidence_set_hash = p_output_evidence_set_hash, + latest_snapshot_id = coalesce(v_snapshot_id, latest_snapshot_id), + feature_snapshot_id = coalesce(v_feature_id, feature_snapshot_id), + latest_diagnostics_id = coalesce(v_diagnostics_id, latest_diagnostics_id), + agent_mode = p_validated_decision->>'mode', + current_question = p_next_question, + status = p_status, + phase = p_phase, + updated_at = p_now + where id = v_case.id; + update public.birth_time_rectification_v4_jobs + set status = 'completed', phase = p_phase, result_snapshot_id = v_snapshot_id, + completion_payload_hash = p_completion_payload_hash, + lease_expires_at = null, updated_at = p_now + where id = p_job_id; + return v_case.id; +end; +$$; + +revoke all on function public.revise_birth_time_rectification_v4_event(uuid, uuid, uuid, bigint, jsonb, text, uuid, uuid, timestamptz) from public, anon, authenticated; +grant execute on function public.revise_birth_time_rectification_v4_event(uuid, uuid, uuid, bigint, jsonb, text, uuid, uuid, timestamptz) to service_role; +revoke all on function public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +) from public, anon, authenticated; +grant execute on function public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +) to service_role; + +commit; diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index e46e819d..97dd966e 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -19,7 +19,6 @@ test("journey engine serializes only stored event-scoring inputs", () => { lat: 31.2304, lon: 121.4737, tz: 8, - high_rigor: true, events: [ { id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" }, { id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" }, diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 667c1e43..74288ad8 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { rectificationV4ChatMessages } from "../src/components/rectification-v4-panel.tsx"; +import { + canRegenerateRectificationMessage, + rectificationV4ChatMessages, + rectificationPhaseLabel, + toggleRectificationFeedback, +} from "../src/components/rectification-v4-panel.tsx"; +import { applyRectificationV4JobUpdate } from "../src/hooks/use-rectification-v4.ts"; import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts"; const id = "00000000-0000-4000-8000-000000000901"; @@ -55,6 +61,7 @@ function response(overrides: Record = {}): RectificationV4ApiRe }, job: null, events: [], + analysis: [], turns: [{ id: "00000000-0000-4000-8000-000000000903", caseId: id, @@ -68,7 +75,28 @@ function response(overrides: Record = {}): RectificationV4ApiRe actionId: "00000000-0000-4000-8000-000000000905", createdAt: now, }], - }; + } as unknown as RectificationV4ApiResponse; +} + +function analysisTrace(label: string) { + return { + status: "completed", + stages: [{ + phase: "extracting_evidence", + label, + status: "completed", + durationMs: 320, + }], + toolCalls: [{ + category: "candidate_engine", + label: "候选分钟扫描", + outcome: "succeeded", + durationMs: 840, + }], + techniques: ["Vimshottari Dasha", "D24"], + reasoningSummary: "现有证据更适合继续收集另一件时间明确的经历。", + reasoningSource: "provider_summary", + } as const; } test("v4 rectification reuses the ordinary session message list, composer, and model selector", () => { @@ -87,6 +115,18 @@ test("v4 rectification reuses the ordinary session message list, composer, and m assert.match(component, /className="composer"/); assert.match(component, /