diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 99e4b7b2..245e952a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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` diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index e5e12d37..795904bb 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -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(), diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index a6ce986d..29e96996 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -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(); 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 { diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index 98136535..a42e5fd8 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -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(["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(); @@ -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: { diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 8ddefdb8..2448218b 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -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};它仍是待验证范围,不代表其中某一分钟已被确认。`; } diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 732b27b0..ae8c015f 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -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, diff --git a/frontend/src/lib/rectification-v4/domain-scorers.ts b/frontend/src/lib/rectification-v4/domain-scorers.ts index 60cbb7ac..24f4a86e 100644 --- a/frontend/src/lib/rectification-v4/domain-scorers.ts +++ b/frontend/src/lib/rectification-v4/domain-scorers.ts @@ -10,7 +10,7 @@ export type DomainScorerPolicy = Readonly<{ export const domainScorerRegistry: Readonly> = { 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): void { diff --git a/frontend/src/lib/rectification-v4/evidence-ledger.ts b/frontend/src/lib/rectification-v4/evidence-ledger.ts index 3870bba8..bf9933ac 100644 --- a/frontend/src/lib/rectification-v4/evidence-ledger.ts +++ b/frontend/src/lib/rectification-v4/evidence-ledger.ts @@ -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"); } diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index 301f5360..5b60bd14 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -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> = { + 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> = { + 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> = { + 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, diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 1b2e9edb..06a4cb40 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -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(); + 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, diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 55a3bd67..849548c3 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -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; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index cd31548d..7a32da2f 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -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, diff --git a/frontend/src/lib/rectification-v4/worker.ts b/frontend/src/lib/rectification-v4/worker.ts index 1e100275..d13c4f80 100644 --- a/frontend/src/lib/rectification-v4/worker.ts +++ b/frontend/src/lib/rectification-v4/worker.ts @@ -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 ? { diff --git a/frontend/supabase/migrations/20260730020000_pending_evidence_resolution.sql b/frontend/supabase/migrations/20260730020000_pending_evidence_resolution.sql new file mode 100644 index 00000000..c0f59f25 --- /dev/null +++ b/frontend/supabase/migrations/20260730020000_pending_evidence_resolution.sql @@ -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; diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index 954f8256..4f931e7c 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -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/); diff --git a/frontend/tests/rectification-director.test.ts b/frontend/tests/rectification-director.test.ts index d76b2765..509ea464 100644 --- a/frontend/tests/rectification-director.test.ts +++ b/frontend/tests/rectification-director.test.ts @@ -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")); +}); diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index ab6526d0..8739bf2c 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -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", () => { diff --git a/frontend/tests/rectification-v4-migration.test.ts b/frontend/tests/rectification-v4-migration.test.ts index 003c4fae..c03dc8f4 100644 --- a/frontend/tests/rectification-v4-migration.test.ts +++ b/frontend/tests/rectification-v4-migration.test.ts @@ -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); +}); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index e98befb6..bf095054 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -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); +})); diff --git a/scripts/active_rectification_event_engine.py b/scripts/active_rectification_event_engine.py index 6b168080..b8c4928f 100644 --- a/scripts/active_rectification_event_engine.py +++ b/scripts/active_rectification_event_engine.py @@ -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"], diff --git a/tests/test_active_rectification_events.py b/tests/test_active_rectification_events.py index 80131bb5..9e6a42bd 100644 --- a/tests/test_active_rectification_events.py +++ b/tests/test_active_rectification_events.py @@ -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"]