253 lines
9.1 KiB
TypeScript
253 lines
9.1 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import {
|
||
birthTimeGuideRequestSchema,
|
||
type BirthTimeGuideGenerator,
|
||
} from "../src/lib/birth-time-guide-agent.ts";
|
||
import {
|
||
BirthTimeGuideActionError,
|
||
createBirthTimeGuideService,
|
||
} from "../src/lib/birth-time-guide-service.ts";
|
||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||
import type { LegacyStoredRectificationCase, StoredRectificationCase, VersionedJourneyResponse } from "../src/lib/birth-time-journey-service.ts";
|
||
import { storedJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
|
||
import { createInitialJourneyTurn } from "../src/lib/birth-time-journey-turn.ts";
|
||
import type { QuestionSpec } from "../src/lib/birth-time-question-planner.ts";
|
||
|
||
const caseId = "7299894c-10a8-4b45-91d1-339007282c50";
|
||
const actionId = "c70ea014-f8b4-41f2-9305-e4ae60c0d4d1";
|
||
|
||
type ProposedDraftCall = {
|
||
readonly userId: string;
|
||
readonly caseId: string;
|
||
readonly actionId: string;
|
||
readonly turnVersion: number;
|
||
readonly domain: string;
|
||
readonly precision: string | null;
|
||
readonly date: string | null;
|
||
};
|
||
|
||
function careerQuestion(): QuestionSpec {
|
||
return {
|
||
questionId: "baseline_career_1",
|
||
phase: "baseline",
|
||
domain: "career",
|
||
requestedPrecision: ["year", "month"],
|
||
allowUnknown: true,
|
||
purposeCode: "candidate_difference_career",
|
||
plannerVersion: "candidate-difference-v1",
|
||
};
|
||
}
|
||
|
||
function storedCase(): LegacyStoredRectificationCase {
|
||
return {
|
||
id: caseId,
|
||
userId: "owner-1",
|
||
journeyProtocol: "legacy-guided-v1",
|
||
snapshot: {
|
||
state: "rectifying",
|
||
assistantIntent: "continue_rectification_questions",
|
||
input: "rectification_questions",
|
||
route: "rectification",
|
||
confidence: null,
|
||
canApply: false,
|
||
activeTime: null,
|
||
reportedRange: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
|
||
},
|
||
questionnaire: { questions: [], samples: [], raw: {} },
|
||
answers: {},
|
||
lifeEvents: [],
|
||
turnVersion: 4,
|
||
turnState: createInitialJourneyTurn(careerQuestion(), 4),
|
||
processedActionIds: [],
|
||
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
|
||
evidenceDraft: null,
|
||
};
|
||
}
|
||
|
||
function generator(text: string, onGenerate?: () => void): BirthTimeGuideGenerator {
|
||
return {
|
||
async generate() {
|
||
onGenerate?.();
|
||
return { text };
|
||
},
|
||
};
|
||
}
|
||
|
||
function service(input?: {
|
||
readonly stored?: StoredRectificationCase | null;
|
||
readonly generator?: BirthTimeGuideGenerator | null;
|
||
readonly onLoad?: (userId: string, loadedCaseId: string) => void;
|
||
readonly onPropose?: (value: ProposedDraftCall) => void;
|
||
}) {
|
||
const stored = input?.stored === undefined ? storedCase() : input.stored;
|
||
return createBirthTimeGuideService({
|
||
generator: input?.generator ?? null,
|
||
timeoutMs: 20,
|
||
async loadCase(userId, loadedCaseId) {
|
||
input?.onLoad?.(userId, loadedCaseId);
|
||
return stored;
|
||
},
|
||
async proposeEvidenceDraft(userId, loadedCaseId, receivedActionId, turnVersion, proposal) {
|
||
input?.onPropose?.({
|
||
userId,
|
||
caseId: loadedCaseId,
|
||
actionId: receivedActionId,
|
||
turnVersion,
|
||
...proposal,
|
||
});
|
||
return storedJourneyResponse(storedCase()) satisfies VersionedJourneyResponse;
|
||
},
|
||
});
|
||
}
|
||
|
||
test("render question uses an owner-scoped load and one neutral model question", async () => {
|
||
let loaded: readonly string[] = [];
|
||
const result = await service({
|
||
generator: generator(JSON.stringify({ variant: "gentle" })),
|
||
onLoad: (userId, loadedCaseId) => { loaded = [userId, loadedCaseId]; },
|
||
}).renderQuestion("owner-1", caseId);
|
||
|
||
assert.deepEqual(loaded, ["owner-1", caseId]);
|
||
assert.equal(result.type, "question");
|
||
assert.equal(result.questionId, "baseline_career_1");
|
||
assert.equal(result.source, "agent");
|
||
assert.doesNotMatch(result.question, /候选|支持|更符合|更接近/);
|
||
});
|
||
|
||
test("render question falls back when model is absent, throws, times out, or returns invalid output", async () => {
|
||
const failures: readonly (BirthTimeGuideGenerator | null)[] = [
|
||
null,
|
||
{ async generate() { throw new Error("offline"); } },
|
||
{ async generate() { return Promise.reject("offline"); } },
|
||
{ async generate() { return new Promise(() => undefined); } },
|
||
generator(JSON.stringify({ variant: "relationship" })),
|
||
generator(JSON.stringify({ variant: "direct", domain: "relationship" })),
|
||
generator(JSON.stringify({ question: "Which relationship changed?" })),
|
||
generator(JSON.stringify({ question: "具体是哪一天?" })),
|
||
generator(JSON.stringify({ question: "04:00 这个候选时间更符合吗?" })),
|
||
generator(JSON.stringify({ variant: "direct", question: "越权覆盖" })),
|
||
];
|
||
for (const failingGenerator of failures) {
|
||
const result = await service({ generator: failingGenerator }).renderQuestion("owner-1", caseId);
|
||
assert.equal(result.source, "fallback");
|
||
assert.equal((result.question.match(/[??]/g) ?? []).length, 1);
|
||
assert.match(result.question, /工作|职业方向|身份变化/);
|
||
assert.doesNotMatch(result.question, /关系进入|关系结束|哪一天|具体日期|04[::]00|Which/);
|
||
}
|
||
});
|
||
|
||
test("non-question turns are rejected", async () => {
|
||
const stored = storedCase();
|
||
const turn = createInitialJourneyTurn(careerQuestion(), 4);
|
||
const scoring = {
|
||
...stored,
|
||
turnState: {
|
||
...turn,
|
||
nextAction: { kind: "score_pending", jobId: "75a5fbb3-bf1d-44b2-8c74-c92cf7578f82" },
|
||
progress: { ...turn.progress, phase: "scoring" },
|
||
},
|
||
} satisfies StoredRectificationCase;
|
||
|
||
await assert.rejects(
|
||
service({ stored: scoring }).renderQuestion("owner-1", caseId),
|
||
BirthTimeGuideActionError,
|
||
);
|
||
});
|
||
|
||
test("draft keeps action/version and calls only the review-only proposal action", async () => {
|
||
let proposed: ProposedDraftCall | null = null;
|
||
const result = await service({
|
||
generator: generator(JSON.stringify({ domain: "career", precision: "month", date: "2023-04" })),
|
||
onPropose: (value) => { proposed = value; },
|
||
}).draftEvidence("owner-1", {
|
||
caseId,
|
||
actionId,
|
||
turnVersion: 4,
|
||
message: "我在 2023 年 4 月换了工作",
|
||
});
|
||
|
||
assert.deepEqual(proposed, {
|
||
userId: "owner-1",
|
||
caseId,
|
||
actionId,
|
||
turnVersion: 4,
|
||
domain: "career",
|
||
precision: "month",
|
||
date: "2023-04",
|
||
});
|
||
assert.equal(result.type, "evidence_draft");
|
||
assert.equal(result.actionId, actionId);
|
||
assert.equal(result.requestedTurnVersion, 4);
|
||
assert.equal("nextAction" in result, false);
|
||
assert.equal(result.turn.caseId, caseId);
|
||
});
|
||
|
||
test("stale draft versions fail before model generation or mutation", async () => {
|
||
let generations = 0;
|
||
let proposals = 0;
|
||
await assert.rejects(
|
||
service({
|
||
generator: generator("{}", () => { generations += 1; }),
|
||
onPropose: () => { proposals += 1; },
|
||
}).draftEvidence("owner-1", {
|
||
caseId,
|
||
actionId,
|
||
turnVersion: 3,
|
||
message: "2023 年换工作",
|
||
}),
|
||
StaleJourneyTurnError,
|
||
);
|
||
assert.equal(generations, 0);
|
||
assert.equal(proposals, 0);
|
||
});
|
||
|
||
test("a processed draft action replays the persisted turn before stale checking", async () => {
|
||
let generations = 0;
|
||
let proposals = 0;
|
||
const stored = { ...storedCase(), processedActionIds: [actionId] } satisfies StoredRectificationCase;
|
||
const result = await service({
|
||
stored,
|
||
generator: generator("{}", () => { generations += 1; }),
|
||
onPropose: () => { proposals += 1; },
|
||
}).draftEvidence("owner-1", {
|
||
caseId,
|
||
actionId,
|
||
turnVersion: 1,
|
||
message: "2023 年换工作",
|
||
});
|
||
|
||
assert.equal(generations, 0);
|
||
assert.equal(proposals, 0);
|
||
assert.equal(result.turn.turnVersion, 4);
|
||
assert.equal(result.requestedTurnVersion, 1);
|
||
});
|
||
|
||
test("guide requests are strict and bound the natural-language message", () => {
|
||
const valid = { type: "draft_evidence", caseId, actionId, turnVersion: 4, message: " 2023 年换工作 " };
|
||
const parsed = birthTimeGuideRequestSchema.parse(valid);
|
||
assert.equal(parsed.type === "draft_evidence" ? parsed.message : null, "2023 年换工作");
|
||
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, score: 10 }).success, false);
|
||
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, message: "" }).success, false);
|
||
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, message: "字".repeat(501) }).success, false);
|
||
assert.equal(birthTimeGuideRequestSchema.safeParse({ type: "render_question", caseId, extra: true }).success, false);
|
||
});
|
||
|
||
test("route authenticates before body parsing and has no privileged workflow imports", () => {
|
||
const source = readFileSync(new URL("../src/app/api/birth-time-guide/route.ts", import.meta.url), "utf8");
|
||
assert.ok(source.indexOf("auth.getUser") < source.indexOf("requestPayload(request)"));
|
||
assert.match(source, /BirthTimeJourneyEngineConfigurationError/);
|
||
for (const forbidden of [
|
||
"begin_consultation_credit",
|
||
"getJyotishAgent",
|
||
"consultationTool",
|
||
"scoreEvents",
|
||
"saveBirthTimeCandidate",
|
||
"confirmBirthTimeCandidate",
|
||
"active_birth_time",
|
||
]) {
|
||
assert.doesNotMatch(source, new RegExp(forbidden));
|
||
}
|
||
});
|