diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 00000000..8219cbcd --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -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. diff --git a/frontend/src/lib/birth-time-dynamic-question-validator.ts b/frontend/src/lib/birth-time-dynamic-question-validator.ts new file mode 100644 index 00000000..26bc3ad5 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-question-validator.ts @@ -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; +export type ParsedQuestionOutput = Extract; +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; +} diff --git a/frontend/src/lib/birth-time-guide-agent.ts b/frontend/src/lib/birth-time-guide-agent.ts index 20bc7292..1cdb9810 100644 --- a/frontend/src/lib/birth-time-guide-agent.ts +++ b/frontend/src/lib/birth-time-guide-agent.ts @@ -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; } diff --git a/frontend/src/lib/birth-time-guide-service.ts b/frontend/src/lib/birth-time-guide-service.ts index cbfa495a..a6b6d4b7 100644 --- a/frontend/src/lib/birth-time-guide-service.ts +++ b/frontend/src/lib/birth-time-guide-service.ts @@ -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; +}; type GuideServicePorts = { readonly generator: BirthTimeGuideGenerator | null; readonly timeoutMs?: number; @@ -38,8 +60,18 @@ type GuideServicePorts = { turnVersion: number, proposal: EvidenceDraftProposal, ) => Promise; + readonly loadDynamicQuestionBuild?: ( + userId: string, + command: DynamicQuestionGenerationCommand, + ) => Promise; + readonly commitDynamicQuestion?: ( + userId: string, + command: DynamicQuestionGenerationCommand, + question: PersistedDynamicChoiceQuestion | null, + commit: DynamicQuestionGenerationCommit, + ) => Promise; + 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"); diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index ff4214d5..7b8cc200 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -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.`; diff --git a/frontend/tests/birth-time-guide-agent.test.ts b/frontend/tests/birth-time-guide-agent.test.ts index 3dddb023..7ebce375 100644 --- a/frontend/tests/birth-time-guide-agent.test.ts +++ b/frontend/tests/birth-time-guide-agent.test.ts @@ -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( diff --git a/frontend/tests/birth-time-guide-route.test.ts b/frontend/tests/birth-time-guide-route.test.ts index 670bd6b1..175fa493 100644 --- a/frontend/tests/birth-time-guide-route.test.ts +++ b/frontend/tests/birth-time-guide-route.test.ts @@ -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;