feat(rectification): drive questions from candidate contrast

This commit is contained in:
Jesse_Chen
2026-07-31 13:17:00 +08:00
parent fdf791953e
commit a7a11a1a7a
21 changed files with 703 additions and 42 deletions
@@ -90,6 +90,19 @@ export const rectificationTurnPlanSchema = z.object({
}).strict();
export type RectificationTurnPlan = z.infer<typeof rectificationTurnPlanSchema>;
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<typeof candidateContrastPacketSchema>;
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(),
@@ -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"] },
});
}
@@ -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<QuestionOpportunity["kind"], number> = {
type OpportunityInput = Omit<SemanticQuestionOpportunity, "contractVersion" | "opportunityId" | "utility" | "active" | "forbiddenMoves">;
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
@@ -27,7 +27,7 @@ const newEventDomainTerms: Readonly<Partial<Record<QuestionOpportunity["domain"]
};
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
const openingMessageSchema = z.object({ message: z.string().trim().min(1).max(1_000) }).strict();
const domainChecklistTerms = /(?:学业|教育|搬家|迁居|感情|婚姻|工作|职业|财务|健康)/g;
const fixedChoiceStructure = /(?:从|在)[^。!??\n]{1,80}(?:、||,|或|或者)[^。!??\n]{1,80}(?:(?:中|里|方面)(?:选|选择|挑|说|讲|开始)|(?:选|选择|挑)(?:一|1)?(?:个|件|段))|按[^。!??\n]{1,80}(?:依次|逐一|分别)(?:回答|说|讲)/;
export type OpeningQuestionGenerator = (prompt: string, phase: "generate" | "repair") => Promise<Readonly<{ object: unknown }>>;
@@ -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 };
}
@@ -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<string, Promise<RectificationV4Case | null>>();
@@ -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 }));
}
@@ -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<typeof rectificationDeploymentModeSchema>;
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,
@@ -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;
}
@@ -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;
@@ -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,