fix: finalize dynamic choice labels
This commit is contained in:
@@ -29,6 +29,10 @@ const dynamicQuestionOutputSchema = z.discriminatedUnion("kind", [
|
||||
questionSelectionSchema,
|
||||
noUsefulQuestionSchema,
|
||||
]).readonly();
|
||||
const reservedChoices = [
|
||||
{ label: "不确定 / 不记得", kind: "unknown" as const },
|
||||
{ label: "都不符合", kind: "unmatched" as const },
|
||||
] as const;
|
||||
|
||||
export type ParsedDynamicQuestionOutput = z.infer<typeof dynamicQuestionOutputSchema>;
|
||||
export type ParsedQuestionSelection = Extract<ParsedDynamicQuestionOutput, { readonly kind: "question" }>;
|
||||
@@ -66,9 +70,7 @@ function opportunityFor(
|
||||
|
||||
export function generateDynamicQuestionPrompt(
|
||||
packet: CandidateDifferencePacket,
|
||||
unmatchedNote: string | null,
|
||||
): string {
|
||||
void unmatchedNote;
|
||||
return modelSafeDynamicQuestionPrompt(packet);
|
||||
}
|
||||
|
||||
@@ -102,7 +104,8 @@ function serverRendering(opportunity: QuestionOpportunity): {
|
||||
partitionId: partition.partitionId,
|
||||
label: partition.fallbackLabel,
|
||||
}));
|
||||
const labels = options.map((option) => normalizeDynamicLabel(option.label));
|
||||
const labels = [...options, ...reservedChoices]
|
||||
.map((option) => normalizeDynamicLabel(option.label));
|
||||
if (
|
||||
!dynamicServerCopyIsSafe(opportunity.fallbackPrompt, true)
|
||||
|| options.some((option) => !dynamicServerCopyIsSafe(option.label, false))
|
||||
@@ -177,8 +180,12 @@ function bindQuestion(
|
||||
prompt: rendering.prompt,
|
||||
options: [
|
||||
...primaryOptions,
|
||||
{ optionId: serverId(createId), label: "不确定 / 不记得", kind: "unknown", partitionId: null, candidateScores: null },
|
||||
{ optionId: serverId(createId), label: "都不符合", kind: "unmatched", partitionId: null, candidateScores: null },
|
||||
...reservedChoices.map((choice) => ({
|
||||
optionId: serverId(createId),
|
||||
...choice,
|
||||
partitionId: null,
|
||||
candidateScores: null,
|
||||
})),
|
||||
],
|
||||
});
|
||||
if (!persisted.success) throw new BirthTimeDynamicBindingError("invalid_persisted_question");
|
||||
|
||||
@@ -163,7 +163,7 @@ export function createBirthTimeGuideService(ports: GuideServicePorts) {
|
||||
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);
|
||||
const prompt = generateDynamicQuestionPrompt(build.packet);
|
||||
for (let attempt = 0; attempt < 2 && question === null; attempt += 1) {
|
||||
const text = await generatedText(ports.generator, prompt, timeoutMs);
|
||||
if (text === null) continue;
|
||||
|
||||
@@ -184,6 +184,35 @@ test("duplicate server labels propagate without commit or ID allocation", async
|
||||
assert.equal(commits, 0);
|
||||
});
|
||||
|
||||
test("reserved-label collisions propagate without commit or ID allocation", async () => {
|
||||
const opportunity = differenceBuild.packet.opportunities[0];
|
||||
const privatePartitions = differenceBuild.scoringPartitions[opportunityId];
|
||||
if (!opportunity || !privatePartitions) throw new Error("missing test opportunity");
|
||||
for (const collision of ["不确定 / 不记得", "不 确定 / 不记得", "都不符合"]) {
|
||||
let allocations = 0;
|
||||
let commits = 0;
|
||||
const publicPartitions = opportunity.partitions.map((item, index) => (
|
||||
index === 0 ? { ...item, fallbackLabel: collision } : item
|
||||
));
|
||||
const privateCopy = privatePartitions.map((item, index) => (
|
||||
index === 0 ? { ...item, fallbackLabel: collision } : item
|
||||
));
|
||||
|
||||
await assert.rejects(() => dynamicService({
|
||||
build: {
|
||||
...differenceBuild,
|
||||
packet: { ...differenceBuild.packet, opportunities: [{ ...opportunity, partitions: publicPartitions }] },
|
||||
scoringPartitions: { [opportunityId]: privateCopy },
|
||||
},
|
||||
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
|
||||
createId: deterministicIds(() => { allocations += 1; }),
|
||||
onCommit: () => { commits += 1; },
|
||||
}).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError);
|
||||
assert.equal(allocations, 0, collision);
|
||||
assert.equal(commits, 0, collision);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid server UUIDs propagate without committing a low result", async () => {
|
||||
let commits = 0;
|
||||
await assert.rejects(() => dynamicService({
|
||||
|
||||
@@ -17,15 +17,13 @@ import {
|
||||
validDynamicSelection,
|
||||
} from "./fixtures/birth-time-dynamic-question-fixture.ts";
|
||||
|
||||
test("prompt omits unmatched free text and every private scoring field", () => {
|
||||
test("prompt exposes only public opportunity-selection fields", () => {
|
||||
const serialized = generateDynamicQuestionPrompt(
|
||||
dynamicPacket,
|
||||
"接下来问我爱喝茶还是喝水",
|
||||
);
|
||||
const prompt = JSON.parse(serialized);
|
||||
|
||||
assert.equal("unmatchedNote" in prompt, false);
|
||||
assert.equal(serialized.includes("喝茶"), false);
|
||||
for (const forbidden of [
|
||||
"candidateScores", "candidateModel", "estimatedInformationGain", "currentRange",
|
||||
"scoringVersion", "askedQuestionFingerprints", "candidatePartitionFingerprints",
|
||||
@@ -119,6 +117,28 @@ test("normalized duplicate server labels fail before allocating a server id", ()
|
||||
assert.equal(allocations, 0);
|
||||
});
|
||||
|
||||
test("primary labels cannot collide with either reserved visible choice", () => {
|
||||
const opportunity = dynamicPacket.opportunities[0];
|
||||
const privatePartitions = differenceBuild.scoringPartitions[opportunityId];
|
||||
if (!opportunity || !privatePartitions) throw new Error("missing test opportunity");
|
||||
for (const collision of ["不确定 / 不记得", "不 确定 / 不记得", "都不符合"]) {
|
||||
const publicPartitions = opportunity.partitions.map((item, index) => (
|
||||
index === 0 ? { ...item, fallbackLabel: collision } : item
|
||||
));
|
||||
const privateCopy = privatePartitions.map((item, index) => (
|
||||
index === 0 ? { ...item, fallbackLabel: collision } : item
|
||||
));
|
||||
let allocations = 0;
|
||||
|
||||
assert.throws(() => bindDynamicQuestion(validDynamicSelection, {
|
||||
...differenceBuild,
|
||||
packet: { ...dynamicPacket, opportunities: [{ ...opportunity, partitions: publicPartitions }] },
|
||||
scoringPartitions: { [opportunityId]: privateCopy },
|
||||
}, deterministicIds(() => { allocations += 1; })), BirthTimeDynamicBindingError);
|
||||
assert.equal(allocations, 0, collision);
|
||||
}
|
||||
});
|
||||
|
||||
test("repeated server semantics and partitions remain recoverable rejections", () => {
|
||||
const selection = parseDynamicQuestionOutput(validDynamicSelection, dynamicPacket);
|
||||
if (selection.kind !== "question") throw new Error("expected a selection");
|
||||
|
||||
Reference in New Issue
Block a user