🐛 (picture choice) Fix invalid matching when title equals indices
Some checks failed
Create Tag / create-tag (push) Has been cancelled
Deploy Partykit server / deploy (push) Has been cancelled

This commit is contained in:
Baptiste Arnaud 2025-08-26 11:46:26 +02:00
parent 2b626f75f3
commit d4b33ef4ee
No known key found for this signature in database
7 changed files with 76 additions and 228 deletions

View File

@ -1,4 +1,5 @@
import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema";
import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema";
import { describe, expect, it } from "vitest";
import { parseMultipleChoiceReply } from "./parseMultipleChoiceReply";
@ -11,6 +12,15 @@ const createMockItem = (
outgoingEdgeId: "edge1",
});
const createMockPictureItem = (
id: string,
title: string,
): PictureChoiceBlock["items"][number] => ({
id,
title,
outgoingEdgeId: "edge1",
});
describe("parseMultipleChoiceReply", () => {
it("should return fail if no items match", () => {
const result = parseMultipleChoiceReply("test", {
@ -43,6 +53,20 @@ describe("parseMultipleChoiceReply", () => {
expect(result).toEqual({ status: "success", content: "item, second item" });
});
it("should work with number titles", () => {
const result = parseMultipleChoiceReply("4, 2, 1", {
items: [
createMockPictureItem("id1", "6"),
createMockPictureItem("id2", "5"),
createMockPictureItem("id3", "4"),
createMockPictureItem("id4", "3"),
createMockPictureItem("id5", "2"),
createMockPictureItem("id6", "1"),
],
});
expect(result).toEqual({ status: "success", content: "4, 2, 1" });
});
it("should work when the choices are overlapping", () => {
const result = parseMultipleChoiceReply("item and second item", {
items: [

View File

@ -1,5 +1,6 @@
import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema";
import { sortByContentLengthDesc } from "../../../helpers/choiceMatchers";
import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema";
import { parseItemContent } from "../../../helpers/parseItemContent";
import type { ParsedReply } from "../../../types";
/**
@ -8,10 +9,14 @@ import type { ParsedReply } from "../../../types";
*/
export const parseMultipleChoiceReply = (
reply: string,
{ items }: { items: ChoiceInputBlock["items"] },
{ items }: { items: ChoiceInputBlock["items"] | PictureChoiceBlock["items"] },
): ParsedReply => {
let remainingInput = reply;
const remainingItems = sortByContentLengthDesc(items);
const remainingItems = [...items].sort((a, b) => {
const aContent = parseItemContent(a);
const bContent = parseItemContent(b);
return (bContent?.length ?? 0) - (aContent?.length ?? 0);
});
// We match the IDs first and then filter through the items to have an order independent result
const matchedItemIds: string[] = [];
@ -36,8 +41,9 @@ export const parseMultipleChoiceReply = (
for (const item of remainingItems.filter(
(item) => !matchedItemIds.includes(item.id),
)) {
if (item.content && includesWholePhrase(remainingInput, item.content)) {
remainingInput = remainingInput.replace(item.content, "").trim();
const content = parseItemContent(item);
if (content && includesWholePhrase(remainingInput, content)) {
remainingInput = remainingInput.replace(content, "").trim();
matchedItemIds.push(item.id);
remainingItems.splice(remainingItems.indexOf(item), 1);
}
@ -61,7 +67,7 @@ export const parseMultipleChoiceReply = (
status: "success",
content: items
.filter((item) => matchedItemIds.includes(item.id))
.map((item) => (item.value ?? item.content)?.trim())
.map((item) => item.value ?? parseItemContent(item)?.trim())
.join(", "),
};
};

View File

@ -1,26 +1,35 @@
import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema";
import {
getItemContent,
sortByContentLengthDesc,
} from "../../../helpers/choiceMatchers";
import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema";
import { parseItemContent } from "../../../helpers/parseItemContent";
import type { ParsedReply } from "../../../types";
export const parseSingleChoiceReply = (
displayedItems: ChoiceInputBlock["items"],
inputValue: string,
{
items,
}: {
items: ChoiceInputBlock["items"] | PictureChoiceBlock["items"];
},
): ParsedReply => {
const matchedItem = sortByContentLengthDesc(displayedItems).find(
(item) =>
item.id === inputValue ||
(item.value && inputValue.trim() === item.value.trim()) ||
(item.content && inputValue.trim() === item.content.trim()),
);
const matchedItem = [...items]
.sort((a, b) => {
const aContent = parseItemContent(a);
const bContent = parseItemContent(b);
return (bContent?.length ?? 0) - (aContent?.length ?? 0);
})
.find((item) => {
if (item.id === inputValue) return true;
if (item.value && inputValue.trim() === item.value.trim()) return true;
const itemContent = parseItemContent(item);
if (itemContent && inputValue.trim() === itemContent.trim()) return true;
return false;
});
if (!matchedItem) return { status: "fail" };
return {
status: "success",
content: getItemContent(matchedItem, ["value", "content"]),
content: matchedItem.value ?? parseItemContent(matchedItem) ?? "",
outgoingEdgeId: matchedItem.outgoingEdgeId,
};
};

View File

@ -1,94 +0,0 @@
import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema";
import type { SessionState } from "@typebot.io/chat-session/schemas";
import type { SessionStore } from "@typebot.io/runtime-session-store";
import {
getItemContent,
matchByIndex,
matchByKey,
sortByContentLengthDesc,
} from "../../../helpers/choiceMatchers";
import type { ParsedReply } from "../../../types";
import { injectVariableValuesInPictureChoiceBlock } from "./injectVariableValuesInPictureChoiceBlock";
const parseMultipleChoiceReply = (
displayedItems: PictureChoiceBlock["items"],
inputValue: string,
): ParsedReply => {
const valueMatches = matchByKey({
items: sortByContentLengthDesc(displayedItems, "value"),
inputValue,
key: "value",
});
const contentMatches = matchByKey({
items: valueMatches.remaining,
inputValue,
key: "title",
});
const indexMatches = matchByIndex(contentMatches.remaining, {
strippedInput: inputValue,
matchedItemIds: contentMatches.matchedItemIds,
});
const matchedItems = displayedItems.filter((item) =>
[
...valueMatches.matchedItemIds,
...contentMatches.matchedItemIds,
...indexMatches.matchedItemIds,
].includes(item.id),
);
if (matchedItems.length === 0) return { status: "fail" };
const content = matchedItems
.map((item) => getItemContent(item, ["value", "title", "pictureSrc"]))
.join(", ");
return {
status: "success",
content,
};
};
const parseSingleChoiceReply = (
displayedItems: PictureChoiceBlock["items"],
inputValue: string,
): ParsedReply => {
const matchedItem = sortByContentLengthDesc(displayedItems).find(
(item) =>
item.id === inputValue ||
item.value?.toLowerCase().trim() === inputValue.toLowerCase().trim() ||
item.title?.toLowerCase().trim() === inputValue.toLowerCase().trim() ||
item.pictureSrc?.toLowerCase().trim() === inputValue.toLowerCase().trim(),
);
if (!matchedItem) return { status: "fail" };
return {
status: "success",
outgoingEdgeId: matchedItem.outgoingEdgeId,
content: getItemContent(matchedItem, ["value", "title", "pictureSrc"]),
};
};
export const parsePictureChoicesReply = (
inputValue: string,
{
block,
state,
sessionStore,
}: {
block: PictureChoiceBlock;
state: SessionState;
sessionStore: SessionStore;
},
): ParsedReply => {
const displayedItems = injectVariableValuesInPictureChoiceBlock(block, {
variables: state.typebotsQueue[0].typebot.variables,
sessionStore,
}).items;
return block.options?.isMultipleChoice
? parseMultipleChoiceReply(displayedItems, inputValue)
: parseSingleChoiceReply(displayedItems, inputValue);
};

View File

@ -50,7 +50,7 @@ import { parseDateReply } from "./blocks/inputs/date/parseDateReply";
import { formatEmail } from "./blocks/inputs/email/formatEmail";
import { parseNumber } from "./blocks/inputs/number/parseNumber";
import { formatPhoneNumber } from "./blocks/inputs/phone/formatPhoneNumber";
import { parsePictureChoicesReply } from "./blocks/inputs/pictureChoice/parsePictureChoicesReply";
import { injectVariableValuesInPictureChoiceBlock } from "./blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock";
import { validateRatingReply } from "./blocks/inputs/rating/validateRatingReply";
import { parseTime } from "./blocks/inputs/time/parseTime";
import { saveDataInResponseVariableMapping } from "./blocks/integrations/httpRequest/saveDataInResponseVariableMapping";
@ -850,7 +850,7 @@ const parseReply = async (
}).items;
if (block.options?.isMultipleChoice)
return parseMultipleChoiceReply(reply.text, { items: displayedItems });
return parseSingleChoiceReply(displayedItems, reply.text);
return parseSingleChoiceReply(reply.text, { items: displayedItems });
}
case InputBlockType.NUMBER: {
if (!reply || reply.type !== "text") return { status: "fail" };
@ -915,11 +915,13 @@ const parseReply = async (
}
case InputBlockType.PICTURE_CHOICE: {
if (!reply || reply.type !== "text") return { status: "fail" };
return parsePictureChoicesReply(reply.text, {
block,
state,
const displayedItems = injectVariableValuesInPictureChoiceBlock(block, {
variables: state.typebotsQueue[0].typebot.variables,
sessionStore,
});
}).items;
if (block.options?.isMultipleChoice)
return parseMultipleChoiceReply(reply.text, { items: displayedItems });
return parseSingleChoiceReply(reply.text, { items: displayedItems });
}
case InputBlockType.TEXT: {
if (!reply) return { status: "fail" };

View File

@ -1,110 +0,0 @@
import { isNotEmpty } from "@typebot.io/lib/utils";
export type ChoiceItem = {
id: string;
outgoingEdgeId?: string;
[key: string]: any;
};
export type MatchResult = {
strippedInput: string;
matchedItemIds: string[];
};
export type MatcherResult<T extends ChoiceItem> = MatchResult & {
remaining: T[];
};
export type MatchContext<T extends ChoiceItem> = {
items: T[];
inputValue: string;
key: string;
};
export type MatchFunction<T extends ChoiceItem> = (
item: T,
input: string,
idx?: number,
) => boolean;
export const sortByContentLengthDesc = <T extends ChoiceItem>(
items: T[],
contentKey = "content",
): T[] =>
[...items].sort(
(a, b) => (b[contentKey]?.length ?? 0) - (a[contentKey]?.length ?? 0),
);
export const createMatchReducer =
<T extends ChoiceItem>(
matchFn: MatchFunction<T>,
contentKey = "content",
valueKey = "value",
) =>
(acc: MatchResult, item: T, idx?: number) => {
const input = acc.strippedInput;
if (matchFn(item, input, idx)) {
const matchValue = item[contentKey] ?? item[valueKey] ?? `${idx! + 1}`;
return {
strippedInput: acc.strippedInput.replace(matchValue, ""),
matchedItemIds: [...acc.matchedItemIds, item.id],
};
}
return acc;
};
export const matchByKey = <T extends ChoiceItem>({
items,
inputValue,
key = "content",
}: MatchContext<T>): MatcherResult<T> => {
const matchFn: MatchFunction<T> = (item, input) =>
Boolean(
item[key] && input.toLowerCase().includes(item[key].trim().toLowerCase()),
);
const matchingItems = items.reduce(createMatchReducer(matchFn, key), {
strippedInput: inputValue.trim(),
matchedItemIds: [],
});
return {
...matchingItems,
remaining: items.filter(
(item) => !matchingItems.matchedItemIds.includes(item.id),
),
};
};
export const matchByIndex = <T extends ChoiceItem>(
items: T[],
{ strippedInput }: MatchResult,
): MatcherResult<T> => {
const matchFn: MatchFunction<T> = (_, input, idx) => {
const indexStr = `${idx! + 1}`;
const regex = new RegExp(`\\b${indexStr}\\b`);
return regex.test(input);
};
const matchingItems = items.reduce(createMatchReducer(matchFn), {
strippedInput,
matchedItemIds: [],
});
return {
...matchingItems,
remaining: items.filter(
(item) => !matchingItems.matchedItemIds.includes(item.id),
),
};
};
export const getItemContent = <T extends ChoiceItem>(
item: T,
contentKeys: string[],
): string => {
for (const contentKey of contentKeys) {
if (isNotEmpty(item[contentKey])) return item[contentKey];
}
return "";
};

View File

@ -0,0 +1,11 @@
import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema";
import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema";
export const parseItemContent = (
item: ChoiceInputBlock["items"][number] | PictureChoiceBlock["items"][number],
) => {
// Buttons
if ("content" in item) return item.content;
// Picture choice
if ("title" in item) return item.title ?? item.pictureSrc;
};