Files
Jyotisha/frontend/tests/rectification-agent-contracts.test.ts

169 lines
6.8 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
validateRectificationDecision,
type DiagnosticsSummary,
type QuestionOpportunity,
} from "../src/lib/rectification-agent/contracts.ts";
import { assertRectificationSkillLoaded } from "../src/lib/rectification-agent/skill-runtime.ts";
import { recordRectificationAgentTelemetry } from "../src/lib/rectification-agent/telemetry.ts";
const caseId = "00000000-0000-4000-8000-000000000800";
const opportunityId = "00000000-0000-4000-8000-000000000801";
const snapshotId = "00000000-0000-4000-8000-000000000802";
const diagnostics: DiagnosticsSummary = {
id: "00000000-0000-4000-8000-000000000803",
caseId,
snapshotId,
primaryClusterRetentionRate: 0.8,
leaveOneEventOutRetentionRate: 0.75,
leaveOneDomainOutRetentionRate: 0.7,
dateSensitivityRetentionRate: 0.72,
neighborSupportMinutes: 8,
primarySecondaryMarginPercent: 12,
clusterMassRatio: 0.65,
unstableEventIds: [],
mostDiscriminatingLayers: ["D9"],
eventDateSensitivity: [],
candidateSplits: [],
calculationHash: "c".repeat(64),
createdAt: "2026-07-28T00:00:00.000Z",
};
const opportunity: QuestionOpportunity = {
contractVersion: "semantic-question-v2",
opportunityId,
kind: "ask_new_event",
domain: "career",
targetEventId: null,
goal: "收集一件有大致日期的职业转折。",
requestedFields: ["new_dated_event"],
anchors: [],
contextFacts: ["职业领域尚未覆盖。"],
forbiddenMoves: ["switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace"],
fallbackPrompt: "请说一件时间比较明确的职业变化经历。",
reason: "当前证据领域覆盖不足。",
expectedInformationGain: 0.8,
dateSensitivity: 0.5,
candidateSplitRelevance: 0.7,
domainCoverageGain: 0.6,
recallEase: 0.8,
novelty: 1,
repetitionPenalty: 0,
privacyCost: 0.1,
utility: 0.69,
active: true,
};
function validate(decision: unknown, overrides: Partial<Parameters<typeof validateRectificationDecision>[0]> = {}) {
return validateRectificationDecision({
decision,
caseId,
snapshotId,
opportunities: [opportunity],
diagnostics,
candidateRangeOfferAllowed: true,
...overrides,
});
}
test("only an active server-owned opportunity can be selected", () => {
assert.deepEqual(validate({
action: "ask_question", opportunityId, narrativeFocus: ["latest_event"],
}, { opportunities: [{ ...opportunity, active: false }] }).issues, ["opportunity_not_active"]);
assert.deepEqual(validate({
action: "ask_question", opportunityId: "00000000-0000-4000-8000-000000000899", narrativeFocus: [],
}).issues, ["opportunity_not_active"]);
});
test("candidate ranges require both the policy gate and the current snapshot", () => {
assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId }, {
candidateRangeOfferAllowed: false,
}).issues, ["candidate_range_gate_failed"]);
assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId }, {
snapshotId: "00000000-0000-4000-8000-000000000898",
}).issues, ["snapshot_not_current"]);
});
test("diagnostic reads are bounded and cannot target another case", () => {
assert.deepEqual(validate({ action: "run_diagnostic", diagnostic: "neighbor_stability" }, {
usedDiagnostics: ["neighbor_stability"],
}).issues, ["diagnostic_already_run"]);
assert.deepEqual(validate({ action: "ask_question", opportunityId, narrativeFocus: [] }, {
caseId: "00000000-0000-4000-8000-000000000897",
toolCallCount: 2,
maxToolCalls: 1,
}).issues, ["diagnostics_case_mismatch", "tool_call_budget_exceeded"]);
});
test("model output cannot inject a minute, question, event, or score", () => {
for (const extra of [
{ birthMinute: "06:21" },
{ prompt: "模型自己写的问题" },
{ eventId: "00000000-0000-4000-8000-000000000896" },
{ score: 99 },
]) {
assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId, ...extra }).issues, ["decision_schema_invalid"]);
}
});
test("agent telemetry rejects malformed events and warns on failures", () => {
const info: string[] = [];
const warnings: string[] = [];
const originalInfo = console.info;
const originalWarn = console.warn;
console.info = (message) => info.push(String(message));
console.warn = (message) => warnings.push(String(message));
try {
recordRectificationAgentTelemetry({
caseId, phase: "reasoner", outcome: "failed", modelId: "test-model", toolName: null,
decisionAction: null, durationMs: 12, errorCode: "model_unavailable", deploymentSha: "test-sha",
});
recordRectificationAgentTelemetry({
caseId, phase: "reasoner", outcome: "failed", modelId: "", toolName: null,
decisionAction: null, durationMs: 12, errorCode: "model_unavailable", deploymentSha: "test-sha",
});
} finally {
console.info = originalInfo;
console.warn = originalWarn;
}
assert.equal(info.length, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0] ?? "", /\[rectification-agent\].*"outcome":"failed"/);
});
test("skill loading emits explicit success or failure telemetry without leaking a path", async () => {
const info: string[] = [];
const warnings: string[] = [];
const originalInfo = console.info;
const originalWarn = console.warn;
console.info = (message) => info.push(String(message));
console.warn = (message) => warnings.push(String(message));
try {
await assertRectificationSkillLoaded({ getSkill: async () => ({ name: "birth-time-rectification" }) }, { caseId, modelId: "test-model", deploymentSha: "test-sha" });
await assert.rejects(
assertRectificationSkillLoaded({ getSkill: async () => null }, { caseId, modelId: "test-model", deploymentSha: "test-sha" }),
/rectification_skill_not_loaded/,
);
} finally {
console.info = originalInfo;
console.warn = originalWarn;
}
assert.match(info[0] ?? "", /"phase":"skill".*"loadStatus":"loaded"/);
assert.match(warnings[0] ?? "", /"phase":"skill".*"errorCode":"rectification_skill_not_loaded"/);
assert.doesNotMatch([...info, ...warnings].join("\n"), /private\/tmp|SKILL\.md/);
});
test("durable event semantics migration validates and persists subject fields", () => {
const migration = readFileSync(new URL(
"../supabase/migrations/20260728010000_conversational_event_semantics.sql",
import.meta.url,
), "utf8");
assert.match(migration, /subject in \('self', 'family', 'partner', 'other'\)/);
assert.match(migration, /related_person/);
assert.match(migration, /save_conversational_rectification_turn/);
assert.match(migration, /import_legacy_conversational_rectification_case/);
assert.match(migration, /event_kind/);
assert.match(migration, /scoreability/);
});