diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 245e952a..e9724d9d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1849,3 +1849,20 @@ - 防复发:拒答保护必须有 Orchestrator/Director 级回归;Pending resolution 必须验证缺口已被对应 Revision 补齐;评分政策变化必须同时处理历史 Snapshot;公开技术层过滤按完整技术命名空间测试。 - 相关记录:BUG-099、BUG-102、BUG-103 - 修复版本:`birth-time-rectification-v6` / `rectification-director-v2` + +## BUG-105 | 候选差异未驱动下一问、Event Kind 未进入评分且不可回答 Case 被错误复用 + +- 状态:resolved +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V6 Agent 问题排序、V5 Python 贡献矩阵、V4 Case 创建/复用、Staging 新建校正后的首次回答 +- 用户现象:诊断已经显示候选簇在特定技术层存在差异时,访谈仍可能继续做通用领域轮询;关系确立与关系变化在评分中缺少语义差异;新建校正后首次提交回答可能返回“当前没有待回答的问题,请刷新后重试。”;已知主体的非评分事件还可能被错误追问“发生在谁身上”。 +- 触发条件:候选分歧只携带技术层而没有可行动的缺失证据;评分仅按 Domain 汇总;创建 Case 时复用 `paused`、没有 Active Job 的 `processing`、或没有 `currentQuestion` 的 `awaiting_answer` Case;`pending_review` 被等同于主体不明确。 +- 根因:Director Dossier 缺少 Candidate Contrast Packet,问题排序对同领域历史提问施加通用重复惩罚;共享评分入口没有按 Event Kind 和真实命中 Rule ID 调整贡献;Service、Memory Store 与 Supabase RPC 的可恢复 Case 条件不一致且没有算法版本隔离;主体澄清条件错误地依赖整个 Scoreability 状态。 +- 修复:共享诊断层按主候选与次候选的静态特征差异计算区分层、按事件贡献差值计算相关事件,再生成不暴露候选分钟的 Candidate Contrast Packet,用 Cluster Rank、区分层、相关事件和缺失 Event Kind 驱动问题机会;候选驱动的不同 Event Kind 不受通用同领域重复惩罚,并优先于无诊断依据的领域轮询,非评分事件不作为 Candidate Split Target;共享 Python 贡献矩阵按真实 Rule ID 对 `relationship_start` 与 `relationship_change` 应用不同 Profile,零 Activation 不凭空加分,`relationship_end` 继续 Fail Closed;算法版本升级到 `rectification-v5-matrix-scoring-2`;三层 Case 复用统一为仅恢复可回答 Case 或拥有 Active Job 的 Processing Case,且必须匹配算法版本;主体澄清仅针对 `subject=other`。 +- 数据库:新增向前迁移,更新新 Case 默认算法版本、保留 range-scoring v1 与 matrix-scoring v1 历史 Candidate Snapshot 解析兼容、废弃未完成的旧算法 Case、标记其活跃 Job 为 Stale,并重建带可恢复状态和算法版本检查的 Case 创建 RPC;不写入 Profile 出生时间。 +- 验证:真实用户回放覆盖复读、大学入学并离家、开始工作、分手和负债,断言关系结束保持 `pending_review`、不会把大学入学换词重问为迁居、D9 内部差异转为关系确立/状态变化的自然存在性问题、公开问题不泄露技术层或候选分钟,并允许“没有、不知道、不想回答、换方向”;Service 回归覆盖 Paused、孤儿 Processing、空问题 Awaiting Answer、新 Case 首次回答与旧算法 Case 替换;Python 回归覆盖关系 Event Kind 反转候选排序及零 Activation。 +- 安全边界:Candidate Contrast 只在服务器内部使用,不向用户公开技术层、分数、代表分钟或第二候选簇;Agent 不能把 `relationship_end` 变为可评分事件,不能确认精确出生分钟,也不能自动写入 Profile。 +- 防复发:完整回放测试必须同时覆盖事件账本、问题排序、退出方式和公开文本;评分版本变化必须同步 Case 复用、数据库默认值和历史快照兼容;Case 创建测试必须随后真实调用一次 Answer。 +- 相关记录:BUG-101、BUG-102、BUG-104 +- 修复版本:local follow-up / `rectification-v5-matrix-scoring-2` diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 795904bb..8094cc54 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -90,6 +90,19 @@ export const rectificationTurnPlanSchema = z.object({ }).strict(); export type RectificationTurnPlan = z.infer; +export const candidateContrastPacketSchema = z.object({ + primaryClusterRank: z.number().int().positive().nullable(), + secondaryClusterRank: z.number().int().positive().nullable(), + discriminatingLayers: z.array(nonblank(80)).max(40), + relevantEventIds: z.array(uuid).max(100), + missingEvidence: z.array(z.object({ + domain: evidenceDomainSchema, + eventKind: eventKindSchema, + reason: z.literal("highest_candidate_separation"), + }).strict()).max(8), +}).strict(); +export type CandidateContrastPacket = z.infer; + export const rectificationCaseDossierSchema = z.object({ case: z.object({ candidateWindow: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(), @@ -140,6 +153,7 @@ export const rectificationCaseDossierSchema = z.object({ rangeChanged: z.boolean(), topClusters: z.array(z.object({ rank: z.number().int(), widthMinutes: z.number().int(), stability: z.enum(["stable", "unstable"]) }).strict()).max(4), contrasts: z.array(z.object({ techniqueLayers: z.array(z.string()), relevantEventIds: z.array(uuid) }).strict()).max(8), + contrastIntelligence: candidateContrastPacketSchema.nullable(), eventDiagnostics: z.array(z.object({ eventId: uuid, winnerRetentionRate: z.number(), scoreVariance: z.number() }).strict()).max(100), gateReasons: z.array(z.string()).max(20), currentSnapshotId: uuid.nullable(), diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index 29e96996..52ac734c 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -5,6 +5,7 @@ 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 { buildCandidateContrastPacket } from "./opportunity-builder.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"); @@ -55,7 +56,7 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti 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, 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 }, + 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 })), contrastIntelligence: buildCandidateContrastPacket({ events: input.events, snapshot: input.snapshot, diagnostics: input.diagnostics }), 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"] }, }); } diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts index 90e027e1..12de0b5b 100644 --- a/frontend/src/lib/rectification-agent/opportunity-builder.ts +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -1,8 +1,9 @@ import { createHash } from "node:crypto"; -import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; -import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts"; +import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts"; +import { chronologicalEvents, latestEventRevisions } from "../rectification-v4/evidence-ledger.ts"; import type { TargetDisposition } from "../rectification-v4/extraction.ts"; -import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts"; +import type { CandidateContrastPacket, DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts"; const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [ "switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", @@ -86,21 +87,21 @@ const routingValue: Record = { type OpportunityInput = Omit; -function utility(value: OpportunityInput): number { +function utility(value: OpportunityInput, contrastPriority = 0): number { return Number(( .35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance + .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty - + routingValue[value.kind] - value.repetitionPenalty - value.privacyCost + + routingValue[value.kind] + contrastPriority - value.repetitionPenalty - value.privacyCost ).toFixed(6)); } -function opportunity(caseId: string, input: OpportunityInput): QuestionOpportunity { +function opportunity(caseId: string, input: OpportunityInput, contrastPriority = 0): QuestionOpportunity { return { contractVersion: "semantic-question-v2", ...input, forbiddenMoves, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.goal}:${input.fallbackPrompt}`), - utility: utility(input), + utility: utility(input, contrastPriority), active: true, }; } @@ -121,6 +122,47 @@ function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): Readon return result; } +const genericTechniqueLayers = new Set(["vimshottari", "narayana"]); + +export function buildCandidateContrastPacket(input: Readonly<{ + events: readonly LifeEventRevision[]; + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; +}>): CandidateContrastPacket | null { + const split = input.diagnostics?.candidateSplits[0]; + if (!split) return null; + const discriminatingLayers = split.techniqueLayers.filter((layer) => !genericTechniqueLayers.has(layer.toLowerCase())); + const existingKinds = new Set(latestEventRevisions(input.events) + .filter((event) => event.scoreability === "scoreable") + .map((event) => event.eventKind)); + const missingEvidence = (Object.entries(domainScorerRegistry) as [EvidenceDomain, (typeof domainScorerRegistry)[EvidenceDomain]][]) + .flatMap(([domain, policy]) => { + if (!policy.techniqueLayers.some((layer) => discriminatingLayers.includes(layer))) return []; + const eventKind = policy.supportedKinds.find((kind) => !existingKinds.has(kind)); + return eventKind ? [{ domain, eventKind, reason: "highest_candidate_separation" as const }] : []; + }); + return { + primaryClusterRank: input.snapshot?.clusters[0]?.rank ?? null, + secondaryClusterRank: input.snapshot?.clusters[1]?.rank ?? null, + discriminatingLayers, + relevantEventIds: [...split.eventIds], + missingEvidence, + }; +} + +function contrastQuestion(eventKind: EventKind, anchor: string | null): Readonly<{ goal: string; fallbackPrompt: string }> | null { + const prefix = anchor ? `在“${anchor}”之外,` : ""; + if (eventKind === "relationship_start") return { + goal: `${prefix}在用户愿意的前提下,询问是否有一段关系正式确立或开始共同生活的经历及其大致年月,不预设一定发生。`, + fallbackPrompt: `${prefix}如果你愿意,有没有一段关系正式确立或开始共同生活的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`, + }; + if (eventKind === "relationship_change") return { + goal: `${prefix}在用户愿意的前提下,询问是否有一段关系状态明显改变的经历及其大致年月,不预设一定发生。`, + fallbackPrompt: `${prefix}如果你愿意,有没有一段关系状态明显改变的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`, + }; + return null; +} + export function buildQuestionOpportunities(input: Readonly<{ caseId: string; events: readonly LifeEventRevision[]; @@ -140,6 +182,7 @@ export function buildQuestionOpportunities(input: Readonly<{ const latestEvent = chronologicalEvents(input.events).at(-1); const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? ""; const opportunities: QuestionOpportunity[] = []; + const contrastPacket = buildCandidateContrastPacket(input); if (input.targetDisposition === "answered_other_event") { for (const eventId of retryTargets) { @@ -165,7 +208,7 @@ export function buildQuestionOpportunities(input: Readonly<{ if (targetClosed && retryTargets.has(event.eventId)) continue; const attemptCount = targetAttempts.get(event.eventId) ?? 0; const anchor = anchorFor(event); - if ((event.scoreability === "pending_review" || event.subject === "other") && attemptCount === 0) { + if (event.subject === "other" && attemptCount === 0) { opportunities.push(opportunity(input.caseId, { kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId, goal: `确认“${anchor}”发生在本人、家人还是伴侣。`, requestedFields: ["event_subject"], @@ -205,16 +248,17 @@ export function buildQuestionOpportunities(input: Readonly<{ const split = input.diagnostics?.candidateSplits[0]; if (split) { - const target = input.events.find((event) => split.eventIds.includes(event.eventId) + const target = input.events.find((event) => event.scoreability === "scoreable" + && split.eventIds.includes(event.eventId) && (targetAttempts.get(event.eventId) ?? 0) === 0 && !(targetClosed && retryTargets.has(event.eventId))); const anchor = target ? anchorFor(target) : null; - opportunities.push(opportunity(input.caseId, { + if (target) opportunities.push(opportunity(input.caseId, { kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null, - goal: target ? `确认“${anchor}”更接近开始、高峰还是正式结束。` : "确认一件现有事件的发生阶段。", - requestedFields: ["event_stage"], anchors: anchor ? [anchor] : [], + goal: `确认“${anchor}”更接近开始、高峰还是正式结束。`, + requestedFields: ["event_stage"], anchors: [anchor!], contextFacts: [`候选分歧涉及 ${split.techniqueLayers.length} 个已计算技术层。`], - fallbackPrompt: target ? `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?` : "那件经历更接近开始、达到高峰,还是正式结束?", + fallbackPrompt: `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?`, reason: "候选簇在现有诊断中出现可检验分歧。", expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: 0, privacyCost: .1, @@ -231,8 +275,10 @@ export function buildQuestionOpportunities(input: Readonly<{ const pendingThemeBonus = !latestEvent && policy.signals.test(latestContext) ? .12 : 0; const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId); const latestAnchor = latestEvent ? anchorFor(latestEvent) : null; + const contrastEvidence = contrastPacket?.missingEvidence.find((item) => item.domain === domain) ?? null; + const targetedQuestion = contrastEvidence ? contrastQuestion(contrastEvidence.eventKind, latestAnchor) : null; opportunities.push(opportunity(input.caseId, { - kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal(latestAnchor), + kind: "ask_new_event", domain, targetEventId: null, goal: targetedQuestion?.goal ?? policy.goal(latestAnchor), requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [], contextFacts: [ `已有 ${scoreableCount} 件可评分事件。`, @@ -242,16 +288,17 @@ export function buildQuestionOpportunities(input: Readonly<{ "只询问一件带大致年月的新事件,不要求用户逐项回答例子。", "允许用户回答没有、记不清、不想回答或换方向。", "不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。", + ...(contrastEvidence ? ["该类证据对当前候选区分力最高,应优先确认是否存在。"] : []), ...(semanticOverlap ? ["该领域与最新事件语义重叠,必须降低优先级,避免把同一经历换词重问。"] : []), ], - fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。", - expectedInformationGain: covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus, + fallbackPrompt: targetedQuestion?.fallbackPrompt ?? policy.fallbackPrompt(latestAnchor), reason: contrastEvidence ? "补足当前候选分离所需的关键证据。" : covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。", + expectedInformationGain: contrastEvidence ? .95 : covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus, dateSensitivity: input.snapshot ? .5 : .35, - candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42, - domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15, - recallEase: policy.recallEase, novelty: semanticOverlap ? .45 : alreadyAsked ? .35 : .9, - repetitionPenalty: (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost, - })); + candidateSplitRelevance: contrastEvidence ? .98 : input.diagnostics?.candidateSplits.length ? .58 : .42, + domainCoverageGain: contrastEvidence ? 1 : covered ? 0 : scoreableDomains.size < 2 ? 1 : .15, + recallEase: policy.recallEase, novelty: contrastEvidence ? .95 : semanticOverlap ? .45 : alreadyAsked ? .35 : .9, + repetitionPenalty: contrastEvidence ? 0 : (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost, + }, contrastEvidence ? .08 : 0)); } return opportunities diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts index 3e980f5f..2d488371 100644 --- a/frontend/src/lib/rectification-agent/renderer-agent.ts +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -27,7 +27,7 @@ const newEventDomainTerms: Readonly Promise>; @@ -60,7 +60,7 @@ function validateOpeningMessage(value: unknown, range: Readonly<{ start: string; if (internalTerms.test(message)) issues.push("private_detail_exposed"); const positiveClaims = message.split(/[。;;!??!]/).filter((sentence) => !/(?:不是|并非|尚未|还未|不能)/.test(sentence)).join(" "); if (exactMinuteClaim.test(positiveClaims)) issues.push("exact_minute_claimed"); - if ((message.match(domainChecklistTerms) ?? []).length >= 3) issues.push("fixed_domain_checklist"); + if (fixedChoiceStructure.test(message)) issues.push("fixed_domain_checklist"); return { message: issues.length ? null : message, issues }; } diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index ae54fdfb..1f4d00fe 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -13,7 +13,7 @@ import { generateOpeningQuestion, regenerateQuestionRealization } from "../recti import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts"; import { openingQuestion } from "./opening-question.ts"; -import type { RectificationV4Store } from "./store.ts"; +import { canResumeRectificationCase, type RectificationV4Store } from "./store.ts"; const regenerationInFlight = new Map>(); @@ -58,7 +58,10 @@ export function createRectificationV4CaseService( const specHash = calculationSpecHash(input.calculationSpec); const active = await store.findActiveCase(input.userId); - if (active?.calculationSpecHash === specHash) { + const activeJob = active?.status === "processing" ? await store.loadActiveJob(input.userId, active.id) : null; + if (active?.calculationSpecHash === specHash + && active.algorithmVersion === rectificationV4AlgorithmVersion + && canResumeRectificationCase(active, Boolean(activeJob))) { return response(input.userId, await store.createCase({ case: active, actionId: input.actionId })); } diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index e9d91f0d..2af856c7 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -4,7 +4,11 @@ export const rectificationV4Protocol = "rectification-evidence-v4" as const; export const rectificationAgentV5Protocol = "rectification-evidence-v5" as const; export const rectificationDeploymentModeSchema = z.enum(["v4_legacy", "v5_shadow", "v5_agent"]); export type RectificationDeploymentMode = z.infer; -export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-1" as const; +export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-2" as const; +const rectificationLegacyAlgorithmVersions = [ + "rectification-v4-range-scoring-1", + "rectification-v5-matrix-scoring-1", +] as const; export const rectificationV4CaseStatusSchema = z.enum([ "awaiting_answer", @@ -182,7 +186,7 @@ const candidateSnapshotBaseSchema = z.object({ caseVersion: z.number().int().nonnegative(), evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/), calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/), - algorithmVersion: z.literal(rectificationV4AlgorithmVersion), + algorithmVersion: z.enum([...rectificationLegacyAlgorithmVersions, rectificationV4AlgorithmVersion]), candidates: z.array(candidateMinuteSchema).min(1).max(1_440), clusters: z.array(candidateClusterSchema).max(20), robustness: robustnessSchema, diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 06a4cb40..8e02d4be 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -11,7 +11,7 @@ import type { RectificationV4Store, RectificationV4Turn, } from "./store.ts"; -import { RectificationV4StoreError } from "./store.ts"; +import { canResumeRectificationCase, RectificationV4StoreError } from "./store.ts"; import { evidenceSetHash } from "./fingerprints.ts"; export function createRectificationV4MemoryStore(): RectificationV4Store & { @@ -93,7 +93,10 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { if (replay) return owned(input.case.userId, replay.caseId); const active = [...cases.values()].find((value) => value.userId === input.case.userId && value.status !== "abandoned" && value.acceptedRange === null); - if (active?.calculationSpecHash === input.case.calculationSpecHash) { + const hasActiveJob = active ? [...jobs.values()].some((job) => job.caseId === active.id && ["pending", "processing"].includes(job.status)) : false; + if (active?.calculationSpecHash === input.case.calculationSpecHash + && active.algorithmVersion === input.case.algorithmVersion + && canResumeRectificationCase(active, hasActiveJob)) { actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: active.id, jobId: null }); return active; } diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 849548c3..af9a0f8d 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -12,6 +12,11 @@ import type { } from "./contracts.ts"; export type { RectificationV4Turn } from "./contracts.ts"; +export function canResumeRectificationCase(caseValue: RectificationV4Case, hasActiveJob: boolean): boolean { + return (caseValue.status === "awaiting_answer" && caseValue.currentQuestion !== null) + || (caseValue.status === "processing" && hasActiveJob); +} + export type ClaimedRectificationV4Job = Readonly<{ job: RectificationV4Job; case: RectificationV4Case; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 7a32da2f..9e778317 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -97,7 +97,7 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific narrationModelId: row.narration_model_id ? String(row.narration_model_id) : null, skillVersion: row.skill_version ? String(row.skill_version) : "birth-time-rectification-v5", promptVersion: row.prompt_version ? String(row.prompt_version) : "rectification-agent-v5-1", - algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-1", + algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-2", deploymentMode: row.deployment_mode === "v5_agent" || row.deployment_mode === "v5_shadow" ? row.deployment_mode : "v4_legacy", agentMode: row.agent_mode === "agent" ? "agent" : "deterministic_fallback", featureSnapshotId: row.feature_snapshot_id ? String(row.feature_snapshot_id) : null, diff --git a/frontend/supabase/migrations/20260731010000_rectification_case_resume_and_scoring_v2.sql b/frontend/supabase/migrations/20260731010000_rectification_case_resume_and_scoring_v2.sql new file mode 100644 index 00000000..cd0aaba4 --- /dev/null +++ b/frontend/supabase/migrations/20260731010000_rectification_case_resume_and_scoring_v2.sql @@ -0,0 +1,151 @@ +-- A Case is resumable only when the client can answer it or a worker still owns active work. +-- Scoring v2 changes Event Kind semantics, so unfinished v1 Cases must not be resumed under v2. + +alter table public.birth_time_rectification_v4_cases + alter column algorithm_version set default 'rectification-v5-matrix-scoring-2'; + +alter table public.birth_time_rectification_v4_candidate_snapshots + alter column algorithm_version set default 'rectification-v5-matrix-scoring-2'; + +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_candidate_snapshots'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%algorithm_version%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_candidate_snapshots drop constraint %I', + value.conname + ); + end loop; +end $$; + +alter table public.birth_time_rectification_v4_candidate_snapshots + add constraint birth_time_rectification_scoring_v2_candidate_snapshots_algorithm_check + check (algorithm_version in ( + 'rectification-v4-range-scoring-1', + 'rectification-v5-matrix-scoring-1', + 'rectification-v5-matrix-scoring-2' + )); + +update public.birth_time_rectification_v4_jobs job +set status = 'stale', lease_expires_at = null, updated_at = pg_catalog.now() +from public.birth_time_rectification_v4_cases case_value +where job.case_id = case_value.id + and job.status in ('pending', 'processing') + and case_value.algorithm_version <> 'rectification-v5-matrix-scoring-2' + and case_value.accepted_range_start is null + and case_value.status <> 'abandoned'; + +update public.birth_time_rectification_v4_cases +set status = 'abandoned', phase = 'complete', current_question = null, updated_at = pg_catalog.now() +where algorithm_version <> 'rectification-v5-matrix-scoring-2' + and accepted_range_start is null + and status <> 'abandoned'; + +create or replace function public.create_birth_time_rectification_v5_case( + p_user_id uuid, + p_case_id uuid, + p_action_id uuid, + p_status text, + p_phase text, + p_calculation_spec jsonb, + p_calculation_spec_hash text, + p_evidence_set_hash text, + p_current_question jsonb, + p_orchestration_model_id text, + p_narration_model_id text, + p_skill_version text, + p_prompt_version text, + p_algorithm_version text, + p_deployment_mode text, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_case public.birth_time_rectification_v4_cases%rowtype; + v_case_id uuid; + v_protocol text; +begin + if p_deployment_mode not in ('v4_legacy', 'v5_shadow', 'v5_agent') then + raise exception 'invalid_rectification_v5_deployment_mode'; + end if; + if p_algorithm_version <> 'rectification-v5-matrix-scoring-2' then + raise exception 'invalid_rectification_v5_algorithm_version'; + end if; + v_protocol := case when p_deployment_mode = 'v4_legacy' + then 'rectification-evidence-v4' else 'rectification-evidence-v5' end; + + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':rectification-v5-case', 0) + ); + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.user_id = p_user_id + and value.status <> 'abandoned' + and value.accepted_range_start is null + order by value.created_at desc + limit 1 + for update; + + if found + and v_case.calculation_spec_hash = p_calculation_spec_hash + and v_case.algorithm_version = p_algorithm_version + and ( + (v_case.status = 'awaiting_answer' and v_case.current_question is not null) + or ( + v_case.status = 'processing' + and exists ( + select 1 + from public.birth_time_rectification_v4_jobs job + where job.case_id = v_case.id + and job.status in ('pending', 'processing') + ) + ) + ) then + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, v_case.id, p_now + ); + return v_case.id; + end if; + + if found then + update public.birth_time_rectification_v4_cases + set status = 'abandoned', phase = 'complete', current_question = null, updated_at = p_now + where id = v_case.id; + update public.birth_time_rectification_v4_jobs + set status = 'stale', lease_expires_at = null, updated_at = p_now + where case_id = v_case.id and status in ('pending', 'processing'); + end if; + + insert into public.birth_time_rectification_v4_cases ( + id, user_id, protocol, status, phase, calculation_spec, calculation_spec_hash, + evidence_set_hash, current_question, orchestration_model_id, narration_model_id, + skill_version, prompt_version, algorithm_version, deployment_mode, agent_mode, + created_at, updated_at + ) values ( + p_case_id, p_user_id, v_protocol, p_status, p_phase, p_calculation_spec, + p_calculation_spec_hash, p_evidence_set_hash, p_current_question, + nullif(btrim(p_orchestration_model_id), ''), nullif(btrim(p_narration_model_id), ''), + p_skill_version, p_prompt_version, p_algorithm_version, p_deployment_mode, + 'deterministic_fallback', p_now, p_now + ); + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, p_case_id, p_now + ); + return p_case_id; +end; +$$; diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts index 87d9c1b5..77050819 100644 --- a/frontend/tests/rectification-agent-v5.test.ts +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -50,8 +50,8 @@ test("opening Agent generates and validates the first rectification message", as generate: async (_prompt, phase) => { phases.push(phase); return phase === "generate" - ? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请按学业、搬家、感情、工作依次回答。" } } - : { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。你愿意先从一段自己记得清楚的经历开始说吗?" } }; + ? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请从学习、工作或感情中选一段经历来说。" } } + : { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。请讲一段你最清楚的经历,也可以连续讲几件相关的事;记不清或想换方向都可以。" } }; }, }); assert.deepEqual(phases, ["generate", "repair"]); @@ -475,7 +475,7 @@ test("V6 agent conversation follows dated events, respects direction change, and assert.ok(store.diagnostics.size > 0); assert.equal(fourth.case.latestSnapshot?.robustness.leaveOneDomainOutRetentionRate, 1); assert.equal(fourth.case.latestSnapshot?.canConfirmExactMinute, false); - assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-1"); + assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-2"); const finalMessage = [...store.publicMessages.values()].at(-1); assert.doesNotMatch(`${finalMessage?.candidateUpdate ?? ""}${finalMessage?.limitation ?? ""}`, /唯一分钟|准确分钟|代表分钟|05:13/); }); diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index 4f931e7c..5d0d17e6 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -9,7 +9,7 @@ import { type QuestionOpportunity, type ValidatedDecision, } from "../src/lib/rectification-agent/contracts.ts"; -import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; +import { buildCandidateContrastPacket, buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; import { buildReasonerState } from "../src/lib/rectification-agent/reasoner-agent.ts"; import { candidateUpdateFor, realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts"; import { extractLifeEventEvidence, validatedModelAssistedEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts"; @@ -464,6 +464,139 @@ test("新事件机会提供具体回忆线索和退出方式,不把离家上 assert.equal(validateQuestionRealization(career.fallbackPrompt, career).valid, true); }); +test("D9 候选差异优先请求缺失的关系事件语义而不是继续领域轮询", () => { + const events = [ + event({ summary: "2015年复读", rawText: "2015年复读" }), + event({ eventId: randomUUID(), domain: "career", eventKind: "career_change", summary: "2020年开始工作", rawText: "2020年开始工作" }), + event({ eventId: randomUUID(), domain: "finance", eventKind: "finance_change", summary: "2026年开始负债", rawText: "2026年开始负债" }), + ]; + const splitDiagnostics = diagnostics({ + candidateSplits: [{ + leftCluster: { start: "05:10", end: "05:14" }, + rightCluster: { start: "05:16", end: "05:20" }, + techniqueLayers: ["D9", "vimshottari"], + eventIds: [], + }], + }); + const currentSnapshot = snapshot(["05:10", "05:14"], { + clusters: [ + { rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:12", widthMinutes: 5, peakScore: 10, scoreMass: .55 }, + { rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:18", widthMinutes: 5, peakScore: 9.8, scoreMass: .45 }, + ], + }); + + const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics }); + assert.deepEqual(packet?.missingEvidence[0], { + domain: "relationship", + eventKind: "relationship_start", + reason: "highest_candidate_separation", + }); + assert.deepEqual(packet?.discriminatingLayers, ["D9"]); + + const opportunities = buildQuestionOpportunities({ caseId, events, turns: [], snapshot: currentSnapshot, diagnostics: splitDiagnostics }); + assert.equal(opportunities.some((item) => item.kind === "disambiguate_candidate_split" && item.targetEventId === null), false); + assert.equal(opportunities[0]?.kind, "ask_new_event"); + assert.equal(opportunities[0]?.domain, "relationship"); + assert.ok(opportunities[0]?.contextFacts.some((fact) => /候选区分力最高/.test(fact))); + assert.doesNotMatch(opportunities[0]?.contextFacts.join(" ") ?? "", /D9|05:1/); + + const withStart = buildCandidateContrastPacket({ + events: [...events, event({ eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", summary: "2022年确定关系", rawText: "2022年确定关系" })], + snapshot: currentSnapshot, + diagnostics: splitDiagnostics, + }); + assert.equal(withStart?.missingEvidence[0]?.eventKind, "relationship_change"); +}); + +test("真实用户回放按候选差异追问关系证据且不泄露内部候选", () => { + const replay = [ + { rawText: "2015年复读", domain: "education", eventKind: "education_milestone" }, + { rawText: "2016年离家去外地上大学", domain: "education", eventKind: "education_milestone" }, + { rawText: "2020年开始工作", domain: "career", eventKind: "career_change" }, + { rawText: "2024年分手", domain: "relationship", eventKind: "relationship_end" }, + { rawText: "2026年开始负债", domain: "finance", eventKind: "finance_change" }, + ] as const; + let events: LifeEventRevision[] = []; + const turns: RectificationV4Turn[] = []; + + replay.forEach((item, index) => { + const sourceTurnId = randomUUID(); + const dateText = item.rawText.slice(0, 5); + const assisted = validatedModelAssistedEvidence({ + rawText: item.rawText, + sourceTurnId, + asOfDate: "2026-07-31", + extraction: { + sourceSpan: item.rawText, + summary: item.rawText.slice(5), + domain: item.domain, + eventKind: item.eventKind, + subject: "self", + relatedPerson: null, + dateText, + }, + }); + assert.ok(assisted); + const reconciled = reconcileV4Evidence({ + caseId, + answer: item.rawText, + sourceTurnId, + asOfDate: "2026-07-31", + existing: events, + assistedEvidence: [assisted], + now: new Date(`2026-07-31T0${index}:00:00.000Z`), + }); + assert.equal(reconciled.pending.length, 0); + events = [...events, ...reconciled.revisions]; + turns.push(turn({ + id: sourceTurnId, + caseVersion: index + 1, + questionDomain: item.domain, + answer: item.rawText, + createdAt: `2026-07-31T0${index}:00:00.000Z`, + })); + }); + + const breakup = events.find((item) => item.eventKind === "relationship_end"); + assert.ok(breakup); + assert.equal(breakup.scoreability, "pending_review"); + assert.equal(events.some((item) => item.eventKind === "relationship_end" && item.scoreability === "scoreable"), false); + assert.equal(events.some((item) => item.domain === "relocation"), false); + + const currentSnapshot = snapshot(["05:10", "05:14"], { + canAcceptRange: false, + gateReasons: ["insufficient_candidate_separation"], + clusters: [ + { rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:13", widthMinutes: 5, peakScore: 10, scoreMass: .51 }, + { rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:17", widthMinutes: 5, peakScore: 9.9, scoreMass: .49 }, + ], + }); + const splitDiagnostics = diagnostics({ + candidateSplits: [{ + leftCluster: { start: "05:10", end: "05:14" }, + rightCluster: { start: "05:16", end: "05:20" }, + techniqueLayers: ["D9", "vimshottari"], + eventIds: [breakup.eventId], + }], + }); + const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics }); + assert.equal(packet?.missingEvidence[0]?.eventKind, "relationship_start"); + + const opportunities = buildQuestionOpportunities({ caseId, events, turns, snapshot: currentSnapshot, diagnostics: splitDiagnostics }); + const next = opportunities[0]; + assert.ok(next); + assert.equal(next.kind, "ask_new_event"); + assert.equal(next.domain, "relationship"); + assert.match(next.fallbackPrompt, /关系正式确立|开始共同生活/); + assert.match(next.fallbackPrompt, /没有/); + assert.match(next.fallbackPrompt, /不知道/); + assert.match(next.fallbackPrompt, /不想回答/); + assert.match(next.fallbackPrompt, /换方向/); + assert.doesNotMatch(next.fallbackPrompt, /搬家|迁居|离乡|外地/); + assert.doesNotMatch([next.goal, next.fallbackPrompt, ...next.contextFacts].join(" "), /D9|vimshottari|05:1[037]/i); + assert.equal(validateQuestionRealization(next.fallbackPrompt, next).valid, true); +}); + test("研究院实习后的迁居机会以存在性问题主动引导,不要求用户自己发明事件", () => { const internship = event({ domain: "career", diff --git a/frontend/tests/rectification-case-resume-scoring-v2-migration.test.ts b/frontend/tests/rectification-case-resume-scoring-v2-migration.test.ts new file mode 100644 index 00000000..a3ce6627 --- /dev/null +++ b/frontend/tests/rectification-case-resume-scoring-v2-migration.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync(new URL( + "../supabase/migrations/20260731010000_rectification_case_resume_and_scoring_v2.sql", + import.meta.url, +), "utf8"); + +test("scoring v2 migration only resumes an answerable or actively processing Case", () => { + assert.match(migration, /v_case\.algorithm_version = p_algorithm_version/); + assert.match(migration, /v_case\.status = 'awaiting_answer'[\s\S]*v_case\.current_question is not null/); + assert.match(migration, /v_case\.status = 'processing'[\s\S]*birth_time_rectification_v4_jobs[\s\S]*status in \('pending', 'processing'\)/); + assert.doesNotMatch(migration, /v_case\.status = 'paused'/); +}); + +test("scoring v2 migration retires incompatible unfinished work without touching profile birth time", () => { + assert.match(migration, /algorithm_version <> 'rectification-v5-matrix-scoring-2'[\s\S]*accepted_range_start is null/); + assert.match(migration, /set status = 'stale'/); + assert.match(migration, /set status = 'abandoned', phase = 'complete', current_question = null/); + assert.doesNotMatch(migration, /profiles\.active_birth_time|update\s+public\.profiles/i); +}); + +test("scoring v2 migration permits historical snapshots but defaults new artifacts to v2", () => { + assert.match(migration, /alter column algorithm_version set default 'rectification-v5-matrix-scoring-2'/); + assert.match(migration, /rectification-v4-range-scoring-1[\s\S]*rectification-v5-matrix-scoring-1[\s\S]*rectification-v5-matrix-scoring-2/); +}); diff --git a/frontend/tests/rectification-public-range-gate-v6.test.ts b/frontend/tests/rectification-public-range-gate-v6.test.ts index cd4c8074..a283b7d5 100644 --- a/frontend/tests/rectification-public-range-gate-v6.test.ts +++ b/frontend/tests/rectification-public-range-gate-v6.test.ts @@ -88,6 +88,14 @@ function snapshotInput(overrides: Record = {}) { }; } +test("pre-matrix historical snapshots remain readable", () => { + const parsed = candidateSnapshotSchema.parse(snapshotInput({ + algorithmVersion: "rectification-v4-range-scoring-1", + robustness, + })); + assert.equal(parsed.algorithmVersion, "rectification-v4-range-scoring-1"); +}); + test("legacy snapshots without domain retention still parse without retroactive rejection", () => { const parsed = candidateSnapshotSchema.parse(snapshotInput({ robustness: { diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index 987c2d82..0c04a6d0 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -83,6 +83,48 @@ test("same calculation spec resumes while a changed spec abandons the old case a assert.equal(store.jobs.get(queued.job.id)?.status, "stale"); })); +test("new case replaces paused or orphaned processing state and can be answered immediately", async () => withMode("v5_agent", async () => { + const store = createRectificationV4MemoryStore(); + const service = createTestCaseService(store, { now: fixedNow }); + const userId = randomUUID(); + + const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + await service.transition({ userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, kind: "pause" }); + const afterPause = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + assert.notEqual(afterPause.case.id, first.case.id); + assert.equal(store.cases.get(first.case.id)?.status, "abandoned"); + assert.ok(afterPause.case.currentQuestion); + + store.cases.set(afterPause.case.id, { ...afterPause.case, status: "processing", phase: "reasoning", currentQuestion: null }); + const replacement = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + assert.notEqual(replacement.case.id, afterPause.case.id); + assert.equal(store.cases.get(afterPause.case.id)?.status, "abandoned"); + assert.ok(replacement.case.currentQuestion); + + const queued = await service.answer({ + userId, + caseId: replacement.case.id, + actionId: randomUUID(), + expectedCaseVersion: replacement.case.version, + answer: "2016 年离家去外地上大学", + }); + assert.ok(queued?.job); +})); + +test("new scoring version replaces a resumable Case created by an older algorithm", async () => withMode("v5_agent", async () => { + const store = createRectificationV4MemoryStore(); + const service = createTestCaseService(store, { now: fixedNow }); + const userId = randomUUID(); + const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + store.cases.set(first.case.id, { ...first.case, algorithmVersion: "rectification-v5-matrix-scoring-1" }); + + const second = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + + assert.notEqual(second.case.id, first.case.id); + assert.equal(second.case.algorithmVersion, "rectification-v5-matrix-scoring-2"); + assert.equal(store.cases.get(first.case.id)?.status, "abandoned"); +})); + test("answer is durably queued and a processing case reload restores its active job", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); const service = createTestCaseService(store, { now: fixedNow }); diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index 9311ea9f..232c6b23 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -11,7 +11,7 @@ DatePrecision = Literal["day", "month", "quarter", "year", "range"] SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = { "education": frozenset({"education_milestone"}), "relocation": frozenset({"relocation"}), - "relationship": frozenset({"relationship_start", "relationship_end", "relationship_change"}), + "relationship": frozenset({"relationship_start", "relationship_change"}), "career": frozenset({"career_change"}), "finance": frozenset({"finance_change"}), "health_pressure": frozenset({"self_health_event"}), diff --git a/scripts/rectification/diagnostics_service.py b/scripts/rectification/diagnostics_service.py index 846a7524..a678e883 100644 --- a/scripts/rectification/diagnostics_service.py +++ b/scripts/rectification/diagnostics_service.py @@ -40,6 +40,48 @@ def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[ return [{**row, "score": round(row["score"] - sum(item["points"] for item in row["evidence"] if item["event_id"] in removed_ids), 4)} for row in rows] +def _candidate_feature_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> list[str]: + features = { + value["time"]: value + for context in built.get("static_contexts") or [] + if isinstance((value := context.get("feature")), dict) and isinstance(value.get("time"), str) + } + primary = features.get(primary_time) + secondary = features.get(secondary_time) + if not primary or not secondary: + return [] + layers = [] + for section in ("varga_ascendants", "arudha_signs"): + primary_values = primary.get(section) or {} + secondary_values = secondary.get(section) or {} + layers.extend( + key for key in set(primary_values) | set(secondary_values) + if primary_values.get(key) != secondary_values.get(key) + ) + fingerprints = (("ashtakavarga", "Ashtakavarga"), ("shadbala", "Shadbala")) + primary_fingerprints = primary.get("fingerprints") or {} + secondary_fingerprints = secondary.get("fingerprints") or {} + layers.extend( + layer for key, layer in fingerprints + if primary_fingerprints.get(key) != secondary_fingerprints.get(key) + ) + return sorted(set(layers))[:8] + + +def _candidate_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> tuple[list[str], list[str]]: + event_deltas: list[tuple[float, str]] = [] + for event_id, candidates in built["matrix"].items(): + primary = candidates.get(primary_time) + secondary = candidates.get(secondary_time) + if not primary or not secondary: + continue + delta = abs(float(primary["points"]) - float(secondary["points"])) + if delta > 1e-9: + event_deltas.append((delta, event_id)) + events = [event_id for _, event_id in sorted(event_deltas, key=lambda item: (-item[0], item[1]))] + return _candidate_feature_contrast(built, primary_time, secondary_time), events + + def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow], built: dict[str, Any]) -> dict[str, Any]: primary = set(_primary_cluster(rows)) event_runs = [] @@ -74,11 +116,12 @@ def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow] clusters = [_primary_cluster(rows)] candidate_splits = [] if secondary and clusters[0]: + contrast_layers, contrast_event_ids = _candidate_contrast(built, top[0]["time"], secondary["time"]) candidate_splits.append({ "left_cluster": {"start": clusters[0][0], "end": clusters[0][-1]}, "right_cluster": {"start": secondary["time"], "end": secondary["time"]}, - "technique_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:8]], - "event_ids": [item["event_id"] for item in secondary["evidence"] if item["points"] != 0], + "technique_layers": contrast_layers, + "event_ids": contrast_event_ids, }) return { "primary_cluster_retention_rate": 1.0 if primary else 0.0, diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py index aad6e8ea..f08e09cc 100644 --- a/scripts/rectification/scoring_service.py +++ b/scripts/rectification/scoring_service.py @@ -11,7 +11,7 @@ from scripts.active_rectification_event_engine import compute_candidate_static_c from scripts.active_rectification_events import CandidateScoreRow from scripts.rectification.contracts import LifeEvent, RectificationRequest -ALGORITHM_VERSION = "rectification-v5-matrix-scoring-1" +ALGORITHM_VERSION = "rectification-v5-matrix-scoring-2" INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4" @@ -82,6 +82,42 @@ def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]: return tuple(compute_event_candidate_rows(json.loads(serialized))) +_RELATIONSHIP_SUPPORT_RULES = ( + "functional_benefic_auxiliary", + "arudha_auxiliary", + "ashtakavarga_target_house_support_auxiliary", + "shadbala_sthana_drik_naisargika_support_auxiliary", + "controlled_transit_jupiter_domain_house", +) +_RELATIONSHIP_CHANGE_RULES = ( + "functional_malefic_auxiliary", + "ashtakavarga_target_house_pressure_auxiliary", + "shadbala_sthana_drik_naisargika_pressure_auxiliary", + "controlled_transit_saturn_domain_house", +) + + +def _relationship_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float: + if event_kind not in {"relationship_start", "relationship_change"}: + return 1.0 + support = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_SUPPORT_RULES) for rule in rule_ids) + change = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_CHANGE_RULES) for rule in rule_ids) + direction = support - change if event_kind == "relationship_start" else change - support + return max(0.8, min(1.2, 1 + 0.08 * direction)) + + +def _kind_adjusted_evidence(event: LifeEvent, evidence: dict[str, Any]) -> dict[str, Any]: + if event["domain"] != "relationship": + return evidence + event_kind = event["event_kind"] + rules = list(evidence["rule_ids"]) + return { + **evidence, + "rule_ids": [*rules, f"event_kind_profile:{event_kind}"], + "points": round(float(evidence["points"]) * _relationship_kind_factor(event_kind, rules), 4), + } + + def build_event_contribution_matrix( request: RectificationRequest, row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None, @@ -94,7 +130,14 @@ def build_event_contribution_matrix( candidate_grid: list[str] | None = None for event in request["events"]: samples = sample_event_dates(event) - sample_rows = [list(provider(_legacy_request(request, event, sampled))) for sampled in samples] + sample_rows = [] + for sampled in samples: + rows = list(provider(_legacy_request(request, event, sampled))) + sample_rows.append([ + {**row, "score": adjusted["points"], "evidence": [adjusted]} + for row in rows + for adjusted in [_kind_adjusted_evidence(event, row["evidence"][0])] + ]) grids = [[row["time"] for row in rows] for rows in sample_rows] if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid): raise ValueError("candidate_grid_mismatch") @@ -109,7 +152,12 @@ def build_event_contribution_matrix( matrix[event["id"]][candidate_time] = { "points": round(sum(points) / len(points), 4), "rule_ids": sorted({rule for item in evidences for rule in item["rule_ids"]}), - "technique_layers": sorted({rule.split(":", 1)[0] for item in evidences for rule in item["rule_ids"]}), + "technique_layers": sorted({ + rule.split(":", 1)[0] + for item in evidences + for rule in item["rule_ids"] + if not rule.startswith(("event_kind:", "event_kind_profile:")) + }), } winner = max(set(winners), key=winners.count) mean = sum(matrix[event["id"]][time]["points"] for time in candidate_grid) / len(candidate_grid) diff --git a/tests/test_rectification_diagnostics_clusters.py b/tests/test_rectification_diagnostics_clusters.py index 223fb87d..439e25d1 100644 --- a/tests/test_rectification_diagnostics_clusters.py +++ b/tests/test_rectification_diagnostics_clusters.py @@ -48,6 +48,56 @@ class RectificationDiagnosticsClustersTest(unittest.TestCase): "end": "05:16", }) + def test_candidate_split_reports_actual_candidate_deltas_not_global_activation(self): + stable_event = "00000000-0000-4000-8000-000000000001" + separating_event = "00000000-0000-4000-8000-000000000002" + rows = [ + { + "time": "05:13", "score": 100, "missing_layers": [], + "evidence": [ + {"event_id": stable_event, "domain": "relationship", "candidate_time": "05:13", "rule_ids": ["D9:a"], "points": 50}, + {"event_id": separating_event, "domain": "education", "candidate_time": "05:13", "rule_ids": ["D24:a"], "points": 50}, + ], + }, + { + "time": "05:14", "score": 90, "missing_layers": [], + "evidence": [ + {"event_id": stable_event, "domain": "relationship", "candidate_time": "05:14", "rule_ids": ["D9:b"], "points": 50}, + {"event_id": separating_event, "domain": "education", "candidate_time": "05:14", "rule_ids": ["D24:b"], "points": 40}, + ], + }, + ] + built = { + "date_sensitivity": [], + "static_contexts": [ + {"feature": { + "time": "05:13", + "varga_ascendants": {"D9": 1, "D24": 2}, + "arudha_signs": {}, + "fingerprints": {"ashtakavarga": "same", "shadbala": "same"}, + }}, + {"feature": { + "time": "05:14", + "varga_ascendants": {"D9": 1, "D24": 3}, + "arudha_signs": {}, + "fingerprints": {"ashtakavarga": "same", "shadbala": "same"}, + }}, + ], + "matrix": { + stable_event: { + "05:13": {"points": 50, "technique_layers": ["D9", "D24"]}, + "05:14": {"points": 50, "technique_layers": ["D9", "D24"]}, + }, + separating_event: { + "05:13": {"points": 50, "technique_layers": ["D9", "D24"]}, + "05:14": {"points": 40, "technique_layers": ["D9", "D24"]}, + }, + }, + } + result = run_diagnostics({"events": [{"id": stable_event, "domain": "relationship"}, {"id": separating_event, "domain": "education"}]}, rows, built) + self.assertEqual(result["candidate_splits"][0]["technique_layers"], ["D24"]) + self.assertEqual(result["candidate_splits"][0]["event_ids"], [separating_event]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index 2ed2bd5f..a9c6ef54 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -113,6 +113,71 @@ class RectificationV5ServicesTest(unittest.TestCase): normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28)) self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event") + def test_relationship_end_is_not_scoreable(self): + with self.assertRaisesRegex(ValueError, "event_kind does not match domain"): + normalize_rectification_request( + request(domain="relationship", event_kind="relationship_end"), + today=date(2026, 7, 28), + ) + + def test_relationship_start_and_change_use_distinct_rule_conditioned_profiles(self): + def relationship_rows(_value): + return [ + { + "time": "05:13", "score": 10, + "evidence": [{ + "event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13", + "rule_ids": ["vim_md_domain_house", "vim_ad_functional_benefic_auxiliary"], + "points": 10, + }], + "missing_layers": [], + }, + { + "time": "05:14", "score": 9, + "evidence": [{ + "event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:14", + "rule_ids": ["controlled_transit_saturn_domain_house", "vim_ad_functional_malefic_auxiliary"], + "points": 9, + }], + "missing_layers": [], + }, + ] + + start = normalize_rectification_request( + request(domain="relationship", event_kind="relationship_start"), today=date(2026, 7, 28) + ) + change = normalize_rectification_request( + request(domain="relationship", event_kind="relationship_change"), today=date(2026, 7, 28) + ) + + start_matrix = build_event_contribution_matrix(start, row_provider=relationship_rows) + change_matrix = build_event_contribution_matrix(change, row_provider=relationship_rows) + + self.assertGreater(start_matrix["matrix"][EVENT_ID]["05:13"]["points"], start_matrix["matrix"][EVENT_ID]["05:14"]["points"] ) + self.assertGreater(change_matrix["matrix"][EVENT_ID]["05:14"]["points"], change_matrix["matrix"][EVENT_ID]["05:13"]["points"] ) + self.assertEqual(change_matrix["date_sensitivity"][0]["sample_winners"], ["05:14", "05:14", "05:14"]) + self.assertNotIn("event_kind_profile", change_matrix["matrix"][EVENT_ID]["05:14"]["technique_layers"]) + + def test_relationship_kind_profile_does_not_create_points_without_activation(self): + normalized = normalize_rectification_request( + request(domain="relationship", event_kind="relationship_change", precision="day"), + today=date(2026, 7, 28), + ) + + def rows(_value): + return [{ + "time": "05:13", "score": 0, + "evidence": [{ + "event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13", + "rule_ids": ["no_domain_activation", "event_kind:relationship_change"], + "points": 0, + }], + "missing_layers": [], + }] + + built = build_event_contribution_matrix(normalized, row_provider=rows) + self.assertEqual(built["matrix"][EVENT_ID]["05:13"]["points"], 0) + def test_date_sampling_preserves_declared_range_and_uses_bounded_samples(self): base = request()["events"][0] self.assertEqual(sample_event_dates({**base, "precision": "month"}), ["2016-09-01", "2016-09-15", "2016-09-30"]) @@ -167,7 +232,7 @@ class RectificationV5ServicesTest(unittest.TestCase): } feature = { "calculation_spec_hash": "0" * 64, - "algorithm_version": "rectification-v5-matrix-scoring-1", + "algorithm_version": "rectification-v5-matrix-scoring-2", "candidate_count": 2, "feature_hash": "1" * 64, "features": [{"time": "05:13"}, {"time": "05:14"}],