🐛 Fix multiple choice parsing to work with non-ASCII chars

Closes #2231
This commit is contained in:
Baptiste Arnaud 2025-06-27 15:55:29 +02:00
parent f1e7eac191
commit 4d899c09ef
No known key found for this signature in database
6 changed files with 70 additions and 31 deletions

View File

@ -606,7 +606,7 @@
},
"packages/embeds/js": {
"name": "@typebot.io/js",
"version": "0.8.8",
"version": "0.8.11",
"devDependencies": {
"@ai-sdk/ui-utils": "1.2.2",
"@ark-ui/solid": "5.11.0",
@ -644,7 +644,7 @@
},
"packages/embeds/react": {
"name": "@typebot.io/react",
"version": "0.8.8",
"version": "0.8.11",
"dependencies": {
"@typebot.io/js": "workspace:*",
"react": "18.3.1",

View File

@ -8,7 +8,6 @@ const createMockItem = (
): ChoiceInputBlock["items"][number] => ({
id,
content,
value: content,
outgoingEdgeId: "edge1",
});
@ -54,10 +53,38 @@ describe("parseMultipleChoiceReply", () => {
expect(result).toEqual({ status: "success", content: "item, second item" });
});
it("should work with dirty values / input", () => {
const result = parseMultipleChoiceReply("item and second item", {
items: [
createMockItem("id1", "item\n"),
createMockItem("id2", " second item "),
],
});
expect(result).toEqual({ status: "success", content: "item, second item" });
const result2 = parseMultipleChoiceReply(" item and second item \n", {
items: [
createMockItem("id1", "item"),
createMockItem("id2", "second item"),
],
});
expect(result2).toEqual({
status: "success",
content: "item, second item",
});
});
it("should not work when the choice is inside a longer word", () => {
const result = parseMultipleChoiceReply("tests", {
items: [createMockItem("id1", "Just a few tests")],
});
expect(result).toEqual({ status: "fail" });
});
it("should work with korean characters", () => {
const result = parseMultipleChoiceReply("아이템", {
items: [createMockItem("id1", "아이템")],
});
expect(result).toEqual({ status: "success", content: "아이템" });
});
});

View File

@ -1,5 +1,5 @@
import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema";
import { sortByContentLength } from "../../../helpers/choiceMatchers";
import { sortByContentLengthDesc } from "../../../helpers/choiceMatchers";
import type { ParsedReply } from "../../../types";
/**
@ -11,31 +11,32 @@ export const parseMultipleChoiceReply = (
{ items }: { items: ChoiceInputBlock["items"] },
): ParsedReply => {
let remainingInput = reply;
const remainingItems = sortByContentLength(items);
const remainingItems = sortByContentLengthDesc(items);
// We match the IDs first and then filter through the items to have an order independent result
const matchedItemIds: string[] = [];
// Match by ID
for (const item of remainingItems) {
if (item.id && matchIsolatedWord(remainingInput, item.id)) {
if (item.id && includesWholePhrase(remainingInput, item.id)) {
remainingInput = remainingInput.replace(item.id, "").trim();
matchedItemIds.push(item.id);
remainingItems.splice(remainingItems.indexOf(item), 1);
}
}
// Match by internal value
for (const item of remainingItems) {
if (item.value && matchIsolatedWord(remainingInput, item.value)) {
for (const item of remainingItems.filter(
(item) => !matchedItemIds.includes(item.id),
))
if (item.value && includesWholePhrase(remainingInput, item.value)) {
remainingInput = remainingInput.replace(item.value, "").trim();
matchedItemIds.push(item.id);
remainingItems.splice(remainingItems.indexOf(item), 1);
}
}
// Match by content
for (const item of remainingItems) {
if (item.content && matchIsolatedWord(remainingInput, item.content)) {
for (const item of remainingItems.filter(
(item) => !matchedItemIds.includes(item.id),
)) {
if (item.content && includesWholePhrase(remainingInput, item.content)) {
remainingInput = remainingInput.replace(item.content, "").trim();
matchedItemIds.push(item.id);
remainingItems.splice(remainingItems.indexOf(item), 1);
@ -44,15 +45,8 @@ export const parseMultipleChoiceReply = (
// Match by index
for (const [idx, item] of items.entries()) {
console.log(
item,
idx,
matchIsolatedWord(remainingInput, `${idx + 1}`),
item,
remainingItems,
);
if (
matchIsolatedWord(remainingInput, `${idx + 1}`) &&
includesWholePhrase(remainingInput, `${idx + 1}`) &&
remainingItems.some(({ id }) => id === item.id)
) {
remainingInput = remainingInput.replace(`${idx + 1}`, "").trim();
@ -67,13 +61,31 @@ export const parseMultipleChoiceReply = (
status: "success",
content: items
.filter((item) => matchedItemIds.includes(item.id))
.map((item) => item.value ?? item.content)
.map((item) => (item.value ?? item.content)?.trim())
.join(", "),
};
};
/**
* Matches a word that is not part of a longer word.
* Test whether `phrase` appears in `text` as a **stand-alone sequence of words**.
*/
const matchIsolatedWord = (input: string, word: string) =>
new RegExp(`\\b${word}\\b`).test(input);
const includesWholePhrase = (text: string, phrase: string) => {
if (!phrase || !text) return false;
// Normalise: trim ends and split on whitespace, removing empties.
const words = phrase.trim().split(/\s+/u).filter(Boolean);
if (!words.length) return false;
// Escape any regex metacharacters the caller may have put in `phrase`.
const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const inner = escaped.join("\\s+");
// Unicode “word” characters = Letter | Number | Mark
const boundary = "[^\\p{L}\\p{N}\\p{M}]";
// (^|boundary) inner (?=$|boundary)
const pattern = `(?:^|${boundary})${inner}(?=$|${boundary})`;
return new RegExp(pattern, "ui").test(text);
};

View File

@ -1,7 +1,7 @@
import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema";
import {
getItemContent,
sortByContentLength,
sortByContentLengthDesc,
} from "../../../helpers/choiceMatchers";
import type { ParsedReply } from "../../../types";
@ -9,7 +9,7 @@ export const parseSingleChoiceReply = (
displayedItems: ChoiceInputBlock["items"],
inputValue: string,
): ParsedReply => {
const matchedItem = sortByContentLength(displayedItems).find(
const matchedItem = sortByContentLengthDesc(displayedItems).find(
(item) =>
item.id === inputValue ||
(item.value && inputValue.trim() === item.value.trim()) ||

View File

@ -5,7 +5,7 @@ import {
getItemContent,
matchByIndex,
matchByKey,
sortByContentLength,
sortByContentLengthDesc,
} from "../../../helpers/choiceMatchers";
import type { ParsedReply } from "../../../types";
import { injectVariableValuesInPictureChoiceBlock } from "./injectVariableValuesInPictureChoiceBlock";
@ -15,7 +15,7 @@ const parseMultipleChoiceReply = (
inputValue: string,
): ParsedReply => {
const valueMatches = matchByKey({
items: sortByContentLength(displayedItems, "value"),
items: sortByContentLengthDesc(displayedItems, "value"),
inputValue,
key: "value",
});
@ -54,7 +54,7 @@ const parseSingleChoiceReply = (
displayedItems: PictureChoiceBlock["items"],
inputValue: string,
): ParsedReply => {
const matchedItem = sortByContentLength(displayedItems).find(
const matchedItem = sortByContentLengthDesc(displayedItems).find(
(item) =>
item.id === inputValue ||
item.value?.toLowerCase().trim() === inputValue.toLowerCase().trim() ||

View File

@ -27,7 +27,7 @@ export type MatchFunction<T extends ChoiceItem> = (
idx?: number,
) => boolean;
export const sortByContentLength = <T extends ChoiceItem>(
export const sortByContentLengthDesc = <T extends ChoiceItem>(
items: T[],
contentKey = "content",
): T[] =>