213 lines
8.8 KiB
TypeScript
213 lines
8.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
MAX_RESUMABLE_CASES_PER_USER,
|
|
RECTIFICATION_CASE_STATUSES,
|
|
RECTIFICATION_SKILL_NAME,
|
|
RECTIFICATION_SKILL_VERSION,
|
|
RESUMABLE_CASE_STATUSES,
|
|
TERMINAL_CASE_STATUSES,
|
|
canTransitToTerminal,
|
|
evidenceWritesAllowed,
|
|
isRectificationCaseStatus,
|
|
isResumableStatus,
|
|
isTerminalStatus,
|
|
} from "../src/lib/rectification-agentic/v9/case-status.ts";
|
|
import {
|
|
BACKGROUND_ONLY_KINDS,
|
|
DISTINCT_KIND_GROUPS,
|
|
EVIDENCE_KINDS,
|
|
canTransitEvidenceStatus,
|
|
isBackgroundEvidenceKind,
|
|
isDatePrecision,
|
|
isEvidenceDomain,
|
|
isEvidenceKind,
|
|
isEvidenceStatus,
|
|
normalizeQuote,
|
|
quoteIsGroundedInMessage,
|
|
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
|
import {
|
|
PUBLIC_ACTIVITY_EVENTS,
|
|
PUBLIC_RECTIFICATION_PHASES,
|
|
PUBLIC_RECTIFICATION_TOOLS,
|
|
safeActivityEvent,
|
|
} from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
|
import {
|
|
openRectificationCaseRequestSchema,
|
|
shouldStartOpening,
|
|
} from "../src/lib/rectification-agentic/v9/open-request.ts";
|
|
|
|
const skillDirectory = fileURLToPath(
|
|
new URL("../../skills/jyotish-birth-time-rectification", import.meta.url),
|
|
);
|
|
const skill = readFileSync(`${skillDirectory}/SKILL.md`, "utf8");
|
|
const references = [
|
|
"evidence-model.md",
|
|
"conversation-strategy.md",
|
|
"candidate-comparison.md",
|
|
"technique-routing.md",
|
|
"truth-consent-boundaries.md",
|
|
];
|
|
|
|
test("v9 case status machine separates resumable from terminal statuses", () => {
|
|
assert.deepEqual([...RESUMABLE_CASE_STATUSES], [
|
|
"draft",
|
|
"collecting_evidence",
|
|
"candidate_ready",
|
|
"candidate_accepted",
|
|
"needs_rebaseline",
|
|
"paused",
|
|
]);
|
|
assert.deepEqual([...TERMINAL_CASE_STATUSES], [
|
|
"confirmed",
|
|
"closed",
|
|
"abandoned",
|
|
"superseded",
|
|
]);
|
|
for (const status of RECTIFICATION_CASE_STATUSES) {
|
|
assert.equal(isRectificationCaseStatus(status), true);
|
|
assert.equal(isResumableStatus(status), RESUMABLE_CASE_STATUSES.includes(status));
|
|
assert.equal(isTerminalStatus(status), TERMINAL_CASE_STATUSES.includes(status));
|
|
}
|
|
assert.equal(isRectificationCaseStatus("mystery"), false);
|
|
});
|
|
|
|
test("terminal transitions are one-way and evidence writes stop at terminal", () => {
|
|
assert.equal(canTransitToTerminal("candidate_accepted", "confirmed"), true);
|
|
assert.equal(canTransitToTerminal("collecting_evidence", "closed"), true);
|
|
assert.equal(canTransitToTerminal("confirmed", "closed"), false);
|
|
assert.equal(canTransitToTerminal("closed", "candidate_ready"), false);
|
|
assert.equal(evidenceWritesAllowed("collecting_evidence"), true);
|
|
assert.equal(evidenceWritesAllowed("candidate_accepted"), true);
|
|
assert.equal(evidenceWritesAllowed("needs_rebaseline"), true);
|
|
assert.equal(evidenceWritesAllowed("confirmed"), false);
|
|
assert.equal(evidenceWritesAllowed("closed"), false);
|
|
assert.equal(evidenceWritesAllowed("superseded"), false);
|
|
assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1);
|
|
});
|
|
|
|
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
|
|
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
|
|
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.0");
|
|
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
|
|
assert.match(skill, /^version: 10\.0\.0$/m);
|
|
for (const reference of references) {
|
|
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
|
|
assert.ok(content.length > 0, `${reference} must be non-empty`);
|
|
}
|
|
assert.ok(skill.split("\n").length <= 200, "SKILL.md must stay within 200 lines");
|
|
});
|
|
|
|
test("skill keeps the method in the skill, not a hard-coded questionnaire", () => {
|
|
// The skill may only mention the old questionnaire as an explicit
|
|
// prohibition, never as a requirement.
|
|
assert.match(skill, /不再有固定 10[–-]15 个事件/);
|
|
assert.match(skill, /固定 80%\/60% 匹配率/);
|
|
assert.match(skill, /固定 A\/B\/C\/D 问卷/);
|
|
assert.match(skill, /不得伪造出生分钟/);
|
|
assert.match(skill, /日期精度真实保留/);
|
|
assert.match(skill, /candidate[\s\S]*accepted[\s\S]*confirmed/);
|
|
});
|
|
|
|
test("evidence model exposes the full kind/domain/precision/status sets", () => {
|
|
for (const kind of EVIDENCE_KINDS) assert.equal(isEvidenceKind(kind), true);
|
|
assert.equal(isEvidenceKind("career"), false);
|
|
assert.equal(isEvidenceDomain("career"), true);
|
|
assert.equal(isEvidenceDomain("nope"), false);
|
|
for (const precision of ["year", "month", "day", "range", "unknown"]) {
|
|
assert.equal(isDatePrecision(precision), true);
|
|
}
|
|
assert.equal(isDatePrecision("exact_minute"), false);
|
|
for (const status of ["draft", "pending_confirmation", "confirmed", "superseded", "rejected"]) {
|
|
assert.equal(isEvidenceStatus(status), true);
|
|
}
|
|
assert.equal(isEvidenceStatus("collected"), false);
|
|
});
|
|
|
|
test("semantically distinct kinds are never folded together", () => {
|
|
const flat = DISTINCT_KIND_GROUPS.flat();
|
|
assert.ok(flat.includes("career_entry") && flat.includes("career_pressure") && flat.includes("career_exit"));
|
|
assert.ok(flat.includes("relationship_start") && flat.includes("relationship_commitment") && flat.includes("relationship_separation"));
|
|
assert.equal(new Set(flat).size, flat.length);
|
|
});
|
|
|
|
test("only the server confirmation path may produce confirmed evidence", () => {
|
|
assert.equal(canTransitEvidenceStatus("draft", "pending_confirmation"), true);
|
|
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
|
|
assert.equal(canTransitEvidenceStatus("confirmed", "superseded"), true);
|
|
assert.equal(canTransitEvidenceStatus("superseded", "confirmed"), false);
|
|
assert.equal(canTransitEvidenceStatus("rejected", "confirmed"), false);
|
|
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), true);
|
|
});
|
|
|
|
test("quote grounding normalizes whitespace and punctuation", () => {
|
|
assert.equal(
|
|
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
|
|
"2016年9月,我离开家去北京开始工作。".replace(/[\s\u3000,。!?、;:“”‘’()《》·—…]/g, ""),
|
|
);
|
|
assert.equal(quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"), true);
|
|
assert.equal(quoteIsGroundedInMessage("我去了上海", "去了北京"), false);
|
|
assert.equal(quoteIsGroundedInMessage("", "任意"), false);
|
|
});
|
|
|
|
test("family/other are background-only kinds that never advance scoring", () => {
|
|
assert.equal(isBackgroundEvidenceKind("family_event"), true);
|
|
assert.equal(isBackgroundEvidenceKind("other"), true);
|
|
assert.equal(isBackgroundEvidenceKind("career_entry"), false);
|
|
assert.equal(BACKGROUND_ONLY_KINDS.size, 2);
|
|
});
|
|
|
|
test("public receipt allowlists are exact and deny unknown values", () => {
|
|
for (const phase of PUBLIC_RECTIFICATION_PHASES) {
|
|
assert.equal(safeActivityEvent(phase), phase);
|
|
}
|
|
assert.equal(safeActivityEvent("provider.reasoning"), null);
|
|
assert.equal(safeActivityEvent("tool.payload"), null);
|
|
assert.equal(PUBLIC_ACTIVITY_EVENTS.length, PUBLIC_RECTIFICATION_PHASES.length);
|
|
assert.ok(PUBLIC_RECTIFICATION_TOOLS.includes("rectification-read-case"));
|
|
assert.ok(!(PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes("rectification-scan"));
|
|
});
|
|
|
|
test("open request schemas reject userId, birth data and range from the browser", () => {
|
|
const homepage = openRectificationCaseRequestSchema.safeParse({
|
|
intent: "homepage",
|
|
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
});
|
|
assert.equal(homepage.success, true);
|
|
const withUserId = openRectificationCaseRequestSchema.safeParse({
|
|
intent: "homepage",
|
|
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
userId: "ffffffff-0000-4aaa-9bbb-cccccccccccc",
|
|
});
|
|
assert.equal(withUserId.success, false);
|
|
const withBirthData = openRectificationCaseRequestSchema.safeParse({
|
|
intent: "homepage",
|
|
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
birth_date: "1997-08-08",
|
|
});
|
|
assert.equal(withBirthData.success, false);
|
|
const withRange = openRectificationCaseRequestSchema.safeParse({
|
|
intent: "homepage",
|
|
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
candidate_range: { start_time: "04:00", end_time: "06:00" },
|
|
});
|
|
assert.equal(withRange.success, false);
|
|
const supersedeTrue = openRectificationCaseRequestSchema.safeParse({
|
|
intent: "new",
|
|
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
supersedeActive: true,
|
|
});
|
|
assert.equal(supersedeTrue.success, false);
|
|
});
|
|
|
|
test("shouldStartOpening is server-owned: only freshly created never-started cases", () => {
|
|
assert.equal(shouldStartOpening("created", 0), true);
|
|
assert.equal(shouldStartOpening("created", 1), false);
|
|
assert.equal(shouldStartOpening("resumed", 0), false);
|
|
assert.equal(shouldStartOpening("resumed", 5), false);
|
|
assert.equal(shouldStartOpening("readonly", 0), false);
|
|
});
|