feat(rectification): drive questions from candidate contrast
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user