feat(rectification): drive questions from candidate contrast
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -88,6 +88,14 @@ function snapshotInput(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user