fix(rectification): enforce director evidence boundaries
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user