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
@@ -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.`;