feat: generate constrained dynamic choice questions
This commit is contained in:
@@ -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, 2–4 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user