mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-06-25 21:01:54 +08:00
🐛 Fix multiple choice parsing to work with non-ASCII chars
Closes #2231
This commit is contained in:
parent
f1e7eac191
commit
4d899c09ef
4
bun.lock
4
bun.lock
@ -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",
|
||||
|
||||
@ -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: "아이템" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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()) ||
|
||||
|
||||
@ -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() ||
|
||||
|
||||
@ -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[] =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user