feat: persist rectification analysis traces

This commit is contained in:
Jesse_Chen
2026-07-30 03:15:06 +08:00
parent 29374ded2a
commit ee10fb4d8a
19 changed files with 1084 additions and 32 deletions
+16
View File
@@ -1692,3 +1692,19 @@
- 相关记录:BUG-090、BUG-091、BUG-092、BUG-093
- 复发自:无
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`
## BUG-095 | 生时校正“思考中”无法证明实际执行步骤且刷新后不可溯源
- 状态:resolved
- 首次发现:2026-07-29
- 最近更新:2026-07-29
- 影响面:V4/V5 生时校正运行状态、历史助手消息、候选计算与诊断的测试可观测性
- 用户现象:页面只显示“正在核对星盘信息……”动画,无法判断本轮实际执行了哪些阶段、工具和技法;任务结束或刷新后也无法回看。
- 根因:Job 只保存一个会被后续步骤覆盖的当前 `phase`,不能还原阶段历史;已持久化的 Public Message 与 Agent Run 工具记录没有被 Case API 投影到对应 TurnUI 的 thinking 状态只是客户端进度动画,不是模型 reasoning,也不是服务端执行收据。
- 修复:将“分析过程”定义为与 Turn 关联、可持久化和刷新后可恢复的服务端执行收据;只投影实际发生的阶段、工具调用和 allowlist 技法。供应商显式返回的 reasoning 内容只有通过服务端来源校验与安全过滤后才可作为可选摘要,缺失或不安全时直接省略,不伪造且不读取 hidden chain-of-thought。
- 安全边界:不公开分数、权重、贡献矩阵、内部 ID/字段、候选分钟、工具参数或原始结果、Prompt、模型内部信息和用户敏感原文;D60 不展示且不驱动结论;未执行、不可用或仅供参考的技法不得显示为已执行。
- 兼容边界:历史无收据记录继续读取;`v4_legacy``v5_shadow` 保持原有用户可见回复,shadow 仅持久化新产物而不展示分析轨迹;completed-job replay、原子 completion、`canConfirmExactMinute === false` 和禁止自动写入 `profiles.active_birth_time` 保持不变。
- 防复发:API 与组件测试必须锁定 Turn 关联、刷新恢复、运行中真实 phase、仅展示实际工具/技法、无安全 reasoning 时不补写摘要,以及旧记录、legacy、shadow 的兼容行为。
- 相关记录:BUG-011、BUG-086、BUG-087、BUG-094
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`
+55
View File
@@ -601,6 +601,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); }
@@ -1539,6 +1589,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);
@@ -2,11 +2,12 @@
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,6 +30,115 @@ 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<RectificationV4ApiResponse["job"]>["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<string, string>)[status] ?? "已记录";
}
function RectificationAnalysisDetails({ trace }: Readonly<{ trace: RectificationAnalysisTrace }>) {
return (
<details className="rectification-analysis">
<summary>
<span></span>
<small>{publicStatusLabel(trace.status)}</small>
</summary>
<div className="rectification-analysis-content">
{trace.stages.length > 0 && (
<section aria-label="执行阶段">
<h4></h4>
<ol>
{trace.stages.map((stage, index) => {
const duration = durationLabel(stage.durationMs);
return (
<li key={`${stage.phase}-${index}`}>
<span>{stage.label}</span>
<small>{publicStatusLabel(stage.status)}{duration ? ` · ${duration}` : ""}</small>
</li>
);
})}
</ol>
</section>
)}
{trace.toolCalls.length > 0 && (
<section aria-label="实际调用">
<h4></h4>
<ul>
{trace.toolCalls.map((toolCall, index) => {
const duration = durationLabel(toolCall.durationMs);
return (
<li key={`${toolCall.category}-${index}`}>
<span>{toolCall.label}</span>
<small>{publicStatusLabel(toolCall.outcome)}{duration ? ` · ${duration}` : ""}</small>
</li>
);
})}
</ul>
</section>
)}
{trace.techniques.length > 0 && (
<section aria-label="实际使用的技法">
<h4>使</h4>
<p>{trace.techniques.join("、")}</p>
</section>
)}
{trace.reasoningSource === "provider_summary" && trace.reasoningSummary && (
<section aria-label="推理摘要">
<h4></h4>
<p>{trace.reasoningSummary}</p>
</section>
)}
</div>
</details>
);
}
function RectificationMessageRow({ message }: Readonly<{ message: RectificationChatMessageView }>) {
if (message.state !== "thinking" || !message.text) return <ChatMessageRow message={message} />;
return (
<article className="message message-assistant" aria-label="Jyotisha 正在分析">
<AgentAvatar />
<div className="message-content">
<div className="message-bubble">
<AgentActivityStatus state="working" label={message.text} />
</div>
</div>
</article>
);
}
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
@@ -55,7 +165,7 @@ export function rectificationV4ChatMessages(
data: RectificationV4ApiResponse | null,
processing: boolean,
pendingConsultationQuestion?: string | null,
): readonly ChatMessageView[] {
): readonly RectificationChatMessageView[] {
if (!data) {
return [{
role: "assistant",
@@ -65,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",
@@ -75,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({
@@ -90,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({
@@ -107,6 +229,7 @@ export function rectificationV4ChatMessages(
text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`,
renderKey: `rectification-range-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
}
@@ -116,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",
});
@@ -132,6 +256,7 @@ export function rectificationV4ChatMessages(
text: "进度已经保存。准备好后,我们可以从这里继续。",
renderKey: `rectification-paused-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
} else if (caseValue.status === "abandoned") {
messages.push({
@@ -139,6 +264,7 @@ export function rectificationV4ChatMessages(
text: "这次校正已经结束,原出生时间没有被改写。",
renderKey: `rectification-abandoned-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
}
@@ -254,9 +380,12 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
});
return (
<div className="rectification-message-entry" key={message.renderKey}>
<ChatMessageRow message={regenerating
<RectificationMessageRow message={regenerating
? { ...message, text: "", state: "thinking" }
: message} />
{message.role === "assistant" && message.state === "settled" && message.analysisTrace && (
<RectificationAnalysisDetails trace={message.analysisTrace} />
)}
{showActions && !regenerating && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
@@ -1,5 +1,5 @@
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}$/);
@@ -254,6 +254,11 @@ export const publicMessageSchema = z.object({
}).strict();
export type PublicMessage = z.infer<typeof publicMessageSchema>;
export const storedPublicMessageSchema = publicMessageSchema.extend({
analysisTrace: rectificationAnalysisTraceSchema.optional(),
}).strict();
export type StoredPublicMessage = z.infer<typeof storedPublicMessageSchema>;
export const agentRunSchema = z.object({
id: uuid,
caseId: uuid,
@@ -1,7 +1,7 @@
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";
@@ -21,7 +21,7 @@ import {
type AgentRun,
type CandidateFeatureSnapshot,
type DiagnosticsSummary,
type PublicMessage,
type StoredPublicMessage,
type ValidatedDecision,
} from "./contracts.ts";
@@ -29,6 +29,48 @@ function hash(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
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<string>();
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;
@@ -41,14 +83,33 @@ 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 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,
@@ -88,11 +149,16 @@ 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,
@@ -191,7 +257,7 @@ 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,
@@ -201,7 +267,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
targetDisposition: reconciliation.targetDisposition,
retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [],
});
await input.onPhase?.("reasoning");
await enterPhase("reasoning");
const reasoned = await runBoundedReasoner({
caseValue: claimed.case,
snapshot,
@@ -259,7 +325,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
selectedOpportunity,
};
await input.onPhase?.("rendering");
await enterPhase("rendering");
const legacyProjection = projectLegacyV4Turn({
events,
newEvents: extracted,
@@ -268,7 +334,7 @@ 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,
@@ -279,6 +345,25 @@ export async function processRectificationAgentTurn(input: Readonly<{
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,
@@ -20,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<Usage> }>;
type GeneratedDecision = Readonly<{
object: unknown;
totalUsage?: Usage | Promise<Usage>;
reasoningSummary?: string | null;
reasoningSource?: "provider_summary" | null;
}>;
export type RectificationReasonerGenerator = (
prompt: string,
phase: "initial" | "after_diagnostic",
) => Promise<GeneratedDecision>;
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 };
@@ -95,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;
@@ -116,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");
@@ -160,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;
@@ -176,11 +223,22 @@ export async function runBoundedReasoner(input: Readonly<{
usageObserved = true;
};
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);
@@ -190,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");
}
@@ -200,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"
@@ -26,15 +26,19 @@ export function createRectificationV4CaseService(
const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization;
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
const [events, turns] = await Promise.all([
const [events, turns, analysis] = await Promise.all([
store.loadEvents(userId, caseValue.id),
store.loadTurns(userId, caseValue.id),
caseValue.deploymentMode === "v5_agent"
? store.loadAnalysisMessages(userId, caseValue.id)
: Promise.resolve([]),
]);
return {
case: caseValue,
job: jobId ? await store.loadJob(userId, jobId) : null,
events: [...events],
turns: [...turns],
analysis: [...analysis],
};
}
@@ -270,11 +270,49 @@ export const rectificationV4JobSchema = z.object({
}).strict();
export type RectificationV4Job = z.infer<typeof rectificationV4JobSchema>;
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<typeof rectificationAnalysisTraceSchema>;
export const rectificationAnalysisItemSchema = z.object({
sourceTurnId: z.string().uuid(),
trace: rectificationAnalysisTraceSchema,
}).strict();
export type RectificationAnalysisItem = z.infer<typeof rectificationAnalysisItemSchema>;
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<typeof rectificationV4ApiResponseSchema>;
@@ -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<string, DiagnosticsSummary>;
readonly featureSnapshots: Map<string, CandidateFeatureSnapshot>;
readonly agentRuns: Map<string, AgentRun>;
readonly publicMessages: Map<string, PublicMessage>;
readonly publicMessages: Map<string, StoredPublicMessage>;
readonly validatedDecisions: Map<string, ValidatedDecision>;
readonly pendingEvidence: Map<string, PendingEvidence>;
} {
@@ -32,7 +32,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
const diagnostics = new Map<string, DiagnosticsSummary>();
const featureSnapshots = new Map<string, CandidateFeatureSnapshot>();
const agentRuns = new Map<string, AgentRun>();
const publicMessages = new Map<string, PublicMessage>();
const publicMessages = new Map<string, StoredPublicMessage>();
const validatedDecisions = new Map<string, ValidatedDecision>();
const pendingEvidence = new Map<string, PendingEvidence>();
@@ -69,6 +69,13 @@ 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;
+4 -2
View File
@@ -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,6 +46,7 @@ export interface RectificationV4Store {
loadCase(userId: string, caseId: string): Promise<RectificationV4Case | null>;
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
loadAnalysisMessages(userId: string, caseId: string): Promise<readonly RectificationAnalysisItem[]>;
loadLatestValidatedDecision(userId: string, caseId: string): Promise<ValidatedDecision | null>;
loadActionCase(userId: string, actionId: string): Promise<RectificationV4Case | null>;
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
@@ -1,13 +1,15 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { validatedDecisionSchema, type ValidatedDecision } from "../rectification-agent/contracts.ts";
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,
@@ -22,6 +24,24 @@ import { evidenceSetHash, rectificationFingerprint } from "./fingerprints.ts";
type Row = Record<string, unknown>;
export function projectAnalysisMessages(
publicMessageRows: readonly Readonly<Row>[],
jobRows: readonly Readonly<Row>[],
): 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);
}
@@ -183,6 +203,21 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
return ((data ?? []) as Row[]).map(turnValue);
}
async function loadAnalysisMessagesByCase(userId: string, caseId: string): Promise<readonly RectificationAnalysisItem[]> {
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<unknown> {
const { data, error } = await supabase.rpc(name, args);
if (error) throw storeError(error);
@@ -200,6 +235,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
loadCase: loadCaseById,
loadEvents: loadEventsByCase,
loadTurns: loadTurnsByCase,
loadAnalysisMessages: loadAnalysisMessagesByCase,
async loadLatestValidatedDecision(userId, caseId): Promise<ValidatedDecision | null> {
const { data, error } = await supabase.from("birth_time_rectification_agent_runs")
.select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId)
@@ -4,6 +4,7 @@ import test from "node:test";
import {
canRegenerateRectificationMessage,
rectificationV4ChatMessages,
rectificationPhaseLabel,
toggleRectificationFeedback,
} from "../src/components/rectification-v4-panel.tsx";
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
@@ -59,6 +60,7 @@ function response(overrides: Record<string, unknown> = {}): RectificationV4ApiRe
},
job: null,
events: [],
analysis: [],
turns: [{
id: "00000000-0000-4000-8000-000000000903",
caseId: id,
@@ -72,7 +74,28 @@ function response(overrides: Record<string, unknown> = {}): 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", () => {
@@ -93,7 +116,14 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
assert.match(component, /<ModelSelector/);
assert.match(component, /aria-label="赞"/);
assert.match(component, /aria-label="踩"/);
assert.match(component, /aria-label="复制回答"/);
assert.match(component, /aria-label="重新生成回答"/);
assert.match(component, /<details className="rectification-analysis">/);
assert.match(component, /<span>分析过程<\/span>/);
assert.match(
component,
/<RectificationAnalysisDetails trace=\{message\.analysisTrace\} \/>[\s\S]*?<div className="rectification-message-actions"/,
);
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
assert.match(component, /controller\.regenerate\(\)/);
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
@@ -151,10 +181,95 @@ test("turn history and the context-aware next question render as one chat timeli
]);
});
test("processing is an ordinary assistant thinking message after the saved answer", () => {
const messages = rectificationV4ChatMessages(response({ currentQuestion: null, status: "processing" }), true);
test("completed analysis is attached to the next Agent message by source turn", () => {
const base = response();
const firstTurn = base.turns[0]!;
const secondTurn = {
...firstTurn,
id: "00000000-0000-4000-8000-000000000906",
caseVersion: 2,
questionId: "00000000-0000-4000-8000-000000000907",
question: "承接复读后再次毕业,你还记得哪次职业变化的大概时间?",
answer: "2020年4月去研究院实习。",
actionId: "00000000-0000-4000-8000-000000000908",
};
const firstTrace = analysisTrace("整理第一轮经历");
const secondTrace = analysisTrace("整理第二轮经历");
const data = {
...base,
turns: [firstTurn, secondTurn],
analysis: [
{ sourceTurnId: firstTurn.id, trace: firstTrace },
{ sourceTurnId: secondTurn.id, trace: secondTrace },
],
} as unknown as RectificationV4ApiResponse;
const assistantMessages = rectificationV4ChatMessages(data, false)
.filter((message) => message.role === "assistant");
assert.equal(assistantMessages[0]?.analysisTrace, undefined);
assert.equal(assistantMessages[1]?.analysisTrace, firstTrace);
assert.equal(assistantMessages[2]?.analysisTrace, secondTrace);
});
test("legacy and shadow modes do not expose persisted analysis traces", () => {
const base = response();
const trace = analysisTrace("不应显示");
for (const deploymentMode of ["v4_legacy", "v5_shadow"] as const) {
const data = {
...base,
case: { ...base.case, deploymentMode },
analysis: [{ sourceTurnId: base.turns[0]!.id, trace }],
} as unknown as RectificationV4ApiResponse;
assert.equal(
rectificationV4ChatMessages(data, false).some((message) => message.analysisTrace),
false,
deploymentMode,
);
}
});
test("processing shows the current server job phase in Chinese", () => {
const base = response({ currentQuestion: null, status: "processing", phase: "checking_robustness" });
const data = {
...base,
job: {
id: "00000000-0000-4000-8000-000000000909",
caseId: id,
status: "processing",
phase: "checking_robustness",
expectedCaseVersion: 2,
evidenceSetHash: "b".repeat(64),
calculationSpecHash: "a".repeat(64),
errorCode: null,
createdAt: now,
updatedAt: now,
},
} as unknown as RectificationV4ApiResponse;
const messages = rectificationV4ChatMessages(data, true);
assert.equal(messages.at(-1)?.role, "assistant");
assert.equal(messages.at(-1)?.state, "thinking");
assert.equal(messages.at(-1)?.text, "正在检查候选范围的稳定性…");
assert.equal(rectificationPhaseLabel("planning_question"), "正在选择下一条最有信息量的问题…");
});
test("analysis details render only public labels and preserve the message action icons", () => {
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(component, /\{stage\.label\}/);
assert.match(component, /\{toolCall\.label\}/);
assert.match(component, /trace\.techniques\.join\("、"\)/);
assert.match(component, /trace\.reasoningSource === "provider_summary"/);
assert.doesNotMatch(component, />\{stage\.phase\}</);
assert.doesNotMatch(component, />\{toolCall\.category\}</);
assert.doesNotMatch(component, />\{item\.sourceTurnId\}</);
assert.match(component, /<ThumbsUp aria-hidden="true" \/>/);
assert.match(component, /<ThumbsDown aria-hidden="true" \/>/);
assert.match(component, /<Copy aria-hidden="true" \/>/);
assert.match(component, /<RotateCcw aria-hidden="true" \/>/);
assert.match(css, /\.rectification-analysis > summary/);
assert.doesNotMatch(component, /candidateScore|contributionMatrix|opportunityId|snapshotId/);
});
test("range messages never claim an exact confirmed birth minute", () => {
+12 -2
View File
@@ -361,17 +361,27 @@ test("shadow mode persists V5 artifacts while preserving the legacy visible repl
});
assert.ok(queued?.job);
await worker.runOnce();
const loaded = await service.loadCase(userId, created.case.id);
return {
message: [...store.publicMessages.values()][0],
question: (await service.loadCase(userId, created.case.id))?.case.currentQuestion,
question: loaded?.case.currentQuestion,
analysis: loaded?.analysis ?? [],
agentRuns: store.agentRuns.size,
};
});
}
const legacy = await run("v4_legacy");
const shadow = await run("v5_shadow");
assert.deepEqual(shadow.message, legacy.message);
const visibleMessage = (message: NonNullable<typeof legacy.message>) => ({
acknowledgement: message.acknowledgement,
candidateUpdate: message.candidateUpdate,
limitation: message.limitation,
question: message.question,
});
assert.deepEqual(visibleMessage(shadow.message!), visibleMessage(legacy.message!));
assert.equal(shadow.question?.prompt, legacy.question?.prompt);
assert.deepEqual(legacy.analysis, []);
assert.deepEqual(shadow.analysis, []);
assert.equal(shadow.agentRuns, 1);
});
@@ -0,0 +1,432 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import {
diagnosticsSummarySchema,
storedPublicMessageSchema,
type DiagnosticsSummary,
type QuestionOpportunity,
} from "../src/lib/rectification-agent/contracts.ts";
import { processRectificationAgentTurn } from "../src/lib/rectification-agent/orchestrator.ts";
import { runBoundedReasoner, sanitizeReasoningSummary } from "../src/lib/rectification-agent/reasoner-agent.ts";
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts";
import type {
CalculationSpec,
LifeEventRevision,
RectificationAnalysisTrace,
RectificationV4Case,
RectificationV4Turn,
} from "../src/lib/rectification-v4/contracts.ts";
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { projectAnalysisMessages } from "../src/lib/rectification-v4/supabase-store.ts";
import type { ClaimedRectificationV4Job } from "../src/lib/rectification-v4/store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
const now = "2026-07-29T00:00:00.000Z";
const spec: CalculationSpec = {
version: "rectification-calculation-spec-v4",
birthDate: "1997-08-08",
candidateRange: { start: "05:00", end: "06:00" },
latitude: 36.419,
longitude: 114.213,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
};
function event(
domain: LifeEventRevision["domain"],
eventKind: LifeEventRevision["eventKind"],
summary: string,
date: string,
): LifeEventRevision {
return {
id: randomUUID(),
eventId: randomUUID(),
revision: 1,
domain,
eventKind,
subject: "self",
relatedPerson: null,
summary,
rawText: `${date} ${summary}`,
dateRange: { start: `${date}-01`, end: `${date}-28`, precision: "month", label: date },
scoreability: "scoreable",
supersedesRevisionId: null,
createdAt: now,
};
}
function makeClaimed(events: readonly LifeEventRevision[]): ClaimedRectificationV4Job {
const caseId = randomUUID();
const turn: RectificationV4Turn = {
id: randomUUID(),
caseId,
caseVersion: 1,
questionId: randomUUID(),
questionDomain: "other",
questionTargetEventId: null,
question: "请换一个方向,补充一件时间较清楚的经历。",
answer: "记不清了,换一个吧。",
modelId: null,
actionId: randomUUID(),
createdAt: now,
};
const caseValue: RectificationV4Case = {
id: caseId,
userId: randomUUID(),
protocol: "rectification-evidence-v5",
version: 1,
status: "processing",
phase: "extracting_evidence",
calculationSpec: spec,
calculationSpecHash: calculationSpecHash(spec),
evidenceSetHash: "e".repeat(64),
currentQuestion: null,
latestSnapshot: null,
orchestrationModelId: null,
narrationModelId: null,
skillVersion: "birth-time-rectification-v6",
promptVersion: "rectification-agent-v6-1",
algorithmVersion: "rectification-v5-matrix-scoring-1",
deploymentMode: "v5_agent",
agentMode: "deterministic_fallback",
featureSnapshotId: null,
latestDiagnosticsId: null,
acceptedRange: null,
createdAt: now,
updatedAt: now,
};
return {
case: caseValue,
turn,
turns: [turn],
events,
attemptedRefinementEventIds: [],
job: {
id: randomUUID(),
caseId,
status: "processing",
phase: "extracting_evidence",
expectedCaseVersion: 1,
evidenceSetHash: caseValue.evidenceSetHash,
calculationSpecHash: caseValue.calculationSpecHash,
errorCode: null,
createdAt: now,
updatedAt: now,
},
};
}
const opportunity: QuestionOpportunity = {
contractVersion: "semantic-question-v2",
opportunityId: randomUUID(),
kind: "ask_new_event",
domain: "career",
targetEventId: null,
goal: "收集一件有大致日期的新经历。",
requestedFields: ["new_dated_event"],
anchors: [],
contextFacts: [],
forbiddenMoves: [
"switch_target_event",
"ask_multiple_questions",
"claim_exact_birth_minute",
"invent_event",
"invent_date",
"expose_private_score",
"expose_internal_id",
"expose_technique_trace",
],
fallbackPrompt: "请再说一件时间比较明确的经历。",
reason: "补充可区分的证据。",
expectedInformationGain: .8,
dateSensitivity: .5,
candidateSplitRelevance: .5,
domainCoverageGain: .8,
recallEase: .8,
novelty: 1,
repetitionPenalty: 0,
privacyCost: 0,
utility: .85,
active: true,
};
const diagnostics: DiagnosticsSummary = diagnosticsSummarySchema.parse({
id: randomUUID(),
caseId: randomUUID(),
snapshotId: randomUUID(),
primaryClusterRetentionRate: .8,
leaveOneEventOutRetentionRate: .8,
leaveOneDomainOutRetentionRate: .8,
dateSensitivityRetentionRate: .8,
neighborSupportMinutes: 3,
primarySecondaryMarginPercent: 12,
clusterMassRatio: .8,
unstableEventIds: [],
mostDiscriminatingLayers: [],
eventDateSensitivity: [],
candidateSplits: [],
calculationHash: "d".repeat(64),
createdAt: now,
});
function analysisToolLabels(trace: RectificationAnalysisTrace, category: RectificationAnalysisTrace["toolCalls"][number]["category"]): string[] {
return trace.toolCalls.filter((call) => call.category === category).map((call) => call.label);
}
test("provider reasoning keeps safe summaries and rejects private or copied content", async () => {
const safeSummary = "现有材料还不足,宜先补充一件时间较清楚的经历。";
const reasoned = await runBoundedReasoner({
caseValue: makeClaimed([]).case,
snapshot: null,
diagnostics,
opportunities: [opportunity],
generateDecision: async () => ({
object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: ["uncertainty"] },
reasoningSummary: safeSummary,
reasoningSource: "provider_summary",
}),
});
assert.equal(reasoned.reasoningSummary, safeSummary);
const unsafe = [
"参考 00000000-0000-4000-8000-000000000901",
"候选是 05:13",
"score 较高",
"snapshotId 已更新",
"opportunityId 已选中",
"执行 tool call",
"采用 D9 继续判断",
"候选更接近清晨五点十三分",
"保留率低于百分之六十五",
];
for (const value of unsafe) assert.equal(sanitizeReasoningSummary(value), null, value);
const userText = "2016年9月离家去外地上大学";
assert.equal(sanitizeReasoningSummary(`用户提到${userText},所以继续。`, [userText]), null);
assert.equal(sanitizeReasoningSummary("母亲去世后需要继续收集证据。", ["母亲去世"]), null);
assert.equal(sanitizeReasoningSummary("癌症使这项证据需要谨慎处理。", ["癌症"]), null);
const unverifiedSource = await runBoundedReasoner({
caseValue: makeClaimed([]).case,
snapshot: null,
diagnostics,
opportunities: [opportunity],
generateDecision: async () => ({
object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: [] },
reasoningSummary: safeSummary,
}),
});
assert.equal(unverifiedSource.reasoningSummary, null);
});
test("below the scoring gate does not claim candidate scanning, diagnostics, or techniques", async () => {
let scoreCalls = 0;
const result = await processRectificationAgentTurn({
claimed: makeClaimed([
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
event("career", "career_change", "开始第一份工作", "2020-04"),
]),
engine: { score: async () => { scoreCalls += 1; throw new Error("candidate_engine_should_not_run"); } },
now: new Date(now),
});
const trace = result.publicMessage.analysisTrace;
assert.ok(trace);
assert.equal(scoreCalls, 0);
assert.equal(result.snapshot, null);
assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), false);
assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), false);
assert.deepEqual(trace.toolCalls, []);
assert.deepEqual(trace.techniques, []);
});
test("scoring trace records one real engine call and only matrix-confirmed techniques", async () => {
const claimed = makeClaimed([
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
event("relocation", "relocation", "搬到北京长期居住", "2018-08"),
event("career", "career_change", "开始负责商业巡演公司", "2023-09"),
]);
let scoreCalls = 0;
const result = await processRectificationAgentTurn({
claimed,
engine: {
score: async ({ calculationSpec, events }) => {
scoreCalls += 1;
const base = v5EngineResult(calculationSpec, events);
const contributionMatrix: CandidateEngineResult["contributionMatrix"] = Object.fromEntries(
Object.entries(base.contributionMatrix).map(([eventId, candidates]) => [
eventId,
Object.fromEntries(Object.entries(candidates).map(([time, contribution]) => [time, {
...contribution,
rule_ids: ["vimshottari_dasha", "D60"],
technique_layers: ["D2", "D60"],
}])),
]),
);
return { ...base, contributionMatrix };
},
},
now: new Date(now),
});
const trace = result.publicMessage.analysisTrace;
assert.ok(trace);
assert.equal(scoreCalls, 1);
assert.equal(result.snapshot?.canConfirmExactMinute, false);
assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), true);
assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), true);
assert.deepEqual(analysisToolLabels(trace, "candidate_engine"), ["候选分钟扫描与稳定性诊断"]);
assert.deepEqual(analysisToolLabels(trace, "diagnostic"), []);
assert.deepEqual(new Set(trace.techniques), new Set(["Vimshottari Dasha", "D2"]));
assert.equal(trace.techniques.includes("D60"), false);
assert.equal(trace.techniques.includes("D9"), false);
assert.equal(trace.techniques.includes("D10"), false);
assert.deepEqual(analysisToolLabels(trace, "agent_diagnostic"), []);
});
test("read-only Agent diagnostics are traced only when the reasoner actually requests one", async () => {
const caseValue = makeClaimed([]).case;
const direct = await runBoundedReasoner({
caseValue,
snapshot: null,
diagnostics,
opportunities: [opportunity],
generateDecision: async () => ({ object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: [] } }),
});
assert.deepEqual(direct.toolCalls, []);
const phases: string[] = [];
const diagnosticRun = await runBoundedReasoner({
caseValue,
snapshot: null,
diagnostics,
opportunities: [opportunity],
generateDecision: async (_prompt, phase) => {
phases.push(phase);
return phase === "initial"
? { object: { action: "run_diagnostic", diagnostic: "neighbor_stability" } }
: { object: { action: "ask_question", opportunityId: opportunity.opportunityId, narrativeFocus: ["uncertainty"] } };
},
});
assert.deepEqual(phases, ["initial", "after_diagnostic"]);
assert.deepEqual(diagnosticRun.toolCalls.map((call) => [call.tool, call.diagnostic, call.outcome]), [
["run_rectification_diagnostics", "neighbor_stability", "succeeded"],
]);
});
test("old public messages without analysisTrace remain readable and are omitted from trace history", async () => {
await withV5Mode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: () => new Date(now) });
const worker = createRectificationV4Worker({
store,
now: () => new Date(now),
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
});
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: created.case.version,
answer: "2016年9月离家去外地上大学",
});
assert.ok(queued?.job);
assert.equal(await worker.runOnce(), true);
const legacyMessage = storedPublicMessageSchema.parse({
acknowledgement: "你提到的是离家去外地上大学。",
candidateUpdate: null,
limitation: null,
question: "请再说一件时间比较明确的经历。",
});
assert.equal(legacyMessage.analysisTrace, undefined);
store.publicMessages.set(queued.job.id, legacyMessage);
assert.deepEqual(await store.loadAnalysisMessages(userId, created.case.id), []);
});
});
function storedMessageWithTrace(trace: unknown): Record<string, unknown> {
return {
acknowledgement: "承接这段经历。",
candidateUpdate: null,
limitation: null,
question: "请再说一件时间比较明确的经历。",
analysisTrace: trace,
};
}
test("Supabase analysis projection orders multiple turns and maps job ids to turn ids", () => {
const earlierJobId = randomUUID();
const laterJobId = randomUUID();
const earlierTurnId = randomUUID();
const laterTurnId = randomUUID();
const earlierTrace: RectificationAnalysisTrace = {
status: "completed",
stages: [],
toolCalls: [],
techniques: ["D2"],
reasoningSummary: null,
reasoningSource: "none",
};
const laterTrace: RectificationAnalysisTrace = {
status: "completed",
stages: [],
toolCalls: [],
techniques: ["D4"],
reasoningSummary: null,
reasoningSource: "none",
};
const projected = projectAnalysisMessages([
{ job_id: laterJobId, message: storedMessageWithTrace(laterTrace), created_at: "2026-07-29T02:00:00.000Z" },
{ job_id: earlierJobId, message: storedMessageWithTrace(earlierTrace), created_at: "2026-07-29T01:00:00.000Z" },
], [
{ id: laterJobId, turn_id: laterTurnId },
{ id: earlierJobId, turn_id: earlierTurnId },
]);
assert.deepEqual(projected, [
{ sourceTurnId: earlierTurnId, trace: earlierTrace },
{ sourceTurnId: laterTurnId, trace: laterTrace },
]);
});
test("Supabase analysis projection ignores legacy messages without analysisTrace", () => {
const jobId = randomUUID();
const turnId = randomUUID();
assert.deepEqual(projectAnalysisMessages([{
job_id: jobId,
message: {
acknowledgement: "承接这段经历。",
candidateUpdate: null,
limitation: null,
question: "请再说一件时间比较明确的经历。",
},
created_at: "2026-07-29T01:00:00.000Z",
}], [{ id: jobId, turn_id: turnId }]), []);
});
test("Supabase analysis projection ignores invalid traces", () => {
const jobId = randomUUID();
const turnId = randomUUID();
assert.deepEqual(projectAnalysisMessages([{
job_id: jobId,
message: storedMessageWithTrace({
status: "invented",
stages: [],
toolCalls: [],
techniques: [],
reasoningSummary: null,
reasoningSource: "none",
}),
created_at: "2026-07-29T01:00:00.000Z",
}], [{ id: jobId, turn_id: turnId }]), []);
});
@@ -69,6 +69,12 @@ test("V5 completion is lease-bound, hash-bound, ownership-bound and replay-safe"
assert.match(migration, /completion_payload_hash = p_completion_payload_hash/);
});
test("V5 completion accepts and replays the full extensible public message JSON", () => {
assert.match(migration, /jsonb_typeof\(p_public_message\) is distinct from 'object'/);
assert.match(migration, /v_existing_message\.message is distinct from p_public_message/);
assert.match(migration, /birth_time_rectification_public_messages[\s\S]*p_public_message/);
});
test("V5 inserts use explicit columns and never mutate the profile birth minute", () => {
for (const table of [
"birth_time_rectification_v4_events",
+13
View File
@@ -17,6 +17,7 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
- The server owns event reconciliation, candidate-minute scanning, event contributions, snapshots, diagnostics, stability gates, jobs, replay, persistence, and final decision validation.
- The agent may select one active server opportunity, call at most one allowed read-only diagnostic, offer an already-gated candidate range, or stop for low confidence.
- The agent never creates events, dates, scores, candidate minutes, or profile updates.
- The persisted “分析过程” is a server-owned execution receipt, not hidden chain-of-thought. It may list only stages, tools, and techniques that actually ran, plus a provider-explicit reasoning summary after server-side safety filtering.
- `canConfirmExactMinute` is always `false`. Never write `profiles.active_birth_time` automatically.
## Seventeen conversation boundaries
@@ -61,3 +62,15 @@ A user who answers with a different complete event may have that event saved wit
## Public language
Use a brief acknowledgement tied to the user's actual event, an optional gated candidate update, an optional limitation, and at most one question. Do not repeat an unchanged range, over-interpret the event, or turn sparse/conflicting evidence into certainty.
## Analysis process receipt
The collapsible “分析过程” shown with an assistant message is a durable projection of server execution artifacts. It must remain attached to the correct Turn after refresh; the browser must not infer history from timestamps or manufacture missing phases.
- Show only phases that actually ran and only tools or techniques confirmed by persisted server artifacts.
- Never present an unavailable, skipped, reference-only, or merely supported technique as executed.
- A provider reasoning summary may be shown only when the provider explicitly returned displayable reasoning content and the server accepted it through the public safety filter. Never synthesize a replacement summary or expose hidden chain-of-thought.
- Do not expose scores, weights, contribution matrices, internal IDs or field names, candidate minutes, tool arguments/results, prompts, sensitive answer text, or model/provider internals.
- D60 is neither displayed nor allowed to drive a conclusion.
- Historical records without a receipt remain readable. `v4_legacy` and `v5_shadow` retain their existing visible-reply behavior and gain trace display only when compatible persisted artifacts actually exist.
@@ -19,3 +19,12 @@ A month-dated event is not a failure. Refine it only when date-sensitivity diagn
Preserve the existing Job and persistence guarantees: claim/lease, idempotency, completed-job replay, and atomic completion. A renderer or extraction failure must not cause partial artifact writes, duplicate completion, profile mutation, or a different replay result.
Never log raw sensitive answers to ordinary telemetry. Persist user text only in the approved Turn/evidence stores required by the product contract.
## Analysis receipt failures
- Missing phase history, tool traces, technique evidence, or provider reasoning is represented by omission, never by reconstruction or invented text.
- A provider reasoning payload that fails source checks or the server safety filter is discarded. Do not fall back to hidden reasoning, a second-model summary, or raw provider metadata.
- If a Job fails before durable artifacts exist, show only a safe public failure state; do not expose internal error codes or partial model output.
- Legacy records without analysis receipts must continue to load. `v4_legacy` and `v5_shadow` visible replies must not change merely to populate the receipt.
- Receipt persistence and Turn association must be replay-safe and owner-scoped. Refresh, completed-job replay, or retry must not duplicate, reorder, or attach a receipt to another Turn.
@@ -42,3 +42,19 @@ The no-repeat rule takes precedence: it must be `null` for an unchanged range, i
## Deterministic fallback
Fallback follows the same public rules as model output: acknowledge the actual event naturally, ask one anchored question, avoid repetition and over-interpretation, and never claim exact-minute certainty.
## Persisted analysis process
“分析过程” is rendered beside the assistant message as a collapsible, refresh-safe server execution receipt. It is not part of the model-authored public message fields above and is never generated by the Renderer.
The public projection may contain only:
- server phases that actually started or completed;
- allowlisted labels for candidate calculation, stability diagnostics, and a Reasoner read-only diagnostic that actually ran;
- allowlisted techniques proven present in persisted execution artifacts;
- an optional provider-explicit reasoning summary accepted by the server safety filter.
Do not infer missing phases from the final Job phase, and do not label a capability as executed merely because the deployment supports it. If no safe provider summary exists, omit it; never create a substitute or expose hidden chain-of-thought.
The receipt must exclude scores, weights, contribution matrices, internal IDs and field names, candidate minutes, tool arguments or raw results, prompts, model/provider internals, and sensitive user wording. D60 is never displayed. Historical messages without a receipt remain valid, and `v4_legacy`/`v5_shadow` keep their established visible reply semantics.
@@ -20,3 +20,15 @@ Only server-reported available layers may be described as used. Missing, blocked
- D60 is reference-only and must never drive candidate selection or the public conclusion.
- Never expose private scores, weights, contribution values, internal technique traces, or tool/model names in the user-facing message.
- No technique result can override `canConfirmExactMinute === false` or authorize an automatic profile birth-time write.
## Public execution receipt
The analysis receipt reports observed execution, not the complete capability catalog. A technique or diagnostic may be named only when persisted server artifacts prove it ran in that Turn.
- Candidate-minute scanning and stability diagnostics are shown only on turns that executed them.
- A Reasoner diagnostic is shown as an Agent read only when its persisted tool trace records the call; precomputed diagnostics are not Agent tool calls.
- Technique labels are derived through a server allowlist from actual contribution/technique metadata. Never expose rule IDs, raw layers, scores, weights, contribution values, matrices, arguments, or candidate minutes.
- Unsupported, unavailable, skipped, blocked, reference-only, and research-only layers are omitted rather than shown as missing work.
- D60 is omitted from the receipt and must not drive candidate selection, stability claims, or public conclusions.
- Provider-explicit reasoning content is not technique evidence. It may appear only as a separately labeled, server-filtered summary and never as hidden chain-of-thought.