mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-06-22 21:06:40 +08:00
🐛 (picture choice) Fix invalid matching when title equals indices
This commit is contained in:
parent
2b626f75f3
commit
d4b33ef4ee
@ -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: [
|
||||
|
||||
@ -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(", "),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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" };
|
||||
|
||||
@ -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 "";
|
||||
};
|
||||
11
packages/bot-engine/src/helpers/parseItemContent.ts
Normal file
11
packages/bot-engine/src/helpers/parseItemContent.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user