feat: generate constrained dynamic choice questions
This commit is contained in:
@@ -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.`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user