fix(rectification): preserve grounded agent responses
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
|
||||
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AgentActivityStatus } from "@/components/agent-activity-status";
|
||||
import { AgentActivityStatus, type AgentActivityState } 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 { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
|
||||
import type { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationAssistantMessage, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
|
||||
import { AgentAvatar, ChatMessageRow } from "./chat-message-row";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -32,8 +32,11 @@ type RectificationV4PanelProps = Readonly<{
|
||||
|
||||
type RectificationChatMessageView = ChatMessageView & Readonly<{
|
||||
analysisTrace?: RectificationAnalysisTrace;
|
||||
activityState?: AgentActivityState;
|
||||
}>;
|
||||
|
||||
type RectificationPhase = NonNullable<RectificationV4ApiResponse["job"]>["phase"];
|
||||
|
||||
const phaseLabels = {
|
||||
collecting_evidence: "正在准备继续收集经历…",
|
||||
extracting_evidence: "正在整理你刚才提到的经历…",
|
||||
@@ -45,22 +48,31 @@ const phaseLabels = {
|
||||
complete: "分析已完成",
|
||||
} as const;
|
||||
|
||||
const phaseActivityStates = {
|
||||
collecting_evidence: "listening",
|
||||
extracting_evidence: "working",
|
||||
scoring_candidates: "searching",
|
||||
checking_robustness: "solving",
|
||||
planning_question: "shaping",
|
||||
reasoning: "solving",
|
||||
rendering: "composing",
|
||||
complete: "shaping",
|
||||
} as const satisfies Record<RectificationPhase, AgentActivityState>;
|
||||
|
||||
export function rectificationPhaseLabel(
|
||||
phase: NonNullable<RectificationV4ApiResponse["job"]>["phase"],
|
||||
phase: RectificationPhase,
|
||||
): string {
|
||||
return phaseLabels[phase];
|
||||
}
|
||||
|
||||
export function rectificationProgressLabel(
|
||||
phase: NonNullable<RectificationV4ApiResponse["job"]>["phase"],
|
||||
phase: RectificationPhase,
|
||||
): string {
|
||||
const activeStep = ["collecting_evidence", "extracting_evidence"].includes(phase)
|
||||
? 0
|
||||
: ["scoring_candidates", "checking_robustness"].includes(phase)
|
||||
? 1
|
||||
: 2;
|
||||
const steps = ["整理已确认事件", "比较候选时间差异", "确定下一步验证方向"];
|
||||
return [`${rectificationPhaseLabel(phase)}\n正在分析:`, ...steps.map((step, index) => `${index < activeStep || phase === "complete" ? "✓" : index === activeStep ? "●" : "○"} ${step}`)].join("\n");
|
||||
return rectificationPhaseLabel(phase);
|
||||
}
|
||||
|
||||
export function rectificationPhaseActivityState(phase: RectificationPhase): AgentActivityState {
|
||||
return phaseActivityStates[phase];
|
||||
}
|
||||
|
||||
function durationLabel(durationMs: number | null): string | null {
|
||||
@@ -144,7 +156,7 @@ function RectificationMessageRow({ message }: Readonly<{ message: RectificationC
|
||||
<AgentAvatar />
|
||||
<div className="message-content">
|
||||
<div className="message-bubble">
|
||||
<AgentActivityStatus state="working" label={message.text} />
|
||||
<AgentActivityStatus state={message.activityState ?? "working"} label={message.text} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -173,6 +185,13 @@ export function canRegenerateRectificationMessage(input: Readonly<{
|
||||
&& input.canAnswer;
|
||||
}
|
||||
|
||||
|
||||
export function composeRectificationAssistantMessage(message: RectificationAssistantMessage): string {
|
||||
return [message.acknowledgement, message.evidenceExplanation, message.candidateUpdate, message.limitation, message.question]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function rectificationV4ChatMessages(
|
||||
data: RectificationV4ApiResponse | null,
|
||||
processing: boolean,
|
||||
@@ -188,6 +207,8 @@ export function rectificationV4ChatMessages(
|
||||
}
|
||||
|
||||
const messages: RectificationChatMessageView[] = [];
|
||||
const assistantResponses = data.case.deploymentMode === "v5_agent" ? data.assistantResponses ?? [] : [];
|
||||
const responseBySourceTurnId = new Map(assistantResponses.map((item) => [item.sourceTurnId, item]));
|
||||
const analysis = data.case.deploymentMode === "v5_agent"
|
||||
? (data as RectificationV4ApiResponse & {
|
||||
readonly analysis?: readonly RectificationAnalysisItem[];
|
||||
@@ -203,15 +224,17 @@ export function rectificationV4ChatMessages(
|
||||
});
|
||||
}
|
||||
|
||||
let previousTurnId: string | null = null;
|
||||
let previousTurn: RectificationV4ApiResponse["turns"][number] | undefined;
|
||||
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 (!previousTurn || !responseBySourceTurnId.has(previousTurn.id)) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: turn.question,
|
||||
renderKey: `rectification-question-${turn.id}`,
|
||||
state: "settled",
|
||||
analysisTrace: previousTurn ? analysisBySourceTurnId.get(previousTurn.id) : undefined,
|
||||
});
|
||||
}
|
||||
if (turn.answer) {
|
||||
messages.push({
|
||||
role: "user",
|
||||
@@ -220,13 +243,25 @@ export function rectificationV4ChatMessages(
|
||||
state: "settled",
|
||||
});
|
||||
}
|
||||
previousTurnId = turn.id;
|
||||
const response = responseBySourceTurnId.get(turn.id);
|
||||
if (response) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: composeRectificationAssistantMessage(response.message),
|
||||
renderKey: `rectification-response-${turn.id}`,
|
||||
state: "settled",
|
||||
analysisTrace: response.trace ?? undefined,
|
||||
});
|
||||
}
|
||||
previousTurn = turn;
|
||||
}
|
||||
|
||||
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;
|
||||
const latestTurn = data.turns.at(-1);
|
||||
const latestResponse = latestTurn ? responseBySourceTurnId.get(latestTurn.id) : undefined;
|
||||
const latestTurnTrace = latestResponse?.trace ?? analysisBySourceTurnId.get(latestTurn?.id ?? "");
|
||||
const terminalTrace = caseValue.currentQuestion ? undefined : latestTurnTrace ?? undefined;
|
||||
if (caseValue.acceptedRange) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
@@ -235,7 +270,7 @@ export function rectificationV4ChatMessages(
|
||||
state: "settled",
|
||||
analysisTrace: terminalTrace,
|
||||
});
|
||||
} else if (caseValue.status === "range_ready" && primary) {
|
||||
} else if (!latestResponse && caseValue.status === "range_ready" && primary) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}–${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`,
|
||||
@@ -245,24 +280,26 @@ export function rectificationV4ChatMessages(
|
||||
});
|
||||
}
|
||||
|
||||
if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange) {
|
||||
if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange && !latestResponse) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: caseValue.currentQuestion.prompt,
|
||||
renderKey: `rectification-current-${caseValue.currentQuestion.id}`,
|
||||
state: "settled",
|
||||
analysisTrace: latestTurnTrace,
|
||||
analysisTrace: latestTurnTrace ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (processing) {
|
||||
const phase = data.job?.phase ?? caseValue.phase;
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: rectificationProgressLabel(data.job?.phase ?? caseValue.phase),
|
||||
text: rectificationProgressLabel(phase),
|
||||
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
|
||||
state: "thinking",
|
||||
activityState: rectificationPhaseActivityState(phase),
|
||||
});
|
||||
} else if (caseValue.status === "paused") {
|
||||
} else if (caseValue.status === "paused" && !latestResponse) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: "进度已经保存。准备好后,我们可以从这里继续。",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { clockTimeSchema, eventKindSchema, eventSubjectSchema, evidenceDomainSchema, relatedPersonSchema, rectificationAnalysisTraceSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts";
|
||||
import { clockTimeSchema, eventKindSchema, eventSubjectSchema, evidenceDomainSchema, rectificationAnalysisTraceSchema, rectificationAssistantMessageSchema, rectificationDeploymentModeSchema, relatedPersonSchema } from "../rectification-v4/contracts.ts";
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
@@ -108,9 +108,14 @@ export const rectificationTurnPlanSchema = z.object({
|
||||
action: directorActionSchema,
|
||||
publicReply: z.object({
|
||||
acknowledgement: nonblank(1_000),
|
||||
evidenceExplanation: nonblank(1_600).nullable().default(null),
|
||||
candidateCommentary: nonblank(1_000).nullable(),
|
||||
limitation: nonblank(1_000).nullable(),
|
||||
}).strict(),
|
||||
publicExplanationGrounding: z.array(z.object({
|
||||
source: z.enum(["capability_matrix", "window_sensitivity", "candidate_scan", "diagnostic"]),
|
||||
factKey: nonblank(160),
|
||||
}).strict()).max(12).default([]),
|
||||
}).strict();
|
||||
export type RectificationTurnPlan = z.infer<typeof rectificationTurnPlanSchema>;
|
||||
|
||||
@@ -194,6 +199,10 @@ export const rectificationCaseDossierSchema = z.object({
|
||||
capabilities: z.object({
|
||||
supportedDomains: z.array(evidenceDomainSchema),
|
||||
supportedEventKinds: z.array(eventKindSchema),
|
||||
publicTechniqueCapabilities: z.array(z.object({
|
||||
domain: evidenceDomainSchema,
|
||||
techniqueLayers: z.array(nonblank(80)).max(20),
|
||||
}).strict()).max(8),
|
||||
maxQuestionsPerTurn: z.literal(1),
|
||||
maxToolRounds: z.literal(10),
|
||||
forbiddenPublicClaims: z.array(z.string()),
|
||||
@@ -464,12 +473,7 @@ export const validatedDecisionSchema = z.object({
|
||||
}).strict();
|
||||
export type ValidatedDecision = z.infer<typeof validatedDecisionSchema>;
|
||||
|
||||
export const publicMessageSchema = z.object({
|
||||
acknowledgement: nonblank(1_000),
|
||||
candidateUpdate: nonblank(1_000).nullable(),
|
||||
limitation: nonblank(1_000).nullable(),
|
||||
question: nonblank(1_000).nullable(),
|
||||
}).strict();
|
||||
export const publicMessageSchema = rectificationAssistantMessageSchema;
|
||||
export type PublicMessage = z.infer<typeof publicMessageSchema>;
|
||||
|
||||
export const storedPublicMessageSchema = publicMessageSchema.extend({
|
||||
|
||||
@@ -5,15 +5,20 @@ import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts";
|
||||
import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts";
|
||||
import { buildCandidateContrastPacket } from "./opportunity-builder.ts";
|
||||
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationAgentTool, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace, type ToolObservation } from "./contracts.ts";
|
||||
import { assertRectificationSkillLoaded } from "./skill-runtime.ts";
|
||||
|
||||
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
|
||||
const domains: EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family", "other"];
|
||||
const kinds: EventKind[] = ["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 privatePattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|targetEventId|score|评分|得分|权重|rule[_ -]?id|贡献矩阵|tool[_ -]?call|cluster[_ -]?id|\bD\d{1,2}\b|\bKP\b|Vimshottari|Narayana|Shadbala|Ashtakavarga)/iu;
|
||||
const privatePattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|targetEventId|(?:raw|原始)?\s*(?:score|评分|得分)|权重|rule[_ -]?id|贡献矩阵|tool[_ -]?call|cluster[_ -]?id|工具原始(?:输出|轨迹)|内部推理链)/iu;
|
||||
const quantifiedStructurePattern = /(?:(?:D\d{1,2}|KP|Vimshottari|Narayana|Shadbala|Ashtakavarga|Chaturvimshamsha|上升星座|宫位|分盘)[^。!?\n]{0,60}(?:切换|变化|变动|遍历)[^。!?\n]{0,16}(?:\d+|[一二三四五六七八九十百]+)\s*次|(?:\d+|[一二三四五六七八九十百]+)\s*次[^。!?\n]{0,60}(?:切换|变化|变动|遍历))/iu;
|
||||
const exactMinutePattern = /(?:\b(?:[01]?\d|2[0-3]):[0-5]\d\b|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*[点时]\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*分)/u;
|
||||
const questionClausePattern = /(?:请|你(?:还)?(?:记得|能否|是否|有没有)|再(?:说|补充|回忆)|哪(?:一|个|年|月|天)?|什么|多少|几(?:年|月|号|日)?|吗|呢)/u;
|
||||
const genericAcknowledgementPattern = /^(?:好的|明白了|知道了|收到|已记录|我记下了|我已按你的描述整理这轮线索)[。!!]?$/u;
|
||||
const groundedPublicReplyRequirement = "When the latest answer adds or refines a concrete event, the final public reply must: acknowledge the exact event and its date precision; summarize one to three evidence signals found in the user wording; use evidenceExplanation to map those signals to public method layers from dossier.capabilities.publicTechniqueCapabilities; distinguish a general method mapping from calculations actually present in tool observations; explain why the next question helps; and ask at most one question. Public method names such as D4, D24, Vimshottari, Narayana, UL, and A10 are allowed. Never expose internal ids, raw scores, weights, contribution matrices, raw tool traces, candidate minutes, or hidden reasoning. Never claim a numeric structural fact such as a division switching N times unless the dossier contains the matching window_sensitivity observation and publicExplanationGrounding cites its fact key.";
|
||||
|
||||
function containsExactMinute(value: string): boolean {
|
||||
return exactMinutePattern.test(value);
|
||||
@@ -65,18 +70,29 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
|
||||
return { candidateRank: cluster.rank, supportingEventIds: candidate?.supportingEventIds ?? [], conflictingEventIds: candidate?.conflictingEventIds ?? [] };
|
||||
}),
|
||||
},
|
||||
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxToolRounds: 10, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
|
||||
capabilities: {
|
||||
supportedDomains: domains,
|
||||
supportedEventKinds: kinds,
|
||||
publicTechniqueCapabilities: domains.map((domain) => ({ domain, techniqueLayers: [...domainScorerRegistry[domain].techniqueLayers] })),
|
||||
maxQuestionsPerTurn: 1,
|
||||
maxToolRounds: 10,
|
||||
forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "raw_technique_trace", "ungrounded_numeric_structure"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function latestGroundedEvent(dossier: RectificationCaseDossier, latestAnswer: string) {
|
||||
return [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary))) ?? null;
|
||||
}
|
||||
|
||||
function fallback(dossier: RectificationCaseDossier, latestAnswer: string): RectificationTurnPlan {
|
||||
if (dossier.candidateState.publicRangeAllowed && dossier.candidateState.currentSnapshotId) return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "offer_candidate_range", snapshotId: dossier.candidateState.currentSnapshotId }, publicReply: { acknowledgement: "现有事件已经完成本轮复核。", candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" } };
|
||||
if (dossier.candidateState.publicRangeAllowed && dossier.candidateState.currentSnapshotId) return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "offer_candidate_range", snapshotId: dossier.candidateState.currentSnapshotId }, publicReply: { acknowledgement: "现有事件已经完成本轮复核。", evidenceExplanation: null, candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" }, publicExplanationGrounding: [] };
|
||||
const targetEventId = dossier.interviewState.currentTargetEventId;
|
||||
const keepTarget = Boolean(targetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition));
|
||||
const targetEvent = keepTarget ? dossier.eventLedger.find((event) => event.eventId === targetEventId && event.status === "active") : null;
|
||||
const latestGroundedEvent = [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary)));
|
||||
const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !containsExactMinute(latestGroundedEvent.summary)
|
||||
? latestGroundedEvent.summary.slice(0, 120)
|
||||
const groundedEvent = latestGroundedEvent(dossier, latestAnswer);
|
||||
const safeSummary = groundedEvent && !privatePattern.test(groundedEvent.summary) && !containsExactMinute(groundedEvent.summary)
|
||||
? groundedEvent.summary.slice(0, 120)
|
||||
: null;
|
||||
const safeTargetSummary = targetEvent && !privatePattern.test(targetEvent.summary) && !containsExactMinute(targetEvent.summary)
|
||||
? `“${targetEvent.summary.slice(0, 120)}”`
|
||||
@@ -85,7 +101,27 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect
|
||||
const targetQuestion = targetNeedsMonth
|
||||
? `${safeTargetSummary}大概发生在哪个月,或一年中的哪个时间段?`
|
||||
: `关于${safeTargetSummary},你还记得更具体的时间或阶段吗?`;
|
||||
return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "ask_question", focus: { mode: keepTarget ? "clarify_existing_event" : "collect_independent_event", targetEventId: keepTarget ? targetEventId : null, domain: keepTarget ? targetEvent?.domain ?? null : null, requestedFacts: keepTarget ? [targetNeedsMonth ? "month" : "day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? targetQuestion : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } };
|
||||
const question = keepTarget ? targetQuestion : "你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。";
|
||||
const focus = keepTarget
|
||||
? { mode: "clarify_existing_event" as const, targetEventId, domain: targetEvent?.domain ?? null, requestedFacts: [targetNeedsMonth ? "month" as const : "day_or_period" as const], rationaleCodes: ["unresolved_current_event"] }
|
||||
: { mode: "collect_independent_event" as const, targetEventId: null, domain: null, requestedFacts: ["independent_event" as const, "year" as const], rationaleCodes: ["model_unavailable_neutral_fallback"] };
|
||||
const techniqueLayers = groundedEvent ? domainScorerRegistry[groundedEvent.domain].techniqueLayers : [];
|
||||
const evidenceExplanation = safeSummary && techniqueLayers.length
|
||||
? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")};这里只是在说明方法映射,尚未把它写成某个候选时间的计算结论。`
|
||||
: safeSummary ? "这条经历提供了可核对的时间和变化类型;目前只是整理证据,还不是候选时间的计算结论。" : null;
|
||||
return {
|
||||
contractVersion: "rectification-turn-plan-v1",
|
||||
targetDisposition: dossier.interviewState.targetDisposition,
|
||||
evidenceProposals: [],
|
||||
action: { type: "ask_question", focus, question, optionalQuickReplies: [] },
|
||||
publicReply: {
|
||||
acknowledgement: safeSummary ? `你提到的是“${safeSummary}”。` : latestAnswer.trim() ? "我会保留你刚才的原始说法,不补写你没有确认的信息。" : "我们先从真实经历建立事件线索。",
|
||||
evidenceExplanation,
|
||||
candidateCommentary: safeSummary ? "这条线索有明确时间,也说明了具体发生的变化,可以和其他独立经历交叉比较候选范围。" : null,
|
||||
limitation: null,
|
||||
},
|
||||
publicExplanationGrounding: groundedEvent ? [{ source: "capability_matrix", factKey: `domain:${groundedEvent.domain}` }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final" }>): Readonly<{ plan: RectificationTurnPlan | null; issues: readonly string[] }> {
|
||||
@@ -113,12 +149,31 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
if (plan.targetDisposition === "resolved" && !revisedCurrentTarget) issues.push("resolved_target_not_revised");
|
||||
if (plan.targetDisposition === "answered_other_event" && !createdOtherEvent) issues.push("other_event_not_proposed");
|
||||
}
|
||||
const publicText = [plan.publicReply.acknowledgement, plan.publicReply.candidateCommentary, plan.publicReply.limitation, plan.action.type === "ask_question" ? plan.action.question : null].filter(Boolean).join(" ");
|
||||
const publicText = [plan.publicReply.acknowledgement, plan.publicReply.evidenceExplanation, plan.publicReply.candidateCommentary, plan.publicReply.limitation, plan.action.type === "ask_question" ? plan.action.question : null].filter(Boolean).join(" ");
|
||||
if (privatePattern.test(publicText)) issues.push("private_detail_exposed");
|
||||
if (containsExactMinute(publicText)) issues.push("exact_minute_claimed");
|
||||
if (quantifiedStructurePattern.test(publicText)) {
|
||||
const windowFactKeys = new Set(plan.publicExplanationGrounding.filter((item) => item.source === "window_sensitivity").map((item) => item.factKey));
|
||||
const observedFactKeys = new Set(input.dossier.runtime.observations.flatMap((observation) => {
|
||||
if (observation.outcome !== "succeeded") return [];
|
||||
const result = observation.result;
|
||||
const direct = Object.keys(result);
|
||||
const nested = result.windowSensitivity && typeof result.windowSensitivity === "object" && !Array.isArray(result.windowSensitivity)
|
||||
? Object.keys(result.windowSensitivity as Record<string, unknown>)
|
||||
: [];
|
||||
return [...direct, ...nested];
|
||||
}));
|
||||
if (![...windowFactKeys].some((factKey) => observedFactKeys.has(factKey))) issues.push("ungrounded_numeric_structure_claim");
|
||||
}
|
||||
if (plan.action.type === "ask_question") {
|
||||
if (asksMultipleQuestions(plan.action.question)) issues.push("multiple_questions");
|
||||
if (input.phase === "final" && latestGroundedEvent(input.dossier, input.latestAnswer)) {
|
||||
if (genericAcknowledgementPattern.test(plan.publicReply.acknowledgement.trim())) issues.push("event_acknowledgement_generic");
|
||||
if (!plan.publicReply.evidenceExplanation || plan.publicReply.evidenceExplanation.trim().length < 12) issues.push("event_explanation_missing");
|
||||
if (!plan.publicReply.candidateCommentary || plan.publicReply.candidateCommentary.trim().length < 12) issues.push("event_value_commentary_missing");
|
||||
}
|
||||
if (plan.action.focus.targetEventId && !known.has(plan.action.focus.targetEventId)) issues.push("focus_target_invalid");
|
||||
if (plan.action.focus.domain && input.dossier.interviewState.declinedDomains.includes(plan.action.focus.domain)) issues.push("declined_domain_reopened");
|
||||
if (currentTarget && ["unresolved", "answered_other_event"].includes(plan.targetDisposition)
|
||||
&& plan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned");
|
||||
if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition)
|
||||
@@ -139,8 +194,10 @@ export async function regenerateDirectorQuestion(input: Readonly<{
|
||||
}>): Promise<string> {
|
||||
const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel();
|
||||
const agent = model ? new Agent({ id: `rectification-director-regenerate-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Rewrite one natural interview question while preserving the supplied structured focus. Do not expose internal ids, scores, tools, or a birth minute. Return only structured output." }) : null;
|
||||
const skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId: model?.id ?? null, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null }) : null;
|
||||
const generate = input.generateQuestion ?? (async (prompt: string) => {
|
||||
if (!agent) throw new Error("director_model_unavailable");
|
||||
if (!agent || !skillReady) throw new Error("director_model_unavailable");
|
||||
await skillReady;
|
||||
return agent.generate(prompt, { structuredOutput: { schema: regeneratedQuestionSchema, jsonPromptInjection: "inline" } });
|
||||
});
|
||||
const validate = (value: unknown) => {
|
||||
@@ -165,9 +222,11 @@ export async function regenerateDirectorQuestion(input: Readonly<{
|
||||
export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) {
|
||||
const started = Date.now();
|
||||
const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel();
|
||||
const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Direct the interview from the server-owned dossier and tool observations. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null;
|
||||
const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: `Direct the interview from the server-owned dossier and tool observations. Propose every explicit event in the latest answer, independently choose the current focus and wording from the full ledger, declined domains, candidate contrasts, and observations, and write the public reply plus at most one natural question. Do not follow a fixed domain rotation or treat examples, tests, or prior wording as a script. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. ${groundedPublicReplyRequirement} Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output.` }) : null;
|
||||
const skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId: model?.id ?? null, deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null }) : null;
|
||||
const generate = input.generatePlan ?? (async (prompt: string) => {
|
||||
if (!agent) throw new Error("director_model_unavailable");
|
||||
if (!agent || !skillReady) throw new Error("director_model_unavailable");
|
||||
await skillReady;
|
||||
return agent.generate(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 25_000), structuredOutput: { schema: rectificationTurnPlanSchema, jsonPromptInjection: "inline" } });
|
||||
});
|
||||
let inputTokens = 0, outputTokens = 0;
|
||||
@@ -209,7 +268,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
|
||||
return { round, tool: request.tool, diagnostic: request.diagnostic, outcome: "succeeded", result, dossierRevision: dossier.runtime.revision + 1, errorCode: null };
|
||||
};
|
||||
try {
|
||||
const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty. Use a read-only tool only when its observation can materially change the next action.", latestAnswer: input.latestAnswer, dossier: promptDossier() }), input.phase);
|
||||
const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty. Use a read-only tool only when its observation can materially change the next action.", publicReplyRequirement: input.phase === "final" ? groundedPublicReplyRequirement : undefined, latestAnswer: input.latestAnswer, dossier: promptDossier() }), input.phase);
|
||||
await addUsage(first);
|
||||
let candidate = rectificationTurnPlanSchema.parse(first.object);
|
||||
const observedTools = new Set<string>();
|
||||
@@ -235,6 +294,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
|
||||
toolCalls.push({ tool: request.tool, diagnostic: request.diagnostic, outcome: observation.outcome, durationMs: Date.now() - toolStarted, errorCode: observation.errorCode });
|
||||
const next = await generate(JSON.stringify({
|
||||
task: "Read the updated dossier and latest observation. Request another unobserved read-only tool only if it can materially change the interview strategy; otherwise converge to one final non-tool action. evidenceProposals must stay empty.",
|
||||
publicReplyRequirement: groundedPublicReplyRequirement,
|
||||
latestAnswer: input.latestAnswer,
|
||||
dossier: promptDossier(),
|
||||
latestObservation: observation,
|
||||
@@ -247,6 +307,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
|
||||
if (input.phase === "final" && requestedTool(candidate.action)) {
|
||||
const converged = await generate(JSON.stringify({
|
||||
task: "The tool loop has reached its convergence boundary. Return one safe final non-tool action now; do not request another tool and keep evidenceProposals empty.",
|
||||
publicReplyRequirement: groundedPublicReplyRequirement,
|
||||
latestAnswer: input.latestAnswer,
|
||||
dossier: promptDossier(),
|
||||
loopState: { round: toolCalls.length, maxRounds: dossier.capabilities.maxToolRounds, observedTools: [...observedTools], convergenceReason: convergenceReason ?? "tool_round_limit" },
|
||||
@@ -257,7 +318,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
|
||||
}
|
||||
let validated = validateRectificationTurnPlan({ plan: candidate, dossier, latestAnswer: input.latestAnswer, phase: input.phase });
|
||||
if (!validated.plan) {
|
||||
const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", latestAnswer: input.latestAnswer, dossier: promptDossier(), rejectedPlan: candidate, validationIssues: validated.issues }), "repair");
|
||||
const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", publicReplyRequirement: input.phase === "final" ? groundedPublicReplyRequirement : undefined, latestAnswer: input.latestAnswer, dossier: promptDossier(), rejectedPlan: candidate, validationIssues: validated.issues }), "repair");
|
||||
await addUsage(repaired);
|
||||
candidate = rectificationTurnPlanSchema.parse(repaired.object);
|
||||
if (requestedTool(candidate.action)) throw new Error("director_repair_requested_tool");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts";
|
||||
import { chronologicalEvents, latestEventRevisions } from "../rectification-v4/evidence-ledger.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
@@ -10,64 +10,6 @@ const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
|
||||
"invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace",
|
||||
];
|
||||
|
||||
const domainPolicy: Readonly<Record<Exclude<EvidenceDomain, "family" | "other">, Readonly<{
|
||||
goal: (anchor: string | null) => string;
|
||||
fallbackPrompt: (anchor: string | null) => string;
|
||||
recallCues: string;
|
||||
signals: RegExp;
|
||||
recallEase: number;
|
||||
privacyCost: number;
|
||||
}>>> = {
|
||||
education: {
|
||||
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件学习路径变化,用复读、转学、换专业、毕业或重要考试等非穷举线索,不预设一定发生。`,
|
||||
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件学习路径明显变化的经历,比如复读、转学、换专业、毕业或重要考试改变去向;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`,
|
||||
recallCues: "复读、转学、换专业、毕业或重要考试改变去向",
|
||||
signals: /大学|学校|入学|升学|毕业|考试|专业|读书|复读|转学/,
|
||||
recallEase: .82,
|
||||
privacyCost: .03,
|
||||
},
|
||||
relocation: {
|
||||
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件真正改变居住基地的经历,用搬家、住校或到另一座城市长期生活等非穷举线索,不把当前事件换词重问。`,
|
||||
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件真正改变居住地点的经历,比如独立搬家、住校或到另一座城市长期生活;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`,
|
||||
recallCues: "独立搬家、住校或到另一座城市长期生活",
|
||||
signals: /搬家|搬到|搬去|迁居|迁到|迁往|移居|定居|住校|长期居住|生活基地|离家|外地|异地/,
|
||||
recallEase: .78,
|
||||
privacyCost: .04,
|
||||
},
|
||||
relationship: {
|
||||
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件关系状态变化,用关系确立、分开、结婚或共同生活等非穷举线索,不预设一定发生。`,
|
||||
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果你愿意,有没有一件关系状态明显变化的经历,比如关系确立、分开、结婚或开始共同生活;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`,
|
||||
recallCues: "关系确立、分开、结婚或开始共同生活",
|
||||
signals: /恋爱|关系|结婚|离婚|分手|伴侣|对象|共同生活/,
|
||||
recallEase: .62,
|
||||
privacyCost: .22,
|
||||
},
|
||||
career: {
|
||||
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件工作状态变化,用第一次正式入职、离职、换岗、创业或职责增加等非穷举线索,不预设一定发生。`,
|
||||
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件工作状态明显变化的经历,比如第一次正式入职、离职、换岗、创业或职责明显增加;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`,
|
||||
recallCues: "第一次正式入职、离职、换岗、创业或职责明显增加",
|
||||
signals: /工作|实习|公司|研究院|职业|入职|离职|创业|负责|换岗|职责/,
|
||||
recallEase: .85,
|
||||
privacyCost: .03,
|
||||
},
|
||||
finance: {
|
||||
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件财务结构变化,用收入来源、负债、购房或重大投资等非穷举线索,不预设一定发生。`,
|
||||
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果方便,有没有一件财务结构明显变化的经历,比如收入来源改变、开始或还清大额负债、购房或重大投资;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`,
|
||||
recallCues: "收入来源改变、开始或还清大额负债、购房或重大投资",
|
||||
signals: /收入|负债|投资|资产|财务|买房|卖房|购房/,
|
||||
recallEase: .6,
|
||||
privacyCost: .18,
|
||||
},
|
||||
health_pressure: {
|
||||
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件本人健康或高压状态变化,用手术、住院、事故、确诊或明显恢复等非穷举线索,不预设一定发生。`,
|
||||
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果方便,你本人有没有一件健康或高压状态明显变化的经历,比如手术、住院、事故、确诊或明显恢复;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`,
|
||||
recallCues: "手术、住院、事故、确诊或明显恢复",
|
||||
signals: /住院|手术|事故|健康|生病|确诊|康复|高压|恢复/,
|
||||
recallEase: .58,
|
||||
privacyCost: .28,
|
||||
},
|
||||
};
|
||||
|
||||
function stableUuid(value: string): string {
|
||||
const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split("");
|
||||
hex[12] = "4";
|
||||
@@ -138,8 +80,9 @@ export function buildCandidateContrastPacket(input: Readonly<{
|
||||
const missingEvidence = (Object.entries(domainScorerRegistry) as [EvidenceDomain, (typeof domainScorerRegistry)[EvidenceDomain]][])
|
||||
.flatMap(([domain, policy]) => {
|
||||
if (!policy.techniqueLayers.some((layer) => discriminatingLayers.includes(layer))) return [];
|
||||
const eventKind = policy.supportedKinds.find((kind) => !existingKinds.has(kind));
|
||||
return eventKind ? [{ domain, eventKind, reason: "highest_candidate_separation" as const }] : [];
|
||||
return policy.supportedKinds
|
||||
.filter((eventKind) => !existingKinds.has(eventKind))
|
||||
.map((eventKind) => ({ domain, eventKind, reason: "highest_candidate_separation" as const }));
|
||||
});
|
||||
return {
|
||||
primaryClusterRank: input.snapshot?.clusters[0]?.rank ?? null,
|
||||
@@ -150,19 +93,6 @@ export function buildCandidateContrastPacket(input: Readonly<{
|
||||
};
|
||||
}
|
||||
|
||||
function contrastQuestion(eventKind: EventKind, anchor: string | null): Readonly<{ goal: string; fallbackPrompt: string }> | null {
|
||||
const prefix = anchor ? `在“${anchor}”之外,` : "";
|
||||
if (eventKind === "relationship_start") return {
|
||||
goal: `${prefix}在用户愿意的前提下,询问是否有一段关系正式确立或开始共同生活的经历及其大致年月,不预设一定发生。`,
|
||||
fallbackPrompt: `${prefix}如果你愿意,有没有一段关系正式确立或开始共同生活的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`,
|
||||
};
|
||||
if (eventKind === "relationship_change") return {
|
||||
goal: `${prefix}在用户愿意的前提下,询问是否有一段关系状态明显改变的经历及其大致年月,不预设一定发生。`,
|
||||
fallbackPrompt: `${prefix}如果你愿意,有没有一段关系状态明显改变的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildQuestionOpportunities(input: Readonly<{
|
||||
caseId: string;
|
||||
events: readonly LifeEventRevision[];
|
||||
@@ -180,7 +110,6 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
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[] = [];
|
||||
const contrastPacket = buildCandidateContrastPacket(input);
|
||||
|
||||
@@ -266,40 +195,37 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
}
|
||||
|
||||
const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length;
|
||||
for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude<EvidenceDomain, "family" | "other">, (typeof domainPolicy)[Exclude<EvidenceDomain, "family" | "other">]][]) {
|
||||
if (refusedDomains.has(domain)) continue;
|
||||
const covered = scoreableDomains.has(domain);
|
||||
const latestEventText = latestEvent ? `${latestEvent.summary} ${latestEvent.rawText}` : latestContext;
|
||||
const semanticOverlap = Boolean(latestEvent && latestEvent.domain !== domain && policy.signals.test(latestEventText));
|
||||
const latestDomainContinuity = latestEvent?.domain === domain ? .22 : 0;
|
||||
const pendingThemeBonus = !latestEvent && policy.signals.test(latestContext) ? .12 : 0;
|
||||
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
|
||||
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
|
||||
const contrastEvidence = contrastPacket?.missingEvidence.find((item) => item.domain === domain) ?? null;
|
||||
const targetedQuestion = contrastEvidence ? contrastQuestion(contrastEvidence.eventKind, latestAnchor) : null;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "ask_new_event", domain, targetEventId: null, goal: targetedQuestion?.goal ?? policy.goal(latestAnchor),
|
||||
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
|
||||
contextFacts: [
|
||||
`已有 ${scoreableCount} 件可评分事件。`,
|
||||
`该领域${covered ? "已有覆盖" : "尚未覆盖"}。`,
|
||||
`可使用${policy.recallCues}作为非穷举回忆线索。`,
|
||||
"这是存在性询问,不得假定用户一定经历过该事件。",
|
||||
"只询问一件带大致年月的新事件,不要求用户逐项回答例子。",
|
||||
"允许用户回答没有、记不清、不想回答或换方向。",
|
||||
"不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。",
|
||||
...(contrastEvidence ? ["该类证据对当前候选区分力最高,应优先确认是否存在。"] : []),
|
||||
...(semanticOverlap ? ["该领域与最新事件语义重叠,必须降低优先级,避免把同一经历换词重问。"] : []),
|
||||
],
|
||||
fallbackPrompt: targetedQuestion?.fallbackPrompt ?? policy.fallbackPrompt(latestAnchor), reason: contrastEvidence ? "补足当前候选分离所需的关键证据。" : covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
|
||||
expectedInformationGain: contrastEvidence ? .95 : covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus,
|
||||
dateSensitivity: input.snapshot ? .5 : .35,
|
||||
candidateSplitRelevance: contrastEvidence ? .98 : input.diagnostics?.candidateSplits.length ? .58 : .42,
|
||||
domainCoverageGain: contrastEvidence ? 1 : covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
|
||||
recallEase: policy.recallEase, novelty: contrastEvidence ? .95 : semanticOverlap ? .45 : alreadyAsked ? .35 : .9,
|
||||
repetitionPenalty: contrastEvidence ? 0 : (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost,
|
||||
}, contrastEvidence ? .08 : 0));
|
||||
}
|
||||
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
|
||||
const contrastEvidence = contrastPacket?.missingEvidence.filter((item) => !refusedDomains.has(item.domain)) ?? [];
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "ask_new_event",
|
||||
domain: "other",
|
||||
targetEventId: null,
|
||||
goal: "根据完整事件账本、用户拒答记录和候选差异,自主选择最有区分力且不重复的经历方向,再自然询问一件大致时间明确的新经历;不要按固定领域顺序轮询。",
|
||||
requestedFields: ["new_dated_event"],
|
||||
anchors: latestAnchor ? [latestAnchor] : [],
|
||||
contextFacts: [
|
||||
`已有 ${scoreableCount} 件可评分事件。`,
|
||||
`已覆盖领域:${[...scoreableDomains].sort().join(", ") || "无"}。`,
|
||||
`已拒绝领域:${[...refusedDomains].sort().join(", ") || "无"}。`,
|
||||
...contrastEvidence.map((item) => `候选差异诊断建议优先考虑 ${item.domain}/${item.eventKind} 类型的独立证据;这是策略线索,不是必须照抄的公开问题。`),
|
||||
"由 Agent 自主决定下一方向和措辞,不使用预写领域问题、关键词命中或测试样例作为脚本。",
|
||||
"这是存在性询问,不得假定用户一定经历过该事件。",
|
||||
"只询问一件带大致时间的新事件,不要求用户逐项回答例子。",
|
||||
"允许用户回答没有、记不清、不想回答或换方向。",
|
||||
"不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。",
|
||||
],
|
||||
fallbackPrompt: `${latestAnchor ? `在“${latestAnchor}”之外,` : ""}你愿意再讲一件与已有经历不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。`,
|
||||
reason: contrastEvidence.length ? "候选差异仍需要新的独立证据,由 Agent 决定最有价值的询问方向。" : "仍需要一件与现有记录不同的独立事件,由 Agent 决定询问方向。",
|
||||
expectedInformationGain: contrastEvidence.length ? .9 : .65,
|
||||
dateSensitivity: input.snapshot ? .5 : .35,
|
||||
candidateSplitRelevance: contrastEvidence.length ? .95 : input.diagnostics?.candidateSplits.length ? .58 : .42,
|
||||
domainCoverageGain: scoreableDomains.size < 2 ? 1 : .15,
|
||||
recallEase: .7,
|
||||
novelty: .9,
|
||||
repetitionPenalty: 0,
|
||||
privacyCost: 0,
|
||||
}, contrastEvidence.length ? .08 : 0));
|
||||
|
||||
return opportunities
|
||||
.sort((left, right) => right.utility - left.utility || left.opportunityId.localeCompare(right.opportunityId))
|
||||
|
||||
@@ -84,6 +84,15 @@ type AnalysisPhase = keyof typeof analysisPhaseLabels;
|
||||
|
||||
const closedTargetDispositions = new Set<TargetDisposition>(["unknown", "declined", "direction_change"]);
|
||||
|
||||
export function composeRectificationPublicTurn(message: Pick<StoredPublicMessage, "acknowledgement" | "evidenceExplanation" | "candidateUpdate" | "limitation" | "question">): string {
|
||||
const question = message.question?.trim() ?? "";
|
||||
const context = [message.acknowledgement, message.evidenceExplanation, message.candidateUpdate, message.limitation].filter((part): part is string => Boolean(part?.trim())).join("\n\n");
|
||||
if (!question) return context.slice(0, 1_000);
|
||||
const availableContext = Math.max(0, 1_000 - question.length - 2);
|
||||
const prefix = context.slice(0, availableContext).trim();
|
||||
return prefix ? `${prefix}\n\n${question}` : question;
|
||||
}
|
||||
|
||||
export function mergeDirectorReconciliation(input: Readonly<{
|
||||
server: ReconciledV4Evidence;
|
||||
staged: ReconciledV4Evidence;
|
||||
@@ -413,6 +422,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
});
|
||||
const publicMessage: StoredPublicMessage = {
|
||||
acknowledgement: plan.publicReply.acknowledgement,
|
||||
evidenceExplanation: plan.publicReply.evidenceExplanation,
|
||||
candidateUpdate: candidateUpdateFor({ snapshot, previousSnapshot: claimed.case.latestSnapshot, decisionAction: decision.action }) ?? plan.publicReply.candidateCommentary,
|
||||
limitation: plan.publicReply.limitation,
|
||||
question: action.type === "ask_question" ? action.question : null,
|
||||
@@ -428,7 +438,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
id: randomUUID(),
|
||||
domain: action.focus.domain ?? targetEvent?.domain ?? "other",
|
||||
targetEventId: action.focus.targetEventId,
|
||||
prompt: action.question,
|
||||
prompt: composeRectificationPublicTurn(publicMessage),
|
||||
recallCost: "medium",
|
||||
reason: action.focus.rationaleCodes.join(",").slice(0, 240) || "agent_directed_focus",
|
||||
} : null;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { assertRectificationSkillLoaded } from "./skill-runtime.ts";
|
||||
import {
|
||||
rectificationDecisionSchema,
|
||||
rectificationDiagnosticSchema,
|
||||
@@ -191,8 +192,10 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
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 skillReady = agent ? assertRectificationSkillLoaded(agent, { caseId: input.caseValue.id, modelId, deploymentSha }) : null;
|
||||
const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => {
|
||||
if (!agent) throw new Error("reasoner_model_unavailable");
|
||||
if (!agent || !skillReady) throw new Error("reasoner_model_unavailable");
|
||||
await skillReady;
|
||||
let reasoningSummary = "";
|
||||
const stream = await agent.stream(prompt, {
|
||||
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts";
|
||||
import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
import { assertRectificationSkillLoaded } from "./skill-runtime.ts";
|
||||
|
||||
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
|
||||
const agents = new Map<string, Agent>();
|
||||
@@ -15,16 +16,6 @@ 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<Partial<Record<QuestionOpportunity["domain"], RegExp>>> = {
|
||||
education: /(?:入学|升学|毕业|学校|大学|专业|考试|读书)/,
|
||||
relocation: /(?:搬家|搬到|搬去|迁居|迁到|迁往|移居|定居)/,
|
||||
relationship: /(?:恋爱|关系|结婚|离婚|分手|伴侣|对象)/,
|
||||
career: /(?:工作|实习|公司|研究院|职业|入职|离职|创业|职责|负责)/,
|
||||
finance: /(?:收入|负债|投资|资产|财务|买房|卖房)/,
|
||||
health_pressure: /(?:住院|手术|事故|健康|生病|确诊|康复)/,
|
||||
};
|
||||
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
|
||||
const openingMessageSchema = z.object({ message: z.string().trim().min(1).max(1_000) }).strict();
|
||||
const fixedChoiceStructure = /(?:从|在)[^。!??\n]{1,80}(?:、|,|,|或|或者)[^。!??\n]{1,80}(?:(?:中|里|方面)(?:选|选择|挑|说|讲|开始)|(?:选|选择|挑)(?:一|1)?(?:个|件|段))|按[^。!??\n]{1,80}(?:依次|逐一|分别)(?:回答|说|讲)/;
|
||||
@@ -75,8 +66,10 @@ export async function generateOpeningQuestion(input: Readonly<{
|
||||
const modelId = selected?.id ?? input.modelId;
|
||||
const started = Date.now();
|
||||
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null;
|
||||
const skillReady = selected ? assertRectificationSkillLoaded(selected.agent, { caseId: input.caseId, modelId, deploymentSha }) : null;
|
||||
const generate = input.generate ?? (async (prompt: string) => {
|
||||
if (!selected) throw new Error("opening_model_unavailable");
|
||||
if (!selected || !skillReady) throw new Error("opening_model_unavailable");
|
||||
await skillReady;
|
||||
return selected.agent.generate(prompt, {
|
||||
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000),
|
||||
structuredOutput: { schema: openingMessageSchema, jsonPromptInjection: "inline" },
|
||||
@@ -113,34 +106,11 @@ function normalized(value: string): string {
|
||||
return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, "");
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -161,23 +131,6 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques
|
||||
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 };
|
||||
}
|
||||
@@ -220,6 +173,7 @@ function deterministic(input: {
|
||||
}): PublicMessage {
|
||||
return {
|
||||
acknowledgement: naturalAcknowledgement(input),
|
||||
evidenceExplanation: null,
|
||||
candidateUpdate: candidateUpdateFor({ snapshot: input.snapshot, previousSnapshot: input.previousSnapshot, decisionAction: input.validated.decision.action }),
|
||||
limitation: input.validated.decision.action === "stop_low_confidence"
|
||||
? "现有证据不足以安全缩小范围,我会在这里停下,不把不稳定结果包装成确定时间。"
|
||||
@@ -244,6 +198,7 @@ export function realizePublicMessage(value: unknown, input: Parameters<typeof de
|
||||
: null;
|
||||
return {
|
||||
acknowledgement,
|
||||
evidenceExplanation: fallback.evidenceExplanation,
|
||||
candidateUpdate: fallback.candidateUpdate,
|
||||
limitation: fallback.limitation ?? (parsed.limitation && visibleTextSafetyIssues(parsed.limitation).length === 0 ? parsed.limitation : null),
|
||||
question,
|
||||
@@ -275,6 +230,7 @@ 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 {
|
||||
await assertRectificationSkillLoaded(selected.agent, { caseId: input.caseValue.id, modelId: selected.id, deploymentSha });
|
||||
const opportunity = input.validated.selectedOpportunity;
|
||||
const result = await selected.agent.generate(JSON.stringify({
|
||||
task: "Render one public turn and naturally realize the semantic question contract.",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "./contracts.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
|
||||
const skillName = "birth-time-rectification";
|
||||
|
||||
type SkillAgent = Readonly<{ getSkill(name: string): Promise<unknown> }>;
|
||||
|
||||
export async function assertRectificationSkillLoaded(
|
||||
agent: SkillAgent,
|
||||
input: Readonly<{ caseId: string; modelId: string | null; deploymentSha: string | null }>,
|
||||
): Promise<void> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
if (!await agent.getSkill(skillName)) throw new Error("missing");
|
||||
recordRectificationAgentTelemetry({
|
||||
caseId: input.caseId, phase: "skill", outcome: "succeeded", modelId: input.modelId,
|
||||
toolName: null, decisionAction: null, durationMs: Date.now() - started,
|
||||
errorCode: null, deploymentSha: input.deploymentSha,
|
||||
skillName, skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION,
|
||||
promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, loadStatus: "loaded",
|
||||
});
|
||||
} catch {
|
||||
recordRectificationAgentTelemetry({
|
||||
caseId: input.caseId, phase: "skill", outcome: "failed", modelId: input.modelId,
|
||||
toolName: null, decisionAction: null, durationMs: Date.now() - started,
|
||||
errorCode: "rectification_skill_not_loaded", deploymentSha: input.deploymentSha,
|
||||
skillName, skillVersion: CURRENT_RECTIFICATION_SKILL_VERSION,
|
||||
promptVersion: CURRENT_RECTIFICATION_PROMPT_VERSION, loadStatus: "failed",
|
||||
});
|
||||
throw new Error("rectification_skill_not_loaded");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const telemetryEventSchema = z.object({
|
||||
caseId: z.string().uuid().nullable(),
|
||||
phase: z.enum(["reasoner", "renderer", "tool", "fallback"]),
|
||||
phase: z.enum(["reasoner", "renderer", "tool", "fallback", "skill"]),
|
||||
outcome: z.enum(["started", "succeeded", "failed", "rejected"]),
|
||||
modelId: z.string().trim().min(1).max(120).nullable(),
|
||||
toolName: z.string().trim().min(1).max(120).nullable(),
|
||||
@@ -10,6 +10,10 @@ const telemetryEventSchema = z.object({
|
||||
durationMs: z.number().int().min(0).max(300_000).nullable(),
|
||||
errorCode: z.string().trim().min(1).max(120).nullable(),
|
||||
deploymentSha: z.string().trim().min(1).max(80).nullable(),
|
||||
skillName: z.string().trim().min(1).max(120).optional(),
|
||||
skillVersion: z.string().trim().min(1).max(120).optional(),
|
||||
promptVersion: z.string().trim().min(1).max(120).optional(),
|
||||
loadStatus: z.enum(["loaded", "failed"]).optional(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationAgentTelemetryEvent = z.infer<typeof telemetryEventSchema>;
|
||||
|
||||
@@ -32,11 +32,11 @@ export function createRectificationV4CaseService(
|
||||
const generateOpening = options.generateOpeningQuestion ?? generateOpeningQuestion;
|
||||
|
||||
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
|
||||
const [events, turns, analysis, job] = await Promise.all([
|
||||
const [events, turns, assistantResponses, job] = await Promise.all([
|
||||
store.loadEvents(userId, caseValue.id),
|
||||
store.loadTurns(userId, caseValue.id),
|
||||
caseValue.deploymentMode === "v5_agent"
|
||||
? store.loadAnalysisMessages(userId, caseValue.id)
|
||||
? store.loadAssistantResponses(userId, caseValue.id)
|
||||
: Promise.resolve([]),
|
||||
jobId
|
||||
? store.loadJob(userId, jobId)
|
||||
@@ -47,7 +47,8 @@ export function createRectificationV4CaseService(
|
||||
job,
|
||||
events: [...events],
|
||||
turns: [...turns],
|
||||
analysis: [...analysis],
|
||||
analysis: assistantResponses.flatMap((item) => item.trace ? [{ sourceTurnId: item.sourceTurnId, trace: item.trace }] : []),
|
||||
assistantResponses: [...assistantResponses],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class RectificationV4RequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function json<T>(response: Response, schema: z.ZodType<T>): Promise<T> {
|
||||
async function json<T>(response: Response, schema: z.ZodType<T, z.ZodTypeDef, unknown>): Promise<T> {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new RectificationV4RequestError(
|
||||
|
||||
@@ -348,12 +348,29 @@ export const rectificationAnalysisItemSchema = z.object({
|
||||
}).strict();
|
||||
export type RectificationAnalysisItem = z.infer<typeof rectificationAnalysisItemSchema>;
|
||||
|
||||
export const rectificationAssistantMessageSchema = z.object({
|
||||
acknowledgement: z.string().trim().min(1).max(1_000),
|
||||
evidenceExplanation: z.string().trim().min(1).max(1_600).nullable().default(null),
|
||||
candidateUpdate: z.string().trim().min(1).max(1_000).nullable(),
|
||||
limitation: z.string().trim().min(1).max(1_000).nullable(),
|
||||
question: z.string().trim().min(1).max(1_000).nullable(),
|
||||
}).strict();
|
||||
export type RectificationAssistantMessage = z.infer<typeof rectificationAssistantMessageSchema>;
|
||||
|
||||
export const rectificationAssistantResponseSchema = z.object({
|
||||
sourceTurnId: z.string().uuid(),
|
||||
message: rectificationAssistantMessageSchema,
|
||||
trace: rectificationAnalysisTraceSchema.nullable(),
|
||||
}).strict();
|
||||
export type RectificationAssistantResponse = z.infer<typeof rectificationAssistantResponseSchema>;
|
||||
|
||||
export const rectificationV4ApiResponseSchema = z.object({
|
||||
case: rectificationV4CaseSchema,
|
||||
job: rectificationV4JobSchema.nullable(),
|
||||
events: z.array(lifeEventRevisionSchema),
|
||||
turns: z.array(rectificationV4TurnSchema),
|
||||
analysis: z.array(rectificationAnalysisItemSchema).optional(),
|
||||
assistantResponses: z.array(rectificationAssistantResponseSchema).optional(),
|
||||
}).strict();
|
||||
export type RectificationV4ApiResponse = z.infer<typeof rectificationV4ApiResponseSchema>;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export function projectLegacyV4Turn(input: Readonly<{
|
||||
: input.latestAnswer
|
||||
? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。"
|
||||
: "我会继续根据已确认的人生事件比较候选范围。",
|
||||
evidenceExplanation: null,
|
||||
candidateUpdate: primary
|
||||
? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。`
|
||||
: null,
|
||||
|
||||
@@ -76,6 +76,16 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
.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 loadAssistantResponses(userId, caseId) {
|
||||
owned(userId, caseId);
|
||||
return [...jobs.values()]
|
||||
.filter((job) => job.caseId === caseId && publicMessages.has(job.id))
|
||||
.sort((left, right) => turns.get(left.turnId)!.caseVersion - turns.get(right.turnId)!.caseVersion)
|
||||
.map((job) => {
|
||||
const { analysisTrace, ...message } = publicMessages.get(job.id)!;
|
||||
return { sourceTurnId: job.turnId, message, trace: analysisTrace ?? null };
|
||||
});
|
||||
},
|
||||
async loadLatestValidatedDecision(userId, caseId) {
|
||||
const caseValue = cases.get(caseId);
|
||||
if (!caseValue || caseValue.userId !== userId) return null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LifeEventRevision,
|
||||
PendingEvidence,
|
||||
RectificationAnalysisItem,
|
||||
RectificationAssistantResponse,
|
||||
RectificationV4Case,
|
||||
RectificationV4Job,
|
||||
RectificationV4Phase,
|
||||
@@ -59,6 +60,7 @@ export interface RectificationV4Store {
|
||||
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
|
||||
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
|
||||
loadAnalysisMessages(userId: string, caseId: string): Promise<readonly RectificationAnalysisItem[]>;
|
||||
loadAssistantResponses(userId: string, caseId: string): Promise<readonly RectificationAssistantResponse[]>;
|
||||
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>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
lifeEventRevisionSchema,
|
||||
pendingEvidenceSchema,
|
||||
rectificationAnalysisItemSchema,
|
||||
rectificationAssistantResponseSchema,
|
||||
rectificationV4CaseSchema,
|
||||
rectificationV4JobSchema,
|
||||
rectificationV4TurnSchema,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
type LifeEventRevision,
|
||||
type PendingEvidence,
|
||||
type RectificationAnalysisItem,
|
||||
type RectificationAssistantResponse,
|
||||
type RectificationV4Case,
|
||||
type RectificationV4Job,
|
||||
type RectificationV4Turn,
|
||||
@@ -26,24 +28,35 @@ import { evidenceSetHash, rectificationFingerprint } from "./fingerprints.ts";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export function projectAnalysisMessages(
|
||||
export function projectAssistantResponses(
|
||||
publicMessageRows: readonly Readonly<Row>[],
|
||||
jobRows: readonly Readonly<Row>[],
|
||||
): readonly RectificationAnalysisItem[] {
|
||||
): readonly RectificationAssistantResponse[] {
|
||||
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({
|
||||
if (!message.success) return [];
|
||||
const { analysisTrace, ...publicMessage } = message.data;
|
||||
const item = rectificationAssistantResponseSchema.safeParse({
|
||||
sourceTurnId: turnByJob.get(String(row.job_id)),
|
||||
trace: message.data.analysisTrace,
|
||||
message: publicMessage,
|
||||
trace: analysisTrace ?? null,
|
||||
});
|
||||
return item.success ? [item.data] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function projectAnalysisMessages(
|
||||
publicMessageRows: readonly Readonly<Row>[],
|
||||
jobRows: readonly Readonly<Row>[],
|
||||
): readonly RectificationAnalysisItem[] {
|
||||
return projectAssistantResponses(publicMessageRows, jobRows).flatMap((item) => item.trace
|
||||
? [rectificationAnalysisItemSchema.parse({ sourceTurnId: item.sourceTurnId, trace: item.trace })]
|
||||
: []);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): string {
|
||||
return value instanceof Date ? value.toISOString() : String(value);
|
||||
}
|
||||
@@ -234,7 +247,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
return ((data ?? []) as Row[]).map(pendingEvidenceValue);
|
||||
}
|
||||
|
||||
async function loadAnalysisMessagesByCase(userId: string, caseId: string): Promise<readonly RectificationAnalysisItem[]> {
|
||||
async function loadAssistantResponsesByCase(userId: string, caseId: string): Promise<readonly RectificationAssistantResponse[]> {
|
||||
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)
|
||||
@@ -246,7 +259,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
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[]);
|
||||
return projectAssistantResponses(rows, (jobData ?? []) as Row[]);
|
||||
}
|
||||
|
||||
async function rpc(name: string, args: Row): Promise<unknown> {
|
||||
@@ -266,7 +279,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
loadCase: loadCaseById,
|
||||
loadEvents: loadEventsByCase,
|
||||
loadTurns: loadTurnsByCase,
|
||||
loadAnalysisMessages: loadAnalysisMessagesByCase,
|
||||
async loadAnalysisMessages(userId, caseId) {
|
||||
return (await loadAssistantResponsesByCase(userId, caseId)).flatMap((item) => item.trace
|
||||
? [{ sourceTurnId: item.sourceTurnId, trace: item.trace }]
|
||||
: []);
|
||||
},
|
||||
loadAssistantResponses: loadAssistantResponsesByCase,
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user