feat: generate constrained dynamic choice questions

This commit is contained in:
Jesse_Chen
2026-07-19 02:28:54 +08:00
parent 437d50fe1e
commit 1ffc09e89a
7 changed files with 702 additions and 7 deletions
+40
View File
@@ -0,0 +1,40 @@
# Task 4 Report — Constrained Dynamic Question Generation
## Scope
- Base implementation commit: `437d50f` (with plan-only commits through `f90a3bd`).
- Implemented only the Task 4 files listed in `.superpowers/sdd/task-4-brief.md`.
- Preserved the untracked `.omo/` directory and did not change dependencies.
## RED evidence
1. After adding the dynamic prompt/parser/binding and guide-service tests:
- Command: bundled Node `--test tests/birth-time-guide-agent.test.ts tests/birth-time-guide-route.test.ts`
- Expected failures: `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-question-validator.ts` and four `generateQuestion is not a function` failures.
- Legacy guide-route tests remained green.
2. After adding the exact-JSON boundary test:
- Command: bundled Node `--test tests/birth-time-guide-route.test.ts`
- Expected failure: wrapped commentary was accepted in one call (`1 !== 2`) instead of being rejected, retried once, and falling back.
## GREEN implementation
- Added a strict discriminated model-output parser for one server-issued opportunity and its exact unique partition set.
- The prompt projection contains only model-safe opportunity copy and an optional trimmed unmatched note. It excludes candidate times, score vectors, candidate model state, confidence/control fields, ranges, information-gain values, and history fingerprints.
- Added content/length controls for one Simplified-Chinese-facing question, 24 labels, birth-time strings, confidence, candidate support, methodology, and server-control claims.
- Added server-created UUID injection, private score-vector attachment, two server-owned special choices, SHA-256 normalized public-semantic fingerprints, and repeated question/partition rejection.
- Added one model retry, exact-JSON enforcement, deterministic top-opportunity fallback, advisory-only `no_useful_question`, and a persisted low terminal transition when no usable opportunity remains.
- Added the Mastra dynamic task contract while preserving legacy question-variant and evidence-draft behavior.
## Verification
- Focused guide tests: **31/31 pass**.
- All frontend birth-time tests: **218/218 pass**.
- ESLint on all six owned source/test targets: **pass**.
- `git diff --check`: **pass**.
- Source LOC: validator **233**, guide agent **216**, guide service **250**.
- Full `tsc --noEmit`: Task 4 has no type errors; the command still fails only at the pre-existing `tests/profile-persistence.test.ts:7` ES2018 regex-target error.
- Mandatory pre-work check reached all audits but retains the known unrelated fragment-governance mismatch: `candidate_count` expected `0`, actual `1`; remote visibility remained blocked and no synchronization claim was made.
## Integration note
`createBirthTimeGuideService()` commits both question and terminal outcomes through the injected `commitDynamicQuestion` port. Task 5/6 own the transactional store implementation and irreversible turn guards; this task does not bypass or pre-implement those later persistence transitions.
@@ -0,0 +1,233 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { BirthTimeGuideOutputError } from "./birth-time-guide-agent.ts";
import {
persistedDynamicChoiceQuestionSchema,
type CandidateDifferenceBuild,
type CandidateDifferencePacket,
type PersistedDynamicChoiceQuestion,
type QuestionOpportunity,
} from "./birth-time-dynamic-choice-internal.ts";
const modelQuestionSchema = z.object({
kind: z.literal("question"),
opportunityId: z.string().trim().min(1),
prompt: z.string().trim().min(1).max(120),
options: z.array(z.object({
partitionId: z.string().trim().min(1),
label: z.string().trim().min(1).max(80),
}).strict()).min(2).max(4),
}).strict();
const noUsefulQuestionSchema = z.object({
kind: z.literal("no_useful_question"),
}).strict();
const dynamicQuestionOutputSchema = z.discriminatedUnion("kind", [
modelQuestionSchema,
noUsefulQuestionSchema,
]).readonly();
export type ParsedDynamicQuestionOutput = z.infer<typeof dynamicQuestionOutputSchema>;
export type ParsedQuestionOutput = Extract<ParsedDynamicQuestionOutput, { readonly kind: "question" }>;
export type DynamicQuestionSource = "agent" | "fallback";
export type DynamicQuestionIdFactory = () => string;
const timeOfBirthPattern = /(?:^|[^\d])(?:[01]?\d|2[0-3])\s*[:]\s*[0-5]\d(?:$|[^\d])/;
const forbiddenClaimPattern = /出生(?:时间|时刻|分钟|几点)|生时|候选(?:时间|分钟|答案)|置信(?:度)?|可信度|评分|得分|权重|算法|模型|证据分区|分区标识|停止提问|结束评估|应用(?:到)?排盘|更新排盘|系统(?:会|将)/;
const candidateSupportPattern = /(?:支持|排除).*(?:候选|出生)|(?:候选|出生).*(?:支持|排除|更符合|更接近)/;
function invalidQuestion(): never {
throw new BirthTimeGuideOutputError("invalid_question");
}
function publicCopyIsSafe(value: string, question: boolean): boolean {
const normalized = value.normalize("NFKC").trim();
if (!/[\u3400-\u9fff]/u.test(normalized) || /[A-Za-z]/.test(normalized)) return false;
if (timeOfBirthPattern.test(normalized)) return false;
if (forbiddenClaimPattern.test(normalized) || candidateSupportPattern.test(normalized)) return false;
return !question || (normalized.match(/[?]/g) ?? []).length === 1;
}
function opportunityFor(
packet: CandidateDifferencePacket,
opportunityId: string,
): QuestionOpportunity {
const opportunity = packet.opportunities.find((item) => item.opportunityId === opportunityId);
if (!opportunity) return invalidQuestion();
return opportunity;
}
function validateQuestionOutput(
output: ParsedQuestionOutput,
packet: CandidateDifferencePacket,
): ParsedQuestionOutput {
const opportunity = opportunityFor(packet, output.opportunityId);
if (!publicCopyIsSafe(output.prompt, true)) return invalidQuestion();
if (output.options.some((option) => !publicCopyIsSafe(option.label, false))) {
return invalidQuestion();
}
const expected = opportunity.partitions.map((item) => item.partitionId);
const actual = output.options.map((item) => item.partitionId);
if (new Set(actual).size !== actual.length) return invalidQuestion();
if (actual.length !== expected.length || actual.some((item) => !expected.includes(item))) {
return invalidQuestion();
}
return output;
}
export function generateDynamicQuestionPrompt(
packet: CandidateDifferencePacket,
unmatchedNote: string | null,
): string {
const note = unmatchedNote?.trim() || null;
if (note !== null && note.length > 240) return invalidQuestion();
return JSON.stringify({
task: "generate_dynamic_choice_question",
opportunities: packet.opportunities.map((opportunity) => ({
opportunityId: opportunity.opportunityId,
dimensionCode: opportunity.dimensionCode,
neutralContext: opportunity.neutralContext,
partitions: opportunity.partitions.map((partition) => ({
partitionId: partition.partitionId,
descriptor: partition.descriptor,
fallbackLabel: partition.fallbackLabel,
})),
})),
unmatchedNote: note,
});
}
export function parseDynamicQuestionOutput(
value: unknown,
packet: CandidateDifferencePacket,
): ParsedDynamicQuestionOutput {
const parsed = dynamicQuestionOutputSchema.safeParse(value);
if (!parsed.success) return invalidQuestion();
if (parsed.data.kind === "no_useful_question") return parsed.data;
return validateQuestionOutput(parsed.data, packet);
}
export function parseDynamicQuestionText(
text: string,
packet: CandidateDifferencePacket,
): ParsedDynamicQuestionOutput {
try {
return parseDynamicQuestionOutput(JSON.parse(text.trim()), packet);
} catch (error) {
if (error instanceof SyntaxError) {
throw new BirthTimeGuideOutputError("invalid_json");
}
throw error;
}
}
function normalizeSemanticCopy(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/g, " ");
}
export function dynamicQuestionFingerprint(output: ParsedQuestionOutput): string {
const semantics = {
prompt: normalizeSemanticCopy(output.prompt),
options: output.options.map((option) => normalizeSemanticCopy(option.label)),
};
return createHash("sha256")
.update(`birth-time-dynamic-question-v1\n${JSON.stringify(semantics)}`, "utf8")
.digest("hex");
}
function serverId(factory: DynamicQuestionIdFactory): string {
const parsed = z.string().uuid().safeParse(factory());
if (!parsed.success) return invalidQuestion();
return parsed.data.toLowerCase();
}
export function bindDynamicQuestion(
output: ParsedQuestionOutput,
build: CandidateDifferenceBuild,
createId: DynamicQuestionIdFactory,
source: DynamicQuestionSource,
): PersistedDynamicChoiceQuestion {
const validated = validateQuestionOutput(output, build.packet);
const opportunity = opportunityFor(build.packet, validated.opportunityId);
const questionFingerprint = dynamicQuestionFingerprint(validated);
if (
build.packet.askedQuestionFingerprints.includes(questionFingerprint)
|| build.packet.candidatePartitionFingerprints.includes(
opportunity.candidatePartitionFingerprint,
)
) {
throw new BirthTimeGuideOutputError("repeated_question");
}
const scoringPartitions = build.scoringPartitions[opportunity.opportunityId];
if (!scoringPartitions) return invalidQuestion();
const questionId = serverId(createId);
const primaryOptions = validated.options.map((option) => {
const partition = scoringPartitions.find((item) => item.partitionId === option.partitionId);
if (!partition) return invalidQuestion();
return {
optionId: serverId(createId),
label: option.label,
kind: "primary" as const,
partitionId: partition.partitionId,
candidateScores: partition.candidateScores,
};
});
if (
scoringPartitions.length !== primaryOptions.length
|| new Set(scoringPartitions.map((item) => item.partitionId)).size !== primaryOptions.length
) return invalidQuestion();
return persistedDynamicChoiceQuestionSchema.parse({
questionId,
opportunityId: opportunity.opportunityId,
dimensionCode: opportunity.dimensionCode,
estimatedInformationGain: opportunity.estimatedInformationGain,
scoringVersion: build.packet.scoringVersion,
source,
questionFingerprint,
candidatePartitionFingerprint: opportunity.candidatePartitionFingerprint,
prompt: validated.prompt,
options: [
...primaryOptions,
{
optionId: serverId(createId),
label: "不确定 / 不记得",
kind: "unknown",
partitionId: null,
candidateScores: null,
},
{
optionId: serverId(createId),
label: "都不符合",
kind: "unmatched",
partitionId: null,
candidateScores: null,
},
],
});
}
export function bindFallbackDynamicQuestion(
build: CandidateDifferenceBuild,
createId: DynamicQuestionIdFactory,
): PersistedDynamicChoiceQuestion | null {
for (const opportunity of build.packet.opportunities) {
try {
const output = parseDynamicQuestionOutput({
kind: "question",
opportunityId: opportunity.opportunityId,
prompt: opportunity.fallbackPrompt,
options: opportunity.partitions.map((partition) => ({
partitionId: partition.partitionId,
label: partition.fallbackLabel,
})),
}, build.packet);
if (output.kind === "question") {
return bindDynamicQuestion(output, build, createId, "fallback");
}
} catch (error) {
if (!(error instanceof BirthTimeGuideOutputError)) throw error;
}
}
return null;
}
+4 -2
View File
@@ -69,9 +69,11 @@ export interface BirthTimeGuideGenerator {
export class BirthTimeGuideOutputError extends Error {
readonly name = "BirthTimeGuideOutputError";
readonly reason: "invalid_json" | "invalid_question" | "domain_tamper";
readonly reason: "invalid_json" | "invalid_question" | "domain_tamper" | "repeated_question";
constructor(reason: "invalid_json" | "invalid_question" | "domain_tamper") {
constructor(
reason: "invalid_json" | "invalid_question" | "domain_tamper" | "repeated_question",
) {
super(`Birth-time guide output ${reason}`);
this.reason = reason;
}
+66 -5
View File
@@ -1,4 +1,5 @@
import {
BirthTimeGuideOutputError,
draftEvidencePrompt,
fallbackQuestionCopy,
guideQuestionResponseSchema,
@@ -8,6 +9,18 @@ import {
renderQuestionPrompt,
type BirthTimeGuideGenerator,
} from "./birth-time-guide-agent.ts";
import {
bindDynamicQuestion,
bindFallbackDynamicQuestion,
generateDynamicQuestionPrompt,
parseDynamicQuestionText,
} from "./birth-time-dynamic-question-validator.ts";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import type {
CandidateDifferenceBuild,
PersistedDynamicChoiceQuestion,
} from "./birth-time-dynamic-choice-internal.ts";
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
import type { EvidenceDraftProposal } from "./birth-time-evidence.ts";
import { currentJourneyTurn, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type {
@@ -16,14 +29,23 @@ import type {
} from "./birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-turn-persistence.ts";
import { questionFromTurn } from "./birth-time-journey-transitions.ts";
type DraftRequest = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly message: string;
};
export type DynamicQuestionGenerationCommand = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly unmatchedNote: string | null;
};
export type DynamicQuestionGenerationCommit = {
readonly nextAction: Extract<DynamicNextAction,
{ readonly kind: "ask_dynamic_choice" | "present_low_result" }
>;
};
type GuideServicePorts = {
readonly generator: BirthTimeGuideGenerator | null;
readonly timeoutMs?: number;
@@ -38,8 +60,18 @@ type GuideServicePorts = {
turnVersion: number,
proposal: EvidenceDraftProposal,
) => Promise<VersionedJourneyResponse>;
readonly loadDynamicQuestionBuild?: (
userId: string,
command: DynamicQuestionGenerationCommand,
) => Promise<CandidateDifferenceBuild>;
readonly commitDynamicQuestion?: (
userId: string,
command: DynamicQuestionGenerationCommand,
question: PersistedDynamicChoiceQuestion | null,
commit: DynamicQuestionGenerationCommit,
) => Promise<DynamicQuestionGenerationCommit>;
readonly createDynamicId?: () => string;
};
export class BirthTimeGuideActionError extends Error {
readonly name = "BirthTimeGuideActionError";
readonly reason: "case_not_found" | "invalid_turn";
@@ -49,11 +81,9 @@ export class BirthTimeGuideActionError extends Error {
this.reason = reason;
}
}
class BirthTimeGuideTimeoutError extends Error {
readonly name = "BirthTimeGuideTimeoutError";
}
async function generatedText(
generator: BirthTimeGuideGenerator | null,
prompt: string,
@@ -122,6 +152,37 @@ export function createBirthTimeGuideService(ports: GuideServicePorts) {
const timeoutMs = ports.timeoutMs ?? 8_000;
return {
async generateQuestion(userId: string, command: DynamicQuestionGenerationCommand) {
if (!ports.loadDynamicQuestionBuild || !ports.commitDynamicQuestion) {
throw new BirthTimeGuideActionError("invalid_turn");
}
const build = await ports.loadDynamicQuestionBuild(userId, command);
if (build.packet.caseId !== command.caseId) {
throw new BirthTimeGuideActionError("invalid_turn");
}
const createId = ports.createDynamicId ?? (() => globalThis.crypto.randomUUID());
let question: PersistedDynamicChoiceQuestion | null = null;
if (build.packet.opportunities.length > 0) {
const prompt = generateDynamicQuestionPrompt(build.packet, command.unmatchedNote);
for (let attempt = 0; attempt < 2 && question === null; attempt += 1) {
const text = await generatedText(ports.generator, prompt, timeoutMs);
if (text === null) continue;
try {
const output = parseDynamicQuestionText(text, build.packet);
if (output.kind === "no_useful_question") break;
question = bindDynamicQuestion(output, build, createId, "agent");
} catch (error) {
if (!(error instanceof BirthTimeGuideOutputError)) throw error;
}
}
question ??= bindFallbackDynamicQuestion(build, createId);
}
const nextAction = question === null
? { kind: "present_low_result" as const, resultId: null }
: { kind: "ask_dynamic_choice" as const, question: toPublicDynamicChoiceQuestion(question) };
return ports.commitDynamicQuestion(userId, command, question, { nextAction });
},
async renderQuestion(userId: string, caseId: string) {
const stored = await ports.loadCase(userId, caseId);
if (!stored) throw new BirthTimeGuideActionError("case_not_found");
+1
View File
@@ -179,6 +179,7 @@ export function getOnboardingAgent(model: ResolvedLanguageModel) {
const birthTimeGuideInstructions = `You are a constrained guide for birth-time rectification.
Return valid JSON only, without Markdown, commentary, metadata, or hidden fields.
The server has already selected the only allowed question domain. Never change the domain, rank a candidate time, set confidence, choose a route, report progress, grant permission, or infer an active birth time.
For task generate_dynamic_choice_question, select exactly one opportunity supplied by the server and return either {"kind":"question","opportunityId":"exact server id","prompt":"one neutral Simplified Chinese question","options":[{"partitionId":"exact server id","label":"concise Simplified Chinese label"}]} or {"kind":"no_useful_question"}. Use every partition of the selected opportunity exactly once and write two to four clickable options. Never invent or rewrite an opportunity or partition id. Do not mention a birth minute, candidate time, score, confidence, support direction, partition, algorithm, system control, or methodology. The no_useful_question response is advisory only; the server alone decides whether generation stops.
For task select_question_variant, return exactly {"variant":"direct"} or {"variant":"gentle"}. You select presentation style only. Never write or rewrite the question text.
For task draft_evidence, use the draft-evidence-structure tool and return only domain, precision, and date. Precision must be year, month, day, or null; date must match that precision or be null. Never invent a missing year, month, or day. Ambiguous or relative dates stay null. A draft is for user review only and is never confirmed evidence.`;
@@ -8,6 +8,16 @@ import {
parseGuideQuestionOutput,
renderQuestionVariant,
} from "../src/lib/birth-time-guide-agent.ts";
import {
bindDynamicQuestion,
generateDynamicQuestionPrompt,
parseDynamicQuestionOutput,
} from "../src/lib/birth-time-dynamic-question-validator.ts";
import { toPublicDynamicChoiceQuestion } from "../src/lib/birth-time-dynamic-choice-internal.ts";
import type {
CandidateDifferenceBuild,
CandidateDifferencePacket,
} from "../src/lib/birth-time-dynamic-choice-internal.ts";
import { evidenceDomains, type QuestionSpec } from "../src/lib/birth-time-question-planner.ts";
import { draftEvidenceStructureTool, getBirthTimeGuideAgent } from "../src/mastra/index.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
@@ -24,6 +34,178 @@ function question(domain: QuestionSpec["domain"]): QuestionSpec {
};
}
const dynamicPacket: CandidateDifferencePacket = {
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
scoringVersion: "birth-time-choice-scoring-v2",
currentRange: { startTime: "04:00", endTime: "04:01" },
opportunities: [{
opportunityId: "career-window",
dimensionCode: "career",
neutralContext: "一次明显的工作变化",
estimatedInformationGain: 0.7,
candidatePartitionFingerprint: "career-partitions-v1",
fallbackPrompt: "哪一个时间段更接近这次工作变化?",
partitions: [
{ partitionId: "window-a", descriptor: "较早阶段", fallbackLabel: "2018—2020 年" },
{ partitionId: "window-b", descriptor: "较晚阶段", fallbackLabel: "2021—2023 年" },
],
}],
askedQuestionFingerprints: [],
candidatePartitionFingerprints: [],
recentRangeHistory: [{ startTime: "04:00", endTime: "04:01" }],
};
const differenceBuild: CandidateDifferenceBuild = {
packet: dynamicPacket,
candidateModel: { candidates: ["04:00", "04:01"], confidence: "private" },
scoringPartitions: {
"career-window": [
{
partitionId: "window-a",
descriptor: "较早阶段",
fallbackLabel: "2018—2020 年",
candidateScores: { "04:00": 1, "04:01": 0 },
},
{
partitionId: "window-b",
descriptor: "较晚阶段",
fallbackLabel: "2021—2023 年",
candidateScores: { "04:00": 0, "04:01": 1 },
},
],
},
};
const validDynamicOutput = {
kind: "question",
opportunityId: "career-window",
prompt: "哪一个时间段更接近这次工作变化?",
options: [
{ partitionId: "window-a", label: "2018—2020 年" },
{ partitionId: "window-b", label: "2021—2023 年" },
],
} as const;
function deterministicIds() {
const values = [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
"00000000-0000-4000-8000-000000000003",
"00000000-0000-4000-8000-000000000004",
"00000000-0000-4000-8000-000000000005",
];
let index = 0;
return () => {
const value = values[index];
index += 1;
if (value === undefined) throw new Error("test id supply exhausted");
return value;
};
}
test("dynamic prompt exposes only model-safe opportunity copy and optional unmatched context", () => {
const prompt = JSON.parse(generateDynamicQuestionPrompt(dynamicPacket, " 更像发生在年末 "));
assert.deepEqual(prompt, {
task: "generate_dynamic_choice_question",
opportunities: [{
opportunityId: "career-window",
dimensionCode: "career",
neutralContext: "一次明显的工作变化",
partitions: [
{ partitionId: "window-a", descriptor: "较早阶段", fallbackLabel: "2018—2020 年" },
{ partitionId: "window-b", descriptor: "较晚阶段", fallbackLabel: "2021—2023 年" },
],
}],
unmatchedNote: "更像发生在年末",
});
const serialized = JSON.stringify(prompt);
for (const forbidden of [
"candidateScores",
"candidateModel",
"estimatedInformationGain",
"currentRange",
"scoringVersion",
"askedQuestionFingerprints",
"candidatePartitionFingerprints",
"recentRangeHistory",
"04:00",
"confidence",
]) {
assert.equal(serialized.includes(forbidden), false, forbidden);
}
});
test("dynamic output references exactly one server opportunity and each of its partitions once", () => {
const parsed = parseDynamicQuestionOutput(validDynamicOutput, dynamicPacket);
assert.equal(parsed.kind, "question");
for (const unsafe of [
{ ...validDynamicOutput, opportunityId: "invented" },
{ ...validDynamicOutput, options: [{ partitionId: "invented", label: "某个时间" }] },
{ ...validDynamicOutput, options: [validDynamicOutput.options[0], validDynamicOutput.options[0]] },
{ ...validDynamicOutput, options: [validDynamicOutput.options[0]] },
{ ...validDynamicOutput, options: [...validDynamicOutput.options, { partitionId: "window-c", label: "其他" }] },
]) {
assert.throws(
() => parseDynamicQuestionOutput(unsafe, dynamicPacket),
BirthTimeGuideOutputError,
);
}
});
test("dynamic copy rejects birth-time, confidence, support, control, and oversized claims", () => {
const unsafeCopies = [
{ ...validDynamicOutput, prompt: "你是 04:00 出生的吗?" },
{ ...validDynamicOutput, prompt: "哪个答案能提高置信度?" },
{ ...validDynamicOutput, options: [{ partitionId: "window-a", label: "支持候选 A" }, validDynamicOutput.options[1]] },
{ ...validDynamicOutput, prompt: "选择后系统会结束评估吗?" },
{ ...validDynamicOutput, prompt: "字".repeat(121) },
{ ...validDynamicOutput, options: [{ partitionId: "window-a", label: "字".repeat(81) }, validDynamicOutput.options[1]] },
];
for (const unsafe of unsafeCopies) {
assert.throws(
() => parseDynamicQuestionOutput(unsafe, dynamicPacket),
BirthTimeGuideOutputError,
);
}
});
test("server binds private score vectors and adds two public special options", () => {
const output = parseDynamicQuestionOutput(validDynamicOutput, dynamicPacket);
if (output.kind !== "question") throw new Error("expected a test question");
const internal = bindDynamicQuestion(output, differenceBuild, deterministicIds(), "agent");
const publicQuestion = toPublicDynamicChoiceQuestion(internal);
assert.deepEqual(internal.options[0]?.candidateScores, { "04:00": 1, "04:01": 0 });
assert.deepEqual(publicQuestion.options.slice(-2).map((item) => item.label), [
"不确定 / 不记得",
"都不符合",
]);
assert.equal(publicQuestion.options.some((item) => "partitionId" in item), false);
assert.equal(JSON.stringify(publicQuestion).includes("04:00"), false);
});
test("server rejects repeated public semantics and repeated candidate partitions", () => {
const output = parseDynamicQuestionOutput(validDynamicOutput, dynamicPacket);
if (output.kind !== "question") throw new Error("expected a test question");
const first = bindDynamicQuestion(output, differenceBuild, deterministicIds(), "agent");
assert.throws(() => bindDynamicQuestion(output, {
...differenceBuild,
packet: { ...dynamicPacket, askedQuestionFingerprints: [first.questionFingerprint] },
}, deterministicIds(), "agent"), BirthTimeGuideOutputError);
assert.throws(() => bindDynamicQuestion(output, {
...differenceBuild,
packet: {
...dynamicPacket,
candidatePartitionFingerprints: ["career-partitions-v1"],
},
}, deterministicIds(), "agent"), BirthTimeGuideOutputError);
});
test("draft parser fails closed when the model changes the server-selected domain", () => {
assert.throws(
() => parseEvidenceDraftOutput(
@@ -8,7 +8,13 @@ import {
import {
BirthTimeGuideActionError,
createBirthTimeGuideService,
type DynamicQuestionGenerationCommand,
type DynamicQuestionGenerationCommit,
} from "../src/lib/birth-time-guide-service.ts";
import type {
CandidateDifferenceBuild,
PersistedDynamicChoiceQuestion,
} from "../src/lib/birth-time-dynamic-choice-internal.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import type { StoredRectificationCase, VersionedJourneyResponse } from "../src/lib/birth-time-journey-service.ts";
import { storedJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
@@ -18,6 +24,53 @@ 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";
const generationCommand: DynamicQuestionGenerationCommand = {
caseId,
actionId,
turnVersion: 4,
unmatchedNote: null,
};
const dynamicBuild: CandidateDifferenceBuild = {
packet: {
caseId,
scoringVersion: "birth-time-choice-scoring-v2",
currentRange: { startTime: "04:00", endTime: "04:01" },
opportunities: [{
opportunityId: "career-window",
dimensionCode: "career",
neutralContext: "一次明显的工作变化",
estimatedInformationGain: 0.7,
candidatePartitionFingerprint: "career-partitions-v1",
fallbackPrompt: "哪一个时间段更接近这次工作变化?",
partitions: [
{ partitionId: "window-a", descriptor: "较早阶段", fallbackLabel: "2018—2020 年" },
{ partitionId: "window-b", descriptor: "较晚阶段", fallbackLabel: "2021—2023 年" },
],
}],
askedQuestionFingerprints: [],
candidatePartitionFingerprints: [],
recentRangeHistory: [],
},
candidateModel: { privateCandidates: ["04:00", "04:01"] },
scoringPartitions: {
"career-window": [
{
partitionId: "window-a",
descriptor: "较早阶段",
fallbackLabel: "2018—2020 年",
candidateScores: { "04:00": 1, "04:01": 0 },
},
{
partitionId: "window-b",
descriptor: "较晚阶段",
fallbackLabel: "2021—2023 年",
candidateScores: { "04:00": 0, "04:01": 1 },
},
],
},
};
type ProposedDraftCall = {
readonly userId: string;
readonly caseId: string;
@@ -74,6 +127,129 @@ function generator(text: string, onGenerate?: () => void): BirthTimeGuideGenerat
};
}
function dynamicService(input?: {
readonly build?: CandidateDifferenceBuild;
readonly generator?: BirthTimeGuideGenerator | null;
readonly onCommit?: (
question: PersistedDynamicChoiceQuestion | null,
commit: DynamicQuestionGenerationCommit,
) => void;
}) {
const ids = [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
"00000000-0000-4000-8000-000000000003",
"00000000-0000-4000-8000-000000000004",
"00000000-0000-4000-8000-000000000005",
];
let idIndex = 0;
return createBirthTimeGuideService({
generator: input?.generator ?? null,
timeoutMs: 20,
async loadCase() { return storedCase(); },
async proposeEvidenceDraft() {
return storedJourneyResponse(storedCase()) satisfies VersionedJourneyResponse;
},
async loadDynamicQuestionBuild() {
return input?.build ?? dynamicBuild;
},
async commitDynamicQuestion(_userId, _command, question, commit) {
input?.onCommit?.(question, commit);
return commit;
},
createDynamicId() {
const value = ids[idIndex];
idIndex += 1;
if (value === undefined) throw new Error("test id supply exhausted");
return value;
},
});
}
test("invalid dynamic output retries once then persists the top opportunity fallback", async () => {
let calls = 0;
const persisted: PersistedDynamicChoiceQuestion[] = [];
const result = await dynamicService({
generator: generator("{}", () => { calls += 1; }),
onCommit: (question) => { if (question) persisted.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(calls, 2);
assert.equal(result.nextAction.kind, "ask_dynamic_choice");
if (result.nextAction.kind !== "ask_dynamic_choice") throw new Error("expected a question");
assert.equal(result.nextAction.question.prompt, dynamicBuild.packet.opportunities[0]?.fallbackPrompt);
assert.equal(result.nextAction.question.options.length, 4);
assert.equal(persisted[0]?.source, "fallback");
});
test("dynamic generation rejects commentary around otherwise valid JSON", async () => {
let calls = 0;
const persisted: PersistedDynamicChoiceQuestion[] = [];
const wrapped = `Here is the result:\n${JSON.stringify({
kind: "question",
opportunityId: "career-window",
prompt: "哪一个时间段更接近这次工作变化?",
options: [
{ partitionId: "window-a", label: "2018—2020 年" },
{ partitionId: "window-b", label: "2021—2023 年" },
],
})}`;
await dynamicService({
generator: generator(wrapped, () => { calls += 1; }),
onCommit: (question) => { if (question) persisted.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(calls, 2);
assert.equal(persisted[0]?.source, "fallback");
});
test("no opportunity ends safely without invoking the model or regenerating a question", async () => {
let calls = 0;
let persistedQuestion: PersistedDynamicChoiceQuestion | null | undefined;
const result = await dynamicService({
build: { ...dynamicBuild, packet: { ...dynamicBuild.packet, opportunities: [] } },
generator: generator("{}", () => { calls += 1; }),
onCommit: (question) => { persistedQuestion = question; },
}).generateQuestion("owner-1", generationCommand);
assert.equal(calls, 0);
assert.equal(result.nextAction.kind, "present_low_result");
assert.equal(persistedQuestion, null);
});
test("model no-useful-question advice cannot stop while the engine has an opportunity", async () => {
let calls = 0;
const result = await dynamicService({
generator: generator(JSON.stringify({ kind: "no_useful_question" }), () => { calls += 1; }),
}).generateQuestion("owner-1", generationCommand);
assert.equal(calls, 1);
assert.equal(result.nextAction.kind, "ask_dynamic_choice");
if (result.nextAction.kind !== "ask_dynamic_choice") throw new Error("expected a question");
assert.equal(result.nextAction.question.prompt, dynamicBuild.packet.opportunities[0]?.fallbackPrompt);
});
test("valid dynamic output is persisted with server-owned ids and private bindings", async () => {
const persisted: PersistedDynamicChoiceQuestion[] = [];
const result = await dynamicService({
generator: generator(JSON.stringify({
kind: "question",
opportunityId: "career-window",
prompt: "哪一个时间段更接近这次工作变化?",
options: [
{ partitionId: "window-a", label: "2018—2020 年" },
{ partitionId: "window-b", label: "2021—2023 年" },
],
})),
onCommit: (question) => { if (question) persisted.push(question); },
}).generateQuestion("owner-1", generationCommand);
assert.equal(result.nextAction.kind, "ask_dynamic_choice");
assert.equal(persisted[0]?.source, "agent");
assert.equal(persisted[0]?.questionId, "00000000-0000-4000-8000-000000000001");
assert.deepEqual(persisted[0]?.options[0]?.candidateScores, { "04:00": 1, "04:01": 0 });
});
function service(input?: {
readonly stored?: StoredRectificationCase | null;
readonly generator?: BirthTimeGuideGenerator | null;