fix(rectification): enforce director evidence boundaries

This commit is contained in:
Jesse_Chen
2026-07-31 06:13:47 +08:00
parent 77b0079653
commit bab5647afc
21 changed files with 795 additions and 98 deletions
+15
View File
@@ -1834,3 +1834,18 @@
- 防复发:Revision 测试必须同时覆盖合法日期更正和跨事件覆盖拒绝,不能只断言 Target ID 存在。
- 相关记录:BUG-101、BUG-102
- 修复版本:local follow-up
## BUG-104 | Director 可覆盖拒答、日期简答失效、Pending 误关闭与旧候选快照越权
- 状态:resolved
- 首次发现:2026-07-30
- 最近更新:2026-07-30
- 影响面:V5 Agent Director/Orchestrator、事件 Revision、Pending Evidence 生命周期、Event Kind 评分边界、公开候选范围与回复安全校验
- 用户现象:明确的“不想说/记不清/换一个”可能被模型覆盖回未解决并继续追问;仅回答月份、日期或时间段无法修订当前事件;自然纠正事件类型或人物会失败;历史 Pending Evidence 可能永久残留或被无关 Revision 错误关闭;旧 `relationship_end` 评分快照仍可能公开或接受;技术层名称可能出现在公开回复。
- 根因:模型处置优先级高于服务器确定性关闭状态;日期 Revision 复用了创建事件的完整语义要求;修订合同没有区分日期修订与重新分类;Pending completion 没有原子关闭合同,也未按缺口类型验证修订;`relationship_end` 缺少独立评分规则却保留旧 scoreable Snapshot;公开文本过滤只覆盖部分技术层。
- 修复:服务器关闭状态不可被 Director 覆盖,且关闭后禁止用空 Target ID 的澄清/冲突 Focus 重开原事件;Evidence operation 拆为 `create/revise_date/reclassify/ignore`,日期简答继承 Target 身份与缺失年份,显式纠正追加同 Event ID Revision,人物变化进入 `pending_review`;V5 completion 增加 ownership/target/replay 安全的 Pending resolution,并且 `date_unresolved` 只在日期确实变化后关闭;`relationship_end` 强制 `pending_review`,迁移清除由旧 scoreable 关系结束事件支持的最新 Snapshot,Dossier 与 acceptRange 双重拒绝旧快照;公开回复禁止全部 D-number 技术层、KP、Vimshottari、Narayana、Shadbala 与 Ashtakavarga,只允许公开已批准 Snapshot 的首个范围 Cluster。
- 验证:Director、V6 Agent、V4 Domain/Service/Migration 与 Python Event Engine 回归覆盖明确拒答、空 Target ID 绕过、日期简答、事件重新分类、Pending 原子关闭与原因匹配、旧快照失效、技术层泄漏、多事件提取和 Event Kind trace;真实 PostgreSQL migration 应用测试通过。
- 安全边界:服务器继续拥有事实验证、事件身份、Scoreability、候选范围和持久化权限;Agent 只提出结构化计划。禁止公开内部 ID、评分、技术 trace、代表分钟或第二 Cluster,禁止自动写入 Profile 出生时间。
- 防复发:拒答保护必须有 Orchestrator/Director 级回归;Pending resolution 必须验证缺口已被对应 Revision 补齐;评分政策变化必须同时处理历史 Snapshot;公开技术层过滤按完整技术命名空间测试。
- 相关记录:BUG-099、BUG-102、BUG-103
- 修复版本:`birth-time-rectification-v6` / `rectification-director-v2`
@@ -28,7 +28,7 @@ export const targetDispositionSchema = z.enum([
]);
export const evidenceProposalSchema = z.object({
operation: z.enum(["create", "revise", "ignore"]),
operation: z.enum(["create", "revise_date", "reclassify", "ignore"]),
targetEventId: uuid.nullable(),
sourceSpan: nonblank(4_000),
dateText: nonblank(80).nullable(),
@@ -4,13 +4,26 @@ import { z } from "zod";
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 { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } from "./contracts.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)/iu;
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 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;
function containsExactMinute(value: string): boolean {
return exactMinutePattern.test(value);
}
function asksMultipleQuestions(value: string): boolean {
if ((value.match(/[??]/g) ?? []).length > 1) return true;
return value.split(/[。;;!!\n]+|[,,]\s*(?=(?:请|你|再))/u)
.filter((part) => questionClausePattern.test(part))
.length > 1;
}
const declinedPattern = /(?:不想说|不方便说|不想回答|跳过|这个不说|换个方向|不聊这个)/u;
type Generated = Readonly<{ object: unknown; totalUsage?: { inputTokens?: number; outputTokens?: number } | Promise<{ inputTokens?: number; outputTokens?: number }> }>;
const regeneratedQuestionSchema = z.object({ question: z.string().trim().min(8).max(500) }).strict();
@@ -36,12 +49,13 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
const latest = new Map<string, number>();
input.events.forEach((event) => latest.set(event.eventId, Math.max(latest.get(event.eventId) ?? 0, event.revision)));
const recent = input.turns.slice(-12);
const publicRangeAllowed = Boolean(input.snapshot?.canAcceptRange) && !hasPolicyInvalidScoreableEvents(input.events);
return rectificationCaseDossierSchema.parse({
case: { candidateWindow: input.caseValue.calculationSpec.candidateRange, birthDate: input.caseValue.calculationSpec.birthDate, location: { latitude: input.caseValue.calculationSpec.latitude, longitude: input.caseValue.calculationSpec.longitude, timezoneId: input.caseValue.calculationSpec.timezoneId ?? null, timezoneOffsetHours: input.caseValue.calculationSpec.timezoneOffsetHours }, birthTimeSource: input.caseValue.calculationSpec.birthTimeSource ?? null, algorithmVersion: input.caseValue.algorithmVersion },
conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: summarizeEarlierTurns(input.turns) },
eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })),
interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [...new Set(input.turns.flatMap((turn) => turn.questionDomain && declinedPattern.test(turn.answer) ? [turn.questionDomain] : []))], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], pendingEvidence: (input.pendingEvidence ?? []).filter((item) => !item.resolvedAt).map(({ rawText, reasonCode, targetEventId, createdAt }) => ({ rawText, reasonCode, targetEventId, createdAt })), askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition },
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed: input.snapshot?.canAcceptRange ?? false, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: input.snapshot?.canAcceptRange ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 2, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
});
}
@@ -50,7 +64,7 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect
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: "这仍不是对某个精确出生分钟的确认。" } };
const keepTarget = Boolean(dossier.interviewState.currentTargetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition));
const latestGroundedEvent = [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary)));
const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !exactMinutePattern.test(latestGroundedEvent.summary)
const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !containsExactMinute(latestGroundedEvent.summary)
? latestGroundedEvent.summary.slice(0, 120)
: null;
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 ? dossier.interviewState.currentTargetEventId : null, domain: null, requestedFacts: keepTarget ? ["day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? "关于刚才那件事,你还记得它大约发生在哪一年或哪个阶段吗?" : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } };
@@ -66,7 +80,7 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
if (!input.latestAnswer.includes(proposal.sourceSpan)) issues.push("evidence_source_not_in_latest_answer");
if (proposal.dateText && !input.latestAnswer.includes(proposal.dateText)) issues.push("evidence_date_not_in_latest_answer");
if (proposal.operation === "create" && proposal.targetEventId) issues.push("create_must_not_target_event");
if (proposal.operation === "revise" && (!proposal.targetEventId || !known.has(proposal.targetEventId))) issues.push("revision_target_invalid");
if ((proposal.operation === "revise_date" || proposal.operation === "reclassify") && (!proposal.targetEventId || !known.has(proposal.targetEventId))) issues.push("revision_target_invalid");
});
const currentTarget = input.dossier.interviewState.currentTargetEventId;
if (input.phase === "final") {
@@ -75,7 +89,7 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
} else if (!currentTarget && plan.targetDisposition !== "not_applicable") {
issues.push("target_disposition_requires_target");
} else if (currentTarget) {
const revisedCurrentTarget = plan.evidenceProposals.some((proposal) => proposal.operation === "revise" && proposal.targetEventId === currentTarget);
const revisedCurrentTarget = plan.evidenceProposals.some((proposal) => (proposal.operation === "revise_date" || proposal.operation === "reclassify") && proposal.targetEventId === currentTarget);
const createdOtherEvent = plan.evidenceProposals.some((proposal) => proposal.operation === "create");
if (plan.targetDisposition === "not_applicable") issues.push("target_disposition_missing");
if (plan.targetDisposition === "resolved" && !revisedCurrentTarget) issues.push("resolved_target_not_revised");
@@ -83,11 +97,13 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
}
const publicText = [plan.publicReply.acknowledgement, 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 (exactMinutePattern.test(publicText)) issues.push("exact_minute_claimed");
if (containsExactMinute(publicText)) issues.push("exact_minute_claimed");
if (plan.action.type === "ask_question") {
if ((plan.action.question.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions");
if (asksMultipleQuestions(plan.action.question)) issues.push("multiple_questions");
if (plan.action.focus.targetEventId && !known.has(plan.action.focus.targetEventId)) issues.push("focus_target_invalid");
if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition) && plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId) issues.push("declined_target_reopened");
if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition)
&& (plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId
|| ["clarify_existing_event", "resolve_conflict"].includes(plan.action.focus.mode))) issues.push("declined_target_reopened");
}
if (plan.action.type === "offer_candidate_range" && (!input.dossier.candidateState.publicRangeAllowed || plan.action.snapshotId !== input.dossier.candidateState.currentSnapshotId)) issues.push("candidate_range_gate_failed");
return { plan: issues.length ? null : plan, issues };
@@ -111,9 +127,9 @@ export async function regenerateDirectorQuestion(input: Readonly<{
const parsed = regeneratedQuestionSchema.safeParse(value);
if (!parsed.success) return { question: null, issues: ["question_schema_invalid"] };
const issues: string[] = [];
if ((parsed.data.question.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions");
if (asksMultipleQuestions(parsed.data.question)) issues.push("multiple_questions");
if (privatePattern.test(parsed.data.question)) issues.push("private_detail_exposed");
if (exactMinutePattern.test(parsed.data.question)) issues.push("exact_minute_claimed");
if (containsExactMinute(parsed.data.question)) issues.push("exact_minute_claimed");
return { question: issues.length ? null : parsed.data.question, issues };
};
try {
@@ -3,8 +3,9 @@ import type { CandidateEngineResult, RectificationV4CandidateEngine } from "../r
import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.ts";
import type { CandidateSnapshot, RectificationAnalysisTrace, RectificationV4Question } from "../rectification-v4/contracts.ts";
import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts";
import { reconcileV4Evidence, stageAgentEvidenceProposals } from "../rectification-v4/extraction.ts";
import { reconcileV4Evidence, stageAgentEvidenceProposals, type ReconciledV4Evidence, type TargetDisposition } from "../rectification-v4/extraction.ts";
import { buildRectificationCaseDossier, runRectificationDirector } from "./director-agent.ts";
import { candidateUpdateFor } from "./renderer-agent.ts";
import { extractEventWithModel } from "./event-extractor-agent.ts";
import { evidenceSetHash } from "../rectification-v4/fingerprints.ts";
import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts";
@@ -63,12 +64,12 @@ function blockedVedAstroValidation(now: Date, blocker: string, candidateTimes: r
}
const analysisPhaseLabels = {
extracting_evidence: "整理用户经历",
scoring_candidates: "扫描候选分钟",
checking_robustness: "检查候选稳定性",
planning_question: "生成语义问题机会",
reasoning: "选择下一步动作",
rendering: "生成安全回复",
extracting_evidence: "整理并验证事件提议",
scoring_candidates: "重算候选评分",
checking_robustness: "验证候选稳定性",
planning_question: "准备 Director 完整档案",
reasoning: "Director 选择访谈焦点",
rendering: "验证并保存公开回复",
} as const;
const diagnosticLabels = {
@@ -81,6 +82,25 @@ const diagnosticLabels = {
type AnalysisPhase = keyof typeof analysisPhaseLabels;
const closedTargetDispositions = new Set<TargetDisposition>(["unknown", "declined", "direction_change"]);
export function mergeDirectorReconciliation(input: Readonly<{
server: ReconciledV4Evidence;
staged: ReconciledV4Evidence;
proposedDisposition: TargetDisposition;
currentTargetEventId: string | null;
}>): ReconciledV4Evidence {
if (closedTargetDispositions.has(input.server.targetDisposition)) {
return { ...input.staged, pending: [], unansweredTargetEventId: null, targetDisposition: input.server.targetDisposition };
}
const revisedCurrentTarget = Boolean(input.currentTargetEventId && input.staged.revisions.some((event) => event.eventId === input.currentTargetEventId));
const addedOtherEvent = input.staged.revisions.some((event) => event.eventId !== input.currentTargetEventId);
const valid = input.proposedDisposition !== "resolved" && input.proposedDisposition !== "answered_other_event"
|| input.proposedDisposition === "resolved" && revisedCurrentTarget
|| input.proposedDisposition === "answered_other_event" && addedOtherEvent;
return valid ? { ...input.staged, targetDisposition: input.proposedDisposition } : input.staged;
}
export function publicRectificationTechniques(result: CandidateEngineResult | null): string[] {
if (!result) return [];
const techniques = new Set<string>();
@@ -163,18 +183,17 @@ export async function processRectificationAgentTurn(input: Readonly<{
evidenceDirector = await runRectificationDirector({
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics,
});
const serverReconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now });
reconciliation = claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent" && evidenceDirector.plan.evidenceProposals.length
? stageAgentEvidenceProposals({ caseId: claimed.case.id, rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, proposals: evidenceDirector.plan.evidenceProposals, now })
: reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now });
: serverReconciliation;
if (claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent") {
const proposedDisposition = evidenceDirector.plan.targetDisposition;
const currentTarget = claimed.turn.questionTargetEventId;
const revisedCurrentTarget = Boolean(currentTarget && reconciliation.revisions.some((event) => event.eventId === currentTarget));
const addedOtherEvent = reconciliation.revisions.some((event) => event.eventId !== currentTarget);
const stagedDispositionIsValid = proposedDisposition !== "resolved" && proposedDisposition !== "answered_other_event"
|| proposedDisposition === "resolved" && revisedCurrentTarget
|| proposedDisposition === "answered_other_event" && addedOtherEvent;
if (stagedDispositionIsValid) reconciliation = { ...reconciliation, targetDisposition: proposedDisposition };
reconciliation = mergeDirectorReconciliation({
server: serverReconciliation,
staged: reconciliation,
proposedDisposition: evidenceDirector.plan.targetDisposition,
currentTargetEventId: claimed.turn.questionTargetEventId,
});
}
} else {
reconciliation = claimed.turn.answer ? reconcileV4Evidence({
@@ -386,7 +405,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
});
const publicMessage: StoredPublicMessage = {
acknowledgement: plan.publicReply.acknowledgement,
candidateUpdate: plan.publicReply.candidateCommentary,
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,
analysisTrace: {
@@ -132,7 +132,7 @@ export function candidateUpdateFor(input: Readonly<{
const previous = primaryRange(input.previousSnapshot);
const firstStable = !input.previousSnapshot?.canAcceptRange;
const changed = previous !== current;
if (!firstStable && !changed) return null;
if (input.decisionAction !== "offer_candidate_range" && !firstStable && !changed) return null;
return `目前通过稳定性门的候选范围是 ${current};它仍是待验证范围,不代表其中某一分钟已被确认。`;
}
@@ -11,6 +11,7 @@ import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSI
import { regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts";
import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts";
import { openingQuestion } from "./opening-question.ts";
import type { RectificationV4Store } from "./store.ts";
@@ -198,8 +199,10 @@ export function createRectificationV4CaseService(
readonly endTime: string;
}) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current) return null;
const events = await store.loadEvents(input.userId, input.caseId);
const primary = current?.latestSnapshot?.clusters[0];
if (!current || !current.latestSnapshot?.canAcceptRange || !primary
if (hasPolicyInvalidScoreableEvents(events) || !current.latestSnapshot?.canAcceptRange || !primary
|| primary.startTime !== input.startTime || primary.endTime !== input.endTime) return null;
return response(input.userId, await store.transitionCase({
...input,
@@ -10,7 +10,7 @@ export type DomainScorerPolicy = Readonly<{
export const domainScorerRegistry: Readonly<Record<EvidenceDomain, DomainScorerPolicy>> = {
education: { domain: "education", defaultScoreability: "scoreable", supportedKinds: ["education_milestone"], techniqueLayers: ["D24", "vimshottari", "narayana"] },
relocation: { domain: "relocation", defaultScoreability: "scoreable", supportedKinds: ["relocation"], techniqueLayers: ["D4", "vimshottari", "narayana"] },
relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end", "relationship_change"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] },
relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_change"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] },
career: { domain: "career", defaultScoreability: "scoreable", supportedKinds: ["career_change"], techniqueLayers: ["D10", "A10", "vimshottari", "narayana"] },
finance: { domain: "finance", defaultScoreability: "scoreable", supportedKinds: ["finance_change"], techniqueLayers: ["D2", "D11", "vimshottari", "narayana"] },
health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["self_health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] },
@@ -18,8 +18,9 @@ export const domainScorerRegistry: Readonly<Record<EvidenceDomain, DomainScorerP
other: { domain: "other", defaultScoreability: "pending_review", supportedKinds: ["other"], techniqueLayers: [] },
};
export function scoreabilityFor(domain: EvidenceDomain): Scoreability {
return domainScorerRegistry[domain].defaultScoreability;
export function scoreabilityFor(domain: EvidenceDomain, eventKind: EventKind, requested?: Scoreability): Scoreability {
if (domain === "relationship" && eventKind === "relationship_end") return "pending_review";
return requested ?? domainScorerRegistry[domain].defaultScoreability;
}
export function assertScorerSupports(event: Pick<LifeEventRevision, "domain" | "eventKind" | "scoreability" | "subject">): void {
@@ -46,7 +46,7 @@ export function appendEventRevision(
...input,
id: options.id ?? randomUUID(),
revision: (prior?.revision ?? 0) + 1,
scoreability: input.scoreability ?? scoreabilityFor(input.domain),
scoreability: scoreabilityFor(input.domain, input.eventKind, input.scoreability),
supersedesRevisionId: prior?.id ?? null,
createdAt: (options.now ?? new Date()).toISOString(),
};
@@ -55,5 +55,12 @@ export function appendEventRevision(
}
export function scoreableEvents(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
return latestEventRevisions(revisions).filter((event) => event.scoreability === "scoreable");
return latestEventRevisions(revisions).filter((event) =>
scoreabilityFor(event.domain, event.eventKind, event.scoreability) === "scoreable");
}
export function hasPolicyInvalidScoreableEvents(revisions: readonly LifeEventRevision[]): boolean {
return latestEventRevisions(revisions).some((event) =>
event.scoreability === "scoreable"
&& scoreabilityFor(event.domain, event.eventKind, event.scoreability) !== "scoreable");
}
+144 -34
View File
@@ -4,6 +4,7 @@ import type { EvidenceProposal } from "../rectification-agent/contracts.ts";
import type {
EventKind,
EvidenceDomain,
EventDateRange,
EventSubject,
LifeEventRevision,
PendingEvidence,
@@ -44,14 +45,6 @@ function normalizeKind(domain: EvidenceDomain, value: string, summary: string):
return ({ education: "education_milestone", relocation: "relocation", career: "career_change", finance: "finance_change", health_pressure: "self_health_event", family: "family_event", other: "other" } as const)[domain];
}
function isSameEventRevision(target: LifeEventRevision, extracted: ExtractedLifeEventEvidence): boolean {
return target.domain === extracted.domain
&& target.eventKind === extracted.eventKind
&& target.subject === extracted.subject
&& target.relatedPerson === extracted.relatedPerson
&& (target.summary.includes(extracted.eventSummary) || target.rawText.includes(extracted.eventSummary));
}
function pendingEvidence(input: {
caseId: string;
turnId: string;
@@ -73,6 +66,97 @@ function pendingEvidence(input: {
};
}
function inferredTargetDateRange(dateText: string, target: LifeEventRevision, asOfDate: string): EventDateRange | null {
const normalized = dateText.normalize("NFKC").trim();
const parsed = parseDeclaredDateText(normalized, asOfDate);
if (parsed) return dateRangeFromDeclared(parsed.value, parsed.precision);
const year = target.dateRange.start.slice(0, 4);
const partial = normalized.match(/^(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?$/u);
if (partial) {
const completed = parseDeclaredDateText(`${year}年${partial[1]}月${partial[2] ? `${partial[2]}号` : ""}`, asOfDate);
if (!completed) return null;
return { ...dateRangeFromDeclared(completed.value, completed.precision), label: normalized };
}
if (normalized === "上半年" || normalized === "下半年") {
return normalized === "上半年"
? { start: `${year}-01-01`, end: `${year}-06-30`, precision: "range", label: normalized }
: { start: `${year}-07-01`, end: `${year}-12-31`, precision: "range", label: normalized };
}
const monthRange = normalized.match(/^(\d{1,2})\s*月\s*(?:至|到|[-–—])\s*(\d{1,2})\s*月$/u);
if (!monthRange) return null;
const startMonth = Number(monthRange[1]);
const endMonth = Number(monthRange[2]);
if (startMonth < 1 || endMonth > 12 || startMonth > endMonth) return null;
const endDay = new Date(Date.UTC(Number(year), endMonth, 0)).getUTCDate();
return {
start: `${year}-${String(startMonth).padStart(2, "0")}-01`,
end: `${year}-${String(endMonth).padStart(2, "0")}-${String(endDay).padStart(2, "0")}`,
precision: "range",
label: normalized,
};
}
const kindDomain: Readonly<Record<EventKind, EvidenceDomain>> = {
education_milestone: "education",
relocation: "relocation",
relationship_start: "relationship",
relationship_end: "relationship",
relationship_change: "relationship",
career_change: "career",
finance_change: "finance",
self_health_event: "health_pressure",
family_health_event: "family",
family_bereavement: "family",
family_event: "family",
other: "other",
};
const kindEvidence: Readonly<Record<EventKind, RegExp>> = {
education_milestone: /(?:入学|毕业|升学|退学|考试|学业)/u,
relocation: /(?:搬家|搬到|迁居|迁往|住校|移居)/u,
relationship_start: /(?:开始|恋爱|在一起|交往|结婚)/u,
relationship_end: /(?:分手|离婚|结束|分开|断联|破裂)/u,
relationship_change: /(?:关系变化|感情变化|复合)/u,
career_change: /(?:工作|入职|离职|换岗|创业|升职|实习)/u,
finance_change: /(?:收入|亏损|投资|负债|财务|破产)/u,
self_health_event: /(?:我|本人|自己).*(?:生病|手术|住院|健康)/u,
family_health_event: /(?:家人|父亲|母亲|爸爸|妈妈|祖父母|伴侣).*(?:生病|手术|住院|健康)/u,
family_bereavement: /(?:去世|离世|过世|丧亲)/u,
family_event: /(?:家人|家庭|父亲|母亲|爸爸|妈妈|兄弟|姐妹)/u,
other: /(?:其他|别的)/u,
};
function groundedReclassification(target: LifeEventRevision, proposal: EvidenceProposal): boolean {
const explicitCorrection = /(?:不是|并非|不对|错了)/u.test(proposal.sourceSpan)
&& /(?:而是|其实是|实际是|应该是|发生在.+身上|是)/u.test(proposal.sourceSpan);
if (!explicitCorrection) return false;
const changed = target.domain !== proposal.proposedDomain
|| target.eventKind !== proposal.proposedEventKind
|| target.subject !== proposal.proposedSubject
|| target.relatedPerson !== proposal.proposedRelatedPerson;
if (!changed || kindDomain[proposal.proposedEventKind] !== proposal.proposedDomain) return false;
if (target.eventKind !== proposal.proposedEventKind && !kindEvidence[proposal.proposedEventKind].test(proposal.sourceSpan)) return false;
if (!proposal.sourceSpan.includes(proposal.proposedSummary)) return false;
if (target.subject !== proposal.proposedSubject) {
const subjectPattern = proposal.proposedSubject === "self" ? /(?:我|本人|自己)/u
: proposal.proposedSubject === "family" ? /(?:家人|父亲|母亲|爸爸|妈妈|祖父母|兄弟|姐妹)/u
: proposal.proposedSubject === "partner" ? /(?:伴侣|配偶|对象|男友|女友|丈夫|妻子)/u
: /(?:其他人|别人)/u;
if (!subjectPattern.test(proposal.sourceSpan)) return false;
}
if (target.relatedPerson !== proposal.proposedRelatedPerson && proposal.proposedRelatedPerson) {
const relatedPattern: Readonly<Record<RelatedPerson, RegExp>> = {
father: /(?:父亲|爸爸)/u, mother: /(?:母亲|妈妈)/u,
sibling: /(?:兄弟|姐妹|哥哥|弟弟|姐姐|妹妹)/u,
partner: /(?:伴侣|配偶|对象|男友|女友|丈夫|妻子)/u,
grandparent: /(?:祖父母|爷爷|奶奶|外公|外婆)/u,
};
if (!relatedPattern[proposal.proposedRelatedPerson].test(proposal.sourceSpan)) return false;
}
return true;
}
function newRevision(event: ExtractedLifeEventEvidence, existing: readonly LifeEventRevision[], now?: Date): LifeEventRevision | null {
if (!event.dateValue || event.datePrecision === "unknown") return null;
const domain = event.domain as EvidenceDomain;
@@ -240,8 +324,55 @@ export function stageAgentEvidenceProposals(input: Readonly<{
const active = latestEventRevisions(input.existing);
for (const proposal of input.proposals) {
if (proposal.operation === "ignore") continue;
if (!input.rawText.includes(proposal.sourceSpan) || !proposal.dateText || !input.rawText.includes(proposal.dateText)) {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: proposal.dateText ? "event_unparsed" : "date_unresolved", targetEventId: proposal.targetEventId, now: input.now }));
const target = proposal.targetEventId ? active.find((event) => event.eventId === proposal.targetEventId) ?? null : null;
if (!input.rawText.includes(proposal.sourceSpan)) {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now }));
continue;
}
if (proposal.operation === "revise_date") {
const dateRange = target && proposal.dateText && input.rawText.includes(proposal.dateText)
? inferredTargetDateRange(proposal.dateText, target, input.asOfDate)
: null;
if (!target || !dateRange || dateRange.start > input.asOfDate) {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: proposal.dateText ? "event_unparsed" : "date_unresolved", targetEventId: proposal.targetEventId, now: input.now }));
continue;
}
revisions.push(appendEventRevision([...input.existing, ...revisions], {
eventId: target.eventId,
domain: target.domain,
eventKind: target.eventKind,
subject: target.subject,
relatedPerson: target.relatedPerson,
summary: target.summary,
rawText: input.rawText,
dateRange,
...eventDateProvenance(target),
scoreability: target.scoreability,
}, { now: input.now }));
continue;
}
if (proposal.operation === "reclassify") {
if (!target || !groundedReclassification(target, proposal)) {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now }));
continue;
}
const identityChanged = target.subject !== proposal.proposedSubject || target.relatedPerson !== proposal.proposedRelatedPerson;
revisions.push(appendEventRevision([...input.existing, ...revisions], {
eventId: target.eventId,
domain: proposal.proposedDomain,
eventKind: proposal.proposedEventKind,
subject: proposal.proposedSubject,
relatedPerson: proposal.proposedRelatedPerson,
summary: proposal.proposedSummary,
rawText: input.rawText,
dateRange: target.dateRange,
...eventDateProvenance(target),
scoreability: identityChanged ? "pending_review" : target.scoreability,
}, { now: input.now }));
continue;
}
if (!proposal.dateText || !input.rawText.includes(proposal.dateText)) {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: proposal.dateText ? "event_unparsed" : "date_unresolved", targetEventId: null, now: input.now }));
continue;
}
const extracted = validatedModelAssistedEvidence({
@@ -259,32 +390,11 @@ export function stageAgentEvidenceProposals(input: Readonly<{
},
});
if (!extracted?.dateValue || extracted.datePrecision === "unknown") {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now }));
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: null, now: input.now }));
continue;
}
if (proposal.operation === "create") {
const revision = newRevision(extracted, [...input.existing, ...revisions], input.now);
if (revision && !revisions.some((value) => value.eventId === revision.eventId)) revisions.push(revision);
continue;
}
const target = proposal.targetEventId ? active.find((event) => event.eventId === proposal.targetEventId) : null;
const parsedDate = parseDeclaredDateText(proposal.dateText.normalize("NFKC"), input.asOfDate);
if (!target || !parsedDate || !isSameEventRevision(target, extracted)) {
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now }));
continue;
}
revisions.push(appendEventRevision([...input.existing, ...revisions], {
eventId: target.eventId,
domain: target.domain,
eventKind: target.eventKind,
subject: target.subject,
relatedPerson: target.relatedPerson,
summary: extracted.eventSummary,
rawText: input.rawText,
dateRange: dateRangeFromDeclared(parsedDate.value, parsedDate.precision),
...eventDateProvenance(target),
scoreability: target.scoreability,
}, { now: input.now }));
const revision = newRevision(extracted, [...input.existing, ...revisions], input.now);
if (revision && !revisions.some((value) => value.eventId === revision.eventId)) revisions.push(revision);
}
return {
revisions,
@@ -274,6 +274,20 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|| current.evidenceSetHash !== input.inputEvidenceSetHash
|| current.calculationSpecHash !== input.calculationSpecHash) throw new RectificationV4StoreError("stale_job");
const nextEvents = [...(events.get(current.id) ?? []), ...input.newEventRevisions];
const eventIds = new Set(nextEvents.map((event) => event.eventId));
const resolutionIds = new Set<string>();
for (const resolution of input.resolvedPendingEvidence) {
const pending = pendingEvidence.get(resolution.pendingEvidenceId);
if (resolutionIds.has(resolution.pendingEvidenceId)
|| !pending
|| pending.caseId !== current.id
|| pending.resolvedAt
|| !eventIds.has(resolution.resolvedEventId)
|| (pending.targetEventId !== null && pending.targetEventId !== resolution.resolvedEventId)) {
throw new RectificationV4StoreError("invalid_state");
}
resolutionIds.add(resolution.pendingEvidenceId);
}
events.set(current.id, nextEvents);
if (input.diagnostics) diagnostics.set(input.diagnostics.id, input.diagnostics);
if (input.featureSnapshot) featureSnapshots.set(input.featureSnapshot.id, input.featureSnapshot);
@@ -281,6 +295,10 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
publicMessages.set(input.jobId, input.publicMessage);
validatedDecisions.set(input.jobId, input.validatedDecision);
for (const item of input.pendingEvidence) pendingEvidence.set(item.id, item);
for (const resolution of input.resolvedPendingEvidence) {
const pending = pendingEvidence.get(resolution.pendingEvidenceId)!;
pendingEvidence.set(pending.id, { ...pending, resolvedEventId: resolution.resolvedEventId, resolvedAt: now });
}
const updated: RectificationV4Case = {
...current,
version: current.version + 1,
@@ -22,6 +22,11 @@ export type ClaimedRectificationV4Job = Readonly<{
attemptedRefinementEventIds: readonly string[];
}>;
export type ResolvedPendingEvidence = Readonly<{
pendingEvidenceId: string;
resolvedEventId: string;
}>;
export type CompleteRectificationV4JobInput = Readonly<{
workerId: string;
jobId: string;
@@ -31,6 +36,7 @@ export type CompleteRectificationV4JobInput = Readonly<{
calculationSpecHash: string;
newEventRevisions: readonly LifeEventRevision[];
pendingEvidence: readonly PendingEvidence[];
resolvedPendingEvidence: readonly ResolvedPendingEvidence[];
snapshot: CandidateSnapshot | null;
diagnostics: DiagnosticsSummary | null;
featureSnapshot: CandidateFeatureSnapshot | null;
@@ -433,6 +433,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
p_completion_payload_hash: rectificationFingerprint(completionPayload),
p_event_revisions: input.newEventRevisions,
p_pending_evidence: input.pendingEvidence,
p_resolved_pending_evidence: input.resolvedPendingEvidence,
p_snapshot: input.snapshot,
p_diagnostics: input.diagnostics,
p_feature_snapshot: input.featureSnapshot,
+60 -3
View File
@@ -1,9 +1,59 @@
import { randomUUID } from "node:crypto";
import { extractLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts";
import { processRectificationAgentTurn } from "../rectification-agent/orchestrator.ts";
import type { RectificationV4CandidateEngine } from "./candidate-engine.ts";
import type { RectificationV4Question } from "./contracts.ts";
import type { LifeEventRevision, PendingEvidence, RectificationV4Question } from "./contracts.ts";
import { latestEventRevisions } from "./evidence-ledger.ts";
import { evidenceSetHash } from "./fingerprints.ts";
import type { RectificationV4Store } from "./store.ts";
import type { RectificationV4Store, ResolvedPendingEvidence } from "./store.ts";
function sameDate(left: LifeEventRevision, right: LifeEventRevision): boolean {
return left.dateRange.start === right.dateRange.start
&& left.dateRange.end === right.dateRange.end
&& left.dateRange.precision === right.dateRange.precision;
}
export function resolvedPendingEvidence(
pendingEvidence: readonly PendingEvidence[],
revisions: readonly LifeEventRevision[],
existingRevisions: readonly LifeEventRevision[],
asOfDate: string,
): readonly ResolvedPendingEvidence[] {
const latestRevisionByEvent = new Map(latestEventRevisions(revisions).map((revision) => [revision.eventId, revision]));
const existingByEvent = new Map(latestEventRevisions(existingRevisions).map((revision) => [revision.eventId, revision]));
const resolves = (pending: PendingEvidence, revision: LifeEventRevision) => pending.reasonCode !== "date_unresolved"
|| !existingByEvent.get(revision.eventId)
|| !sameDate(existingByEvent.get(revision.eventId)!, revision);
const resolved: ResolvedPendingEvidence[] = pendingEvidence.flatMap((pending) => {
const revision = pending.targetEventId ? latestRevisionByEvent.get(pending.targetEventId) : null;
return revision && resolves(pending, revision)
? [{ pendingEvidenceId: pending.id, resolvedEventId: revision.eventId }]
: [];
});
const untargeted = pendingEvidence.filter((pending) => !pending.targetEventId && pending.reasonCode === "date_unresolved");
const candidates = new Map(untargeted.map((pending) => {
const semantics = extractLifeEventEvidence({
rawText: pending.rawText,
sourceTurnId: pending.turnId,
asOfDate,
});
return [pending.id, [...latestRevisionByEvent.values()].filter((revision) => semantics.some((event) =>
event.domain === revision.domain
&& event.eventKind === revision.eventKind
&& event.subject === revision.subject
&& event.relatedPerson === revision.relatedPerson,
))] as const;
}));
for (const pending of untargeted) {
const matches = candidates.get(pending.id) ?? [];
if (matches.length !== 1) continue;
const revision = matches[0]!;
if (!resolves(pending, revision)) continue;
const competingPending = untargeted.filter((other) => candidates.get(other.id)?.some((match) => match.eventId === revision.eventId));
if (competingPending.length === 1) resolved.push({ pendingEvidenceId: pending.id, resolvedEventId: revision.eventId });
}
return resolved;
}
export function createRectificationV4Worker(input: {
readonly store: RectificationV4Store;
@@ -21,6 +71,7 @@ export function createRectificationV4Worker(input: {
claimed, engine: input.engine, now: now(),
onPhase: (phase) => input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase, now: now().toISOString() }),
});
const completedAt = now().toISOString();
await input.store.completeJob({
workerId, jobId: claimed.job.id, expectedCaseVersion: claimed.case.version,
inputEvidenceSetHash: claimed.case.evidenceSetHash,
@@ -28,6 +79,12 @@ export function createRectificationV4Worker(input: {
calculationSpecHash: claimed.case.calculationSpecHash,
newEventRevisions: result.newEventRevisions,
pendingEvidence: result.pendingEvidence,
resolvedPendingEvidence: resolvedPendingEvidence(
claimed.pendingEvidence,
result.newEventRevisions,
claimed.events,
completedAt.slice(0, 10),
),
snapshot: result.snapshot,
diagnostics: result.diagnostics,
featureSnapshot: result.featureSnapshot,
@@ -37,7 +94,7 @@ export function createRectificationV4Worker(input: {
nextQuestion: result.nextQuestion,
status: result.status,
phase: result.phase,
}, now().toISOString());
}, completedAt);
return true;
} catch (error) {
const restoreQuestion: RectificationV4Question | null = claimed.turn.questionId && claimed.turn.questionDomain ? {
@@ -0,0 +1,180 @@
begin;
update public.birth_time_rectification_v4_cases case_value
set latest_snapshot_id = null
where case_value.latest_snapshot_id is not null
and exists (
select 1
from public.birth_time_rectification_v4_event_revisions event_value
where event_value.case_id = case_value.id
and event_value.user_id = case_value.user_id
and event_value.event_kind = 'relationship_end'
and event_value.scoreability = 'scoreable'
and not exists (
select 1
from public.birth_time_rectification_v4_event_revisions newer
where newer.event_id = event_value.event_id
and newer.revision > event_value.revision
)
);
-- Keep the previous overload internal so completion cannot bypass pending-evidence closure.
revoke all on function public.complete_birth_time_rectification_v5_job(
uuid, uuid, bigint, text, text, text, text,
jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb,
text, text, timestamptz
) from public, anon, authenticated, service_role;
create or replace function public.complete_birth_time_rectification_v5_job(
p_worker_id uuid,
p_job_id uuid,
p_expected_case_version bigint,
p_input_evidence_set_hash text,
p_output_evidence_set_hash text,
p_calculation_spec_hash text,
p_completion_payload_hash text,
p_event_revisions jsonb,
p_pending_evidence jsonb,
p_resolved_pending_evidence jsonb,
p_snapshot jsonb,
p_diagnostics jsonb,
p_feature_snapshot jsonb,
p_validated_decision jsonb,
p_public_message jsonb,
p_agent_run jsonb,
p_next_question jsonb,
p_status text,
p_phase text,
p_now timestamptz
) returns uuid
language plpgsql security definer set search_path = '' as $$
declare
v_job public.birth_time_rectification_v4_jobs%rowtype;
v_case public.birth_time_rectification_v4_cases%rowtype;
v_pending public.birth_time_rectification_pending_evidence%rowtype;
v_case_id uuid;
v_resolved_event_id uuid;
v_was_completed boolean;
v_updated_count integer;
item jsonb;
begin
if jsonb_typeof(p_resolved_pending_evidence) is distinct from 'array' then
raise exception 'invalid_rectification_v5_resolved_pending_evidence';
end if;
if exists (
select 1
from pg_catalog.jsonb_array_elements(p_resolved_pending_evidence) value
group by value->>'pendingEvidenceId'
having count(*) > 1
) then
raise exception 'duplicate_rectification_v5_resolved_pending_evidence';
end if;
select value.* into v_job
from public.birth_time_rectification_v4_jobs value
where value.id = p_job_id
for update;
if not found then raise exception 'rectification_v4_job_lease_lost'; end if;
select value.* into v_case
from public.birth_time_rectification_v4_cases value
where value.id = v_job.case_id
for update;
if not found then raise exception 'rectification_v4_case_not_found'; end if;
v_was_completed := v_job.status = 'completed';
for item in select value from pg_catalog.jsonb_array_elements(p_resolved_pending_evidence) loop
if jsonb_typeof(item) is distinct from 'object'
or nullif(item->>'pendingEvidenceId', '') is null
or nullif(item->>'resolvedEventId', '') is null then
raise exception 'invalid_rectification_v5_resolved_pending_evidence';
end if;
v_resolved_event_id := (item->>'resolvedEventId')::uuid;
select value.* into v_pending
from public.birth_time_rectification_pending_evidence value
where value.id = (item->>'pendingEvidenceId')::uuid
for update;
if not found
or v_pending.case_id is distinct from v_case.id
or v_pending.user_id is distinct from v_case.user_id then
raise exception 'rectification_v5_pending_evidence_case_mismatch';
end if;
if v_pending.target_event_id is not null
and v_pending.target_event_id is distinct from v_resolved_event_id then
raise exception 'rectification_v5_pending_evidence_target_mismatch';
end if;
if v_was_completed then
if v_pending.resolved_event_id is distinct from v_resolved_event_id
or v_pending.resolved_at is null then
raise exception 'rectification_v5_replay_payload_mismatch';
end if;
elsif v_pending.resolved_at is not null or v_pending.resolved_event_id is not null then
raise exception 'rectification_v5_pending_evidence_already_resolved';
end if;
end loop;
v_case_id := public.complete_birth_time_rectification_v5_job(
p_worker_id,
p_job_id,
p_expected_case_version,
p_input_evidence_set_hash,
p_output_evidence_set_hash,
p_calculation_spec_hash,
p_completion_payload_hash,
p_event_revisions,
p_pending_evidence,
p_snapshot,
p_diagnostics,
p_feature_snapshot,
p_validated_decision,
p_public_message,
p_agent_run,
p_next_question,
p_status,
p_phase,
p_now
);
for item in select value from pg_catalog.jsonb_array_elements(p_resolved_pending_evidence) loop
v_resolved_event_id := (item->>'resolvedEventId')::uuid;
if not exists (
select 1
from public.birth_time_rectification_v4_events value
where value.id = v_resolved_event_id
and value.case_id = v_case.id
and value.user_id = v_case.user_id
) then
raise exception 'rectification_v5_pending_resolved_event_mismatch';
end if;
if not v_was_completed then
update public.birth_time_rectification_pending_evidence
set resolved_event_id = v_resolved_event_id,
resolved_at = p_now
where id = (item->>'pendingEvidenceId')::uuid
and case_id = v_case.id
and user_id = v_case.user_id
and resolved_at is null
and resolved_event_id is null;
get diagnostics v_updated_count = row_count;
if v_updated_count <> 1 then
raise exception 'rectification_v5_pending_evidence_resolution_conflict';
end if;
end if;
end loop;
return v_case_id;
end;
$$;
revoke all on function public.complete_birth_time_rectification_v5_job(
uuid, uuid, bigint, text, text, text, text,
jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb,
text, text, timestamptz
) from public, anon, authenticated;
grant execute on function public.complete_birth_time_rectification_v5_job(
uuid, uuid, bigint, text, text, text, text,
jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb,
text, text, timestamptz
) to service_role;
commit;
@@ -174,9 +174,15 @@ test("Renderer 对切换目标、多问题、出生分钟和内部信息统一
test("稳定候选范围相同不重复提示,实际变化才提示且不确认唯一分钟", () => {
const previous = snapshot(["05:00", "05:30"]);
const same = snapshot(["05:00", "05:30"]);
const same = snapshot(["05:00", "05:30"], { clusters: [
{ rank: 1, startTime: "05:00", endTime: "05:30", representativeTime: "05:13", widthMinutes: 31, peakScore: 10, scoreMass: 1 },
{ rank: 2, startTime: "05:40", endTime: "05:45", representativeTime: "05:42", widthMinutes: 6, peakScore: 8, scoreMass: .4 },
] });
const changed = snapshot(["05:12", "05:18"]);
assert.equal(candidateUpdateFor({ snapshot: same, previousSnapshot: previous, decisionAction: "ask_question" }), null);
const offered = candidateUpdateFor({ snapshot: same, previousSnapshot: previous, decisionAction: "offer_candidate_range" });
assert.match(offered ?? "", /05:00–05:30/);
assert.doesNotMatch(offered ?? "", /05:13|05:40|05:42|05:45/);
const update = candidateUpdateFor({ snapshot: changed, previousSnapshot: previous, decisionAction: "ask_question" });
assert.ok(update);
assert.match(update, /05:12.*05:18/);
+125 -8
View File
@@ -4,7 +4,8 @@ import test from "node:test";
import { diagnosticsSummarySchema, type RectificationTurnPlan } from "../src/lib/rectification-agent/contracts.ts";
import { buildRectificationCaseDossier, regenerateDirectorQuestion, runRectificationDirector, validateRectificationTurnPlan } from "../src/lib/rectification-agent/director-agent.ts";
import type { CalculationSpec, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../src/lib/rectification-v4/contracts.ts";
import { mergeDirectorReconciliation } from "../src/lib/rectification-agent/orchestrator.ts";
import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../src/lib/rectification-v4/contracts.ts";
import { stageAgentEvidenceProposals } from "../src/lib/rectification-v4/extraction.ts";
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
@@ -210,7 +211,7 @@ test("revisions keep the server-owned event id and append revision history", ()
sourceTurnId: randomUUID(),
asOfDate: "2026-07-30",
existing: [target],
proposals: [{ operation: "revise", targetEventId: target.eventId, sourceSpan: "2016年10月大学入学", dateText: "2016年10月", proposedSummary: "2016年10月大学入学", proposedDomain: "education", proposedEventKind: "education_milestone", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }],
proposals: [{ operation: "revise_date", targetEventId: target.eventId, sourceSpan: "2016年10月", dateText: "2016年10月", proposedSummary: "2016年10月大学入学", proposedDomain: "education", proposedEventKind: "education_milestone", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }],
now: new Date(now),
});
assert.equal(staged.revisions.length, 1);
@@ -218,10 +219,10 @@ test("revisions keep the server-owned event id and append revision history", ()
assert.equal(staged.revisions[0]?.revision, 2);
assert.equal(staged.revisions[0]?.dateRange.start, "2016-10-01");
assert.equal(staged.revisions[0]?.dateRange.end, "2016-10-31");
assert.equal(staged.revisions[0]?.summary, "大学入学");
assert.equal(staged.revisions[0]?.summary, target.summary);
});
test("revisions cannot replace an existing event with unrelated model content", () => {
test("date revisions ignore model attempts to replace event identity", () => {
const target = event({ eventId: "00000000-0000-4000-8000-000000000708" });
const rawText = "2018年9月搬到北京。";
const staged = stageAgentEvidenceProposals({
@@ -230,12 +231,92 @@ test("revisions cannot replace an existing event with unrelated model content",
sourceTurnId: randomUUID(),
asOfDate: "2026-07-30",
existing: [target],
proposals: [{ operation: "revise", targetEventId: target.eventId, sourceSpan: "2018年9月搬到北京", dateText: "2018年9月", proposedSummary: "2018年9月创办公司", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }],
proposals: [{ operation: "revise_date", targetEventId: target.eventId, sourceSpan: "2018年9月", dateText: "2018年9月", proposedSummary: "2018年9月创办公司", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }],
now: new Date(now),
});
assert.equal(staged.revisions.length, 0);
assert.equal(staged.pending.length, 1);
assert.equal(staged.pending[0]?.targetEventId, target.eventId);
assert.equal(staged.revisions.length, 1);
assert.equal(staged.pending.length, 0);
assert.equal(staged.revisions[0]?.eventId, target.eventId);
assert.equal(staged.revisions[0]?.domain, target.domain);
assert.equal(staged.revisions[0]?.eventKind, target.eventKind);
assert.equal(staged.revisions[0]?.subject, target.subject);
assert.equal(staged.revisions[0]?.summary, target.summary);
});
test("server-closed target dispositions cannot be overwritten by the Director", () => {
const target = event();
const pending: PendingEvidence = {
id: randomUUID(), caseId, turnId: randomUUID(), rawText: "不想说了,换一个。", reasonCode: "event_unparsed",
targetEventId: target.eventId, resolvedEventId: null, createdAt: now, resolvedAt: null,
};
for (const targetDisposition of ["unknown", "declined", "direction_change"] as const) {
const merged = mergeDirectorReconciliation({
server: { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition },
staged: { revisions: [], pending: [pending], unansweredTargetEventId: target.eventId, targetDisposition: "unresolved" },
proposedDisposition: "unresolved",
currentTargetEventId: target.eventId,
});
assert.equal(merged.targetDisposition, targetDisposition);
assert.equal(merged.unansweredTargetEventId, null);
assert.deepEqual(merged.pending, []);
}
});
test("target date-only replies inherit identity and missing year", () => {
const target = event();
const cases = [
["2020年4月", "2020-04-01", "2020-04-30"],
["4月", "2016-04-01", "2016-04-30"],
["8月8号", "2016-08-08", "2016-08-08"],
["上半年", "2016-01-01", "2016-06-30"],
["10月至12月", "2016-10-01", "2016-12-31"],
] as const;
for (const [rawText, start, end] of cases) {
const staged = stageAgentEvidenceProposals({
caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target],
proposals: [{ operation: "revise_date", targetEventId: target.eventId, sourceSpan: rawText, dateText: rawText, proposedSummary: "模型不得改写", proposedDomain: "other", proposedEventKind: "other", proposedSubject: "family", proposedRelatedPerson: "mother", confidence: "high" }],
now: new Date(now),
});
assert.equal(staged.pending.length, 0, rawText);
assert.equal(staged.revisions.length, 1, rawText);
assert.equal(staged.revisions[0]?.eventId, target.eventId, rawText);
assert.equal(staged.revisions[0]?.domain, target.domain, rawText);
assert.equal(staged.revisions[0]?.eventKind, target.eventKind, rawText);
assert.equal(staged.revisions[0]?.subject, target.subject, rawText);
assert.equal(staged.revisions[0]?.summary, target.summary, rawText);
assert.equal(staged.revisions[0]?.dateRange.start, start, rawText);
assert.equal(staged.revisions[0]?.dateRange.end, end, rawText);
}
});
test("explicit event-kind correction appends a revision to the same event", () => {
const target = event({ domain: "relationship", eventKind: "relationship_start", subject: "self", relatedPerson: "partner", summary: "关系开始", rawText: "2020年关系开始" });
const rawText = "不是关系开始,是分手";
const staged = stageAgentEvidenceProposals({
caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target],
proposals: [{ operation: "reclassify", targetEventId: target.eventId, sourceSpan: rawText, dateText: null, proposedSummary: "分手", proposedDomain: "relationship", proposedEventKind: "relationship_end", proposedSubject: "self", proposedRelatedPerson: "partner", confidence: "high" }],
now: new Date(now),
});
assert.equal(staged.pending.length, 0);
assert.equal(staged.revisions[0]?.eventId, target.eventId);
assert.equal(staged.revisions[0]?.revision, 2);
assert.equal(staged.revisions[0]?.eventKind, "relationship_end");
});
test("explicit subject correction is grounded and enters pending review", () => {
const target = event({ domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, summary: "手术", rawText: "2020年我做了手术" });
const rawText = "这次是母亲手术,不是我";
const staged = stageAgentEvidenceProposals({
caseId, rawText, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [target],
proposals: [{ operation: "reclassify", targetEventId: target.eventId, sourceSpan: rawText, dateText: null, proposedSummary: "母亲手术", proposedDomain: "family", proposedEventKind: "family_health_event", proposedSubject: "family", proposedRelatedPerson: "mother", confidence: "high" }],
now: new Date(now),
});
assert.equal(staged.pending.length, 0);
assert.equal(staged.revisions[0]?.eventId, target.eventId);
assert.equal(staged.revisions[0]?.subject, "family");
assert.equal(staged.revisions[0]?.relatedPerson, "mother");
assert.equal(staged.revisions[0]?.scoreability, "pending_review");
});
test("declined targets cannot be reopened and diagnostics stay in a bounded tool loop", async () => {
@@ -243,6 +324,8 @@ test("declined targets cannot be reopened and diagnostics stay in a bounded tool
const targetDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [target], snapshot: null, diagnostics: null, targetDisposition: "declined", currentTargetEventId: target.eventId });
const reopened = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } });
assert.ok(validateRectificationTurnPlan({ plan: reopened, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened"));
const reopenedWithoutId = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: null, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } });
assert.ok(validateRectificationTurnPlan({ plan: reopenedWithoutId, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened"));
const phases: string[] = [];
const prompts: string[] = [];
@@ -306,3 +389,37 @@ test("manual question regeneration preserves focus and repairs unsafe text once"
assert.equal(question, "除了这段经历,你还想从哪件事继续?");
assert.deepEqual(phases, ["regenerate", "repair"]);
});
test("public reply rejects technique names and multiple independent questions without rejecting one natural question", () => {
for (const technique of ["D2", "D4", "D9", "D10", "D11", "D24", "D30", "KP", "Vimshottari", "Narayana", "Shadbala", "Ashtakavarga"]) {
const issues = validateRectificationTurnPlan({
plan: plan({ publicReply: { acknowledgement: `${technique} 更支持这段经历。`, candidateCommentary: null, limitation: null } }),
dossier: dossier(), latestAnswer: "", phase: "final",
}).issues;
assert.ok(issues.includes("private_detail_exposed"), technique);
}
const multiple = plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "你记得它发生在哪一年。那时发生了什么。", optionalQuickReplies: [] } });
assert.ok(validateRectificationTurnPlan({ plan: multiple, dossier: dossier(), latestAnswer: "", phase: "final" }).issues.includes("multiple_questions"));
const single = plan({ action: { type: "ask_question", focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] }, question: "你还记得一件时间大致确定的重要经历吗?", optionalQuickReplies: [] } });
assert.deepEqual(validateRectificationTurnPlan({ plan: single, dossier: dossier(), latestAnswer: "", phase: "final" }).issues, []);
});
test("candidate range requires the current approved snapshot id", () => {
const snapshot: CandidateSnapshot = {
id: diagnostics.snapshotId, caseId, caseVersion: 3, evidenceSetHash: "e".repeat(64), calculationSpecHash: "c".repeat(64), algorithmVersion: "rectification-v5-matrix-scoring-1",
candidates: [{ time: "05:12", score: 10, supportingEventIds: [], conflictingEventIds: [] }],
clusters: [{ rank: 1, startTime: "05:12", endTime: "05:18", representativeTime: "05:13", widthMinutes: 7, peakScore: 10, scoreMass: 1 }],
robustness: { neighborSupportMinutes: 7, leaveOneOutRetentionRate: .8, leaveOneDomainOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true },
canConfirmExactMinute: false, canAcceptRange: true, gateReasons: [], createdAt: now,
};
const approvedDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [], snapshot, diagnostics, targetDisposition: "not_applicable", currentTargetEventId: null });
const accepted = plan({ action: { type: "offer_candidate_range", snapshotId: snapshot.id } });
assert.deepEqual(validateRectificationTurnPlan({ plan: accepted, dossier: approvedDossier, latestAnswer: "", phase: "final" }).issues, []);
const wrongSnapshot = plan({ action: { type: "offer_candidate_range", snapshotId: randomUUID() } });
assert.ok(validateRectificationTurnPlan({ plan: wrongSnapshot, dossier: approvedDossier, latestAnswer: "", phase: "final" }).issues.includes("candidate_range_gate_failed"));
const legacyRelationshipEnd = event({ domain: "relationship", eventKind: "relationship_end", scoreability: "scoreable" });
const staleDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [legacyRelationshipEnd], snapshot, diagnostics, targetDisposition: "not_applicable", currentTargetEventId: null });
assert.equal(staleDossier.candidateState.publicRangeAllowed, false);
assert.ok(validateRectificationTurnPlan({ plan: accepted, dossier: staleDossier, latestAnswer: "", phase: "final" }).issues.includes("candidate_range_gate_failed"));
});
+17 -4
View File
@@ -5,7 +5,7 @@ import { buildQuestionOpportunities } from "../src/lib/rectification-agent/oppor
import { buildCandidateClusters } from "../src/lib/rectification-v4/candidate-clusters.ts";
import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4/date-range.ts";
import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts";
import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts";
import { appendEventRevision, latestEventRevisions, scoreableEvents } from "../src/lib/rectification-v4/evidence-ledger.ts";
import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts";
import { openingQuestion } from "../src/lib/rectification-v4/opening-question.ts";
@@ -25,17 +25,30 @@ test("declared month, quarter and year retain boundaries instead of invented mid
assert.equal(sampledDates(dateRangeFromDeclared("2024-02", "month")).includes("2024-02-15"), false);
});
test("relationship start and end remain separate self/partner events", () => {
test("relationship end fails closed while start and change reuse relationship scoring", () => {
const start = revision({
eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", subject: "self", relatedPerson: "partner",
summary: "关系开始", rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"), scoreability: "scoreable",
});
const change = revision({
eventId: randomUUID(), domain: "relationship", eventKind: "relationship_change", subject: "self", relatedPerson: "partner",
summary: "关系变化", rawText: "2024年6月关系发生变化", dateRange: dateRangeFromDeclared("2024-06", "month"), scoreability: "scoreable",
});
const end = revision({
eventId: randomUUID(), domain: "relationship", eventKind: "relationship_end", subject: "self", relatedPerson: "partner",
summary: "关系结束", rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"), scoreability: "scoreable",
});
assert.notEqual(start.eventId, end.eventId);
assert.deepEqual([start.eventKind, end.eventKind], ["relationship_start", "relationship_end"]);
assert.equal(start.scoreability, "scoreable");
assert.equal(change.scoreability, "scoreable");
assert.equal(end.scoreability, "pending_review");
const legacyScoreableEnd = { ...end, scoreability: "scoreable" as const };
assert.doesNotThrow(() => scoreableEvents([start, change, legacyScoreableEnd]));
assert.deepEqual(scoreableEvents([start, change, legacyScoreableEnd]).map((event) => event.eventKind).sort(), [
"relationship_change",
"relationship_start",
]);
});
test("family health and bereavement stay context-only while self health is scoreable", () => {
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
const conversationalSql = readFileSync(new URL("../supabase/migrations/20260727010000_rectification_v4_conversational_turns.sql", import.meta.url), "utf8");
const regenerationSql = readFileSync(new URL("../supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql", import.meta.url), "utf8");
const pendingResolutionSql = readFileSync(new URL("../supabase/migrations/20260730020000_pending_evidence_resolution.sql", import.meta.url), "utf8");
test("v4 migration creates canonical append-only storage and leased jobs", () => {
for (const table of [
@@ -58,3 +59,17 @@ test("current-question regeneration is service-role-only, atomic, idempotent, an
assert.doesNotMatch(regenerationSql, /insert into public\.birth_time_rectification_v4_(?:turns|events|jobs|candidate_snapshots)/i);
assert.doesNotMatch(regenerationSql, /update\s+public\.profiles/i);
});
test("pending evidence resolution is atomic, ownership-checked, target-safe, and replay-consistent", () => {
assert.match(pendingResolutionSql, /update public\.birth_time_rectification_v4_cases[\s\S]*set latest_snapshot_id = null[\s\S]*birth_time_rectification_v4_event_revisions[\s\S]*event_kind = 'relationship_end'[\s\S]*scoreability = 'scoreable'/i);
assert.match(pendingResolutionSql, /p_resolved_pending_evidence jsonb/i);
assert.match(pendingResolutionSql, /jsonb_typeof\(p_resolved_pending_evidence\)[\s\S]*'array'/i);
assert.match(pendingResolutionSql, /case_id is distinct from v_case\.id[\s\S]*user_id is distinct from v_case\.user_id/i);
assert.match(pendingResolutionSql, /target_event_id is not null[\s\S]*target_event_id is distinct from v_resolved_event_id/i);
assert.match(pendingResolutionSql, /resolved_at is not null[\s\S]*rectification_v5_pending_evidence_already_resolved/i);
assert.match(pendingResolutionSql, /birth_time_rectification_v4_events[\s\S]*case_id = v_case\.id[\s\S]*user_id = v_case\.user_id/i);
assert.match(pendingResolutionSql, /update public\.birth_time_rectification_pending_evidence[\s\S]*resolved_event_id = v_resolved_event_id[\s\S]*resolved_at = p_now/i);
assert.match(pendingResolutionSql, /v_was_completed[\s\S]*rectification_v5_replay_payload_mismatch/i);
assert.match(pendingResolutionSql, /revoke all on function public\.complete_birth_time_rectification_v5_job\([\s\S]*grant execute[\s\S]*to service_role/i);
});
+124 -2
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvidence } from "../src/lib/rectification-v4/contracts.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
import { createRectificationV4Worker, resolvedPendingEvidence } from "../src/lib/rectification-v4/worker.ts";
const fixedNow = () => new Date("2026-07-28T12:00:00.000Z");
const spec: CalculationSpec = {
@@ -259,3 +259,125 @@ test("legacy and shadow cases cannot call the V5 Agent question renderer", async
});
}
});
test("worker closes only the uniquely matched historical pending evidence", async () => withMode("v4_legacy", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const pending: PendingEvidence = {
id: randomUUID(),
caseId: created.case.id,
turnId: randomUUID(),
rawText: "后来搬家一次,但记不清时间了",
reasonCode: "date_unresolved",
targetEventId: null,
resolvedEventId: null,
createdAt: "2026-07-27T12:00:00.000Z",
resolvedAt: null,
};
store.pendingEvidence.set(pending.id, pending);
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2018年9月搬家到北京",
});
assert.ok(queued?.job);
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const resolved = store.pendingEvidence.get(pending.id);
assert.ok(resolved?.resolvedEventId);
assert.equal(resolved.resolvedAt, fixedNow().toISOString());
const events = await store.loadEvents(userId, created.case.id);
assert.ok(events.some((event) => event.eventId === resolved.resolvedEventId && event.domain === "relocation"));
}));
test("worker leaves ambiguous historical pending evidence unresolved", async () => withMode("v4_legacy", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const pending = ["后来搬家一次,但记不清时间了", "以前也搬家,时间忘了"].map((rawText): PendingEvidence => ({
id: randomUUID(), caseId: created.case.id, turnId: randomUUID(), rawText,
reasonCode: "date_unresolved", targetEventId: null, resolvedEventId: null,
createdAt: "2026-07-27T12:00:00.000Z", resolvedAt: null,
}));
for (const item of pending) store.pendingEvidence.set(item.id, item);
await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2018年9月搬家到北京",
});
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
assert.ok(pending.every((item) => store.pendingEvidence.get(item.id)?.resolvedAt === null));
}));
test("date pending evidence closes only after the event date changes", () => {
const eventId = randomUUID();
const original: LifeEventRevision = {
id: randomUUID(), eventId, revision: 1, domain: "relationship", eventKind: "relationship_start",
subject: "self", relatedPerson: "partner", summary: "关系开始", rawText: "2020年关系开始",
dateRange: { start: "2020-01-01", end: "2020-12-31", precision: "year", label: "2020年" },
scoreability: "scoreable", supersedesRevisionId: null, createdAt: "2026-07-27T12:00:00.000Z",
};
const pending: PendingEvidence = {
id: randomUUID(), caseId: randomUUID(), turnId: randomUUID(), rawText: "时间还不确定",
reasonCode: "date_unresolved", targetEventId: eventId, resolvedEventId: null,
createdAt: "2026-07-27T12:00:00.000Z", resolvedAt: null,
};
const reclassified: LifeEventRevision = {
...original, id: randomUUID(), revision: 2, eventKind: "relationship_end", summary: "关系结束",
scoreability: "pending_review", supersedesRevisionId: original.id, createdAt: "2026-07-28T12:00:00.000Z",
};
assert.deepEqual(resolvedPendingEvidence([pending], [reclassified], [original], "2026-07-30"), []);
const dated: LifeEventRevision = {
...reclassified, id: randomUUID(), revision: 3,
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
supersedesRevisionId: reclassified.id,
};
assert.deepEqual(resolvedPendingEvidence([pending], [dated], [original, reclassified], "2026-07-30"), [{
pendingEvidenceId: pending.id,
resolvedEventId: eventId,
}]);
});
test("legacy scoreable relationship-end snapshots cannot be accepted", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const revision: LifeEventRevision = {
id: randomUUID(), eventId: randomUUID(), revision: 1, domain: "relationship", eventKind: "relationship_end",
subject: "self", relatedPerson: "partner", summary: "关系结束", rawText: "2020年关系结束",
dateRange: { start: "2020-01-01", end: "2020-12-31", precision: "year", label: "2020年" },
scoreability: "scoreable", supersedesRevisionId: null, createdAt: fixedNow().toISOString(),
};
const revised = await store.reviseEvent({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: created.case.version,
revision, jobId: randomUUID(), now: fixedNow().toISOString(),
});
const snapshot: CandidateSnapshot = {
id: randomUUID(), caseId: created.case.id, caseVersion: revised.case.version,
evidenceSetHash: revised.case.evidenceSetHash, calculationSpecHash: revised.case.calculationSpecHash,
algorithmVersion: "rectification-v5-matrix-scoring-1",
candidates: [{ time: "05:12", score: 10, supportingEventIds: [revision.eventId], conflictingEventIds: [] }],
clusters: [{ rank: 1, startTime: "05:12", endTime: "05:18", representativeTime: "05:13", widthMinutes: 7, peakScore: 10, scoreMass: 1 }],
robustness: { neighborSupportMinutes: 7, leaveOneOutRetentionRate: .8, leaveOneDomainOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true },
canConfirmExactMinute: false, canAcceptRange: true, gateReasons: [], createdAt: fixedNow().toISOString(),
};
store.cases.set(created.case.id, { ...revised.case, latestSnapshot: snapshot });
assert.equal(await service.acceptRange({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: revised.case.version,
startTime: "05:12", endTime: "05:18",
}), null);
}));
@@ -54,14 +54,6 @@ DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
"finance": (("D2", "D11"), (2, 11)),
"health_pressure": (("D30",), (6, 8, 12)),
}
# ponytail: non-semantic audit offsets; replace only when calibrated kind rules exist.
EVENT_KIND_MODIFIERS: Final[dict[str, float]] = {
"relationship_start": 0.001,
"relationship_end": 0.002,
"relationship_change": 0.003,
}
class RectificationEventCalculationError(RuntimeError):
"""Raised when stored rectification evidence cannot be calculated safely."""
@@ -246,7 +238,6 @@ def _score_event(
if not rules:
rules.append("no_domain_activation")
rules.append(f"event_kind:{event_kind}")
points += EVENT_KIND_MODIFIERS.get(event_kind, 0.0)
weighted_points = round(points * precision_weight(event["precision"]), 4)
return {
"event_id": event["id"],
+2 -2
View File
@@ -170,7 +170,7 @@ def test_matrix_legacy_adapter_preserves_event_kind() -> None:
assert seen == ["relationship_end"]
def test_relationship_event_kinds_have_distinct_traceable_contributions(monkeypatch) -> None:
def test_relationship_event_kinds_reuse_activation_without_fake_offsets(monkeypatch) -> None:
monkeypatch.setattr(
event_engine.functional_benefics,
"derive_functional_benefic_malefic",
@@ -199,7 +199,7 @@ def test_relationship_event_kinds_have_distinct_traceable_contributions(monkeypa
for kind in ("relationship_start", "relationship_end", "relationship_change")
}
assert {item["points"] for item in evidence.values()} == {0.001, 0.002, 0.003}
assert {item["points"] for item in evidence.values()} == {0.0}
for kind, item in evidence.items():
assert f"event_kind:{kind}" in item["rule_ids"]