diff --git a/backend/danswer/prompts/chat_prompts.py b/backend/danswer/prompts/chat_prompts.py index 5cc7e105bb8..fbd33f7f094 100644 --- a/backend/danswer/prompts/chat_prompts.py +++ b/backend/danswer/prompts/chat_prompts.py @@ -128,12 +128,15 @@ HISTORY_QUERY_REPHRASE = f""" -Given the following conversation and a follow up input, rephrase the follow up into a SHORT, \ -standalone query (which captures any relevant context from previous messages) for a vectorstore. -IMPORTANT: EDIT THE QUERY TO BE AS CONCISE AS POSSIBLE. Respond with a short, compressed phrase \ -with mainly keywords instead of a complete sentence. +Given the following conversation and a follow up input, rephrase the follow up into a \ +STANDALONE, natural-language question (which captures any relevant context from previous \ +messages) for a search engine. +Keep it a complete, natural-language question: resolve references (pronouns like "their", \ +"it", "that account") using the conversation. Do NOT compress the query into bare keywords \ +and do NOT drop meaningful words from the user's question — the full phrasing retrieves the \ +correct record (compressing "who is the TAM for X" down to "TAM X" reranks the right record \ +out of the result window). If there is a clear change in topic, disregard the previous messages. -Strip out any information that is not relevant for the retrieval task. If the follow up message is an error or code snippet, repeat the same input back EXACTLY. {GENERAL_SEP_PAT} @@ -142,7 +145,7 @@ {GENERAL_SEP_PAT} Follow Up Input: {{question}} -Standalone question (Respond with only the short combined query): +Standalone question (Respond with only the standalone question): """.strip() diff --git a/backend/danswer/tools/search/search_tool.py b/backend/danswer/tools/search/search_tool.py index 6b5e7d533de..10b2f9c8083 100644 --- a/backend/danswer/tools/search/search_tool.py +++ b/backend/danswer/tools/search/search_tool.py @@ -160,8 +160,20 @@ def get_args_for_non_tool_calling_llm( ): return None + # skip_first_rephrase: when there's no conversation history (a first / + # single-turn question), search the user's full question verbatim instead + # of letting the rephraser compress it to bare keywords. The + # HISTORY_QUERY_REPHRASE prompt deliberately strips queries to "mainly + # keywords"; for entity lookups that surface many near-duplicate records + # (e.g. "who is the TAM for pepsi" -> "TAM Pepsi", where only one of ~50 + # "Pepsi" account records holds the field) the compressed query reranks + # the wrong record into the context window and the answer comes back + # "not available". The full natural-language question retrieves the right + # record. Follow-ups (history present) are still rephrased, since they + # need prior-turn context folded in. This matches the Slack / one-shot + # flow, which already leaves the first query untouched. rephrased_query = history_based_query_rephrase( - query=query, history=history, llm=llm + query=query, history=history, llm=llm, skip_first_rephrase=True ) return {"query": rephrased_query} diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index ddde5e7da5f..08ec5e39b8e 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -19,6 +19,11 @@ import ChatInputOption from "./ChatInputOption"; import { FaBrain } from "react-icons/fa"; import { Persona } from "@/app/admin/assistants/interfaces"; import { assistantDisplayName } from "@/lib/assistants/displayName"; +import { + getMentionQuery, + filterAssistantsByMention, + stripMentionToken, +} from "@/lib/assistants/mentions"; import { FilterManager, LlmOverrideManager } from "@/lib/hooks"; import { SelectedFilterDisplay } from "./SelectedFilterDisplay"; import { useChatContext } from "@/components/context/ChatContext"; @@ -136,7 +141,10 @@ export function ChatInputBar({ const updateCurrentPersona = (persona: Persona) => { onSetSelectedAssistant(persona.id == selectedAssistant.id ? null : persona); hideSuggestions(); - setMessage(""); + // Remove only the "@mention" token the user was typing and keep the rest of + // the message intact (previously this cleared the entire input, discarding + // any question already typed before/around the mention). + setMessage(stripMentionToken(message)); }; // Click out of assistant suggestions @@ -157,33 +165,27 @@ export function ChatInputBar({ }; }, []); + // The partial assistant name being typed after "@" (anywhere in the message), + // or null when the caret isn't inside an @mention token. See lib/assistants/mentions. + const mentionQuery = getMentionQuery(message); + // Complete user input handling const handleInputChange = (event: React.ChangeEvent) => { const text = event.target.value; setMessage(text); - if (!text.startsWith("@")) { - hideSuggestions(); - return; - } - - // If looking for an assistant...fup - const match = text.match(/(?:\s|^)@(\w*)$/); - if (match) { + // Show the assistant typeahead whenever the caret is inside an @mention + // token — anywhere in the message (previously this only fired when "@" was + // the first character of the input). + if (getMentionQuery(text) !== null) { setShowSuggestions(true); } else { hideSuggestions(); } }; - const filteredPersonas = personas.filter((persona) => - persona.name.toLowerCase().startsWith( - message - .slice(message.lastIndexOf("@") + 1) - .split(/\s/)[0] - .toLowerCase() - ) - ); + // Match on both the display name and the raw name so typing either works. + const filteredPersonas = filterAssistantsByMention(personas, mentionQuery ?? ""); const [assistantIconIndex, setAssistantIconIndex] = useState(0); diff --git a/web/src/lib/assistants/mentions.test.ts b/web/src/lib/assistants/mentions.test.ts new file mode 100644 index 00000000000..81a83775f98 --- /dev/null +++ b/web/src/lib/assistants/mentions.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { + getMentionQuery, + filterAssistantsByMention, + stripMentionToken, +} from "./mentions"; + +// Minimal assistant fixtures — the helpers only read `name` / `display_name`. +type A = { name: string; display_name?: string | null }; +const ASSISTANTS: A[] = [ + { name: "AMER Benefits" }, + { name: "amer-payroll" }, + { name: "internal-hr-bot", display_name: "HR Helper" }, + { name: "kb-search", display_name: "Knowledge" }, + { name: "Orchestrator" }, +]; +const names = (list: A[]) => list.map((a) => a.name); + +describe("getMentionQuery (when does the @ typeahead fire, and on what query)", () => { + it("returns '' for a bare '@' (just typed, show full list)", () => { + expect(getMentionQuery("@")).toBe(""); + }); + + it("captures the partial name after '@'", () => { + expect(getMentionQuery("@AM")).toBe("AM"); + expect(getMentionQuery("@AMER")).toBe("AMER"); + expect(getMentionQuery("@123")).toBe("123"); // \w includes digits + }); + + it("fires mid-message (after whitespace), not just at the start", () => { + expect(getMentionQuery("what is @AM")).toBe("AM"); + expect(getMentionQuery("what is the 401k policy @AMER")).toBe("AMER"); + expect(getMentionQuery("line one\n@bob")).toBe("bob"); // newline counts as whitespace + }); + + it("returns null when there is no active mention token", () => { + expect(getMentionQuery("")).toBeNull(); + expect(getMentionQuery("hello world")).toBeNull(); + }); + + it("ignores '@' embedded in a word (e.g. an email address)", () => { + expect(getMentionQuery("contact me@foo")).toBeNull(); + }); + + it("closes (null) once the mention isn't anchored at the caret/end", () => { + expect(getMentionQuery("@AMER what")).toBeNull(); // text typed after the mention + expect(getMentionQuery("@AMER ")).toBeNull(); // a trailing space ends the token + }); +}); + +describe("filterAssistantsByMention (case-insensitive, name OR display name)", () => { + it("returns all assistants for an empty query (the just-typed-'@' case)", () => { + expect(filterAssistantsByMention(ASSISTANTS, "")).toHaveLength( + ASSISTANTS.length + ); + }); + + it("matches the raw name, case-insensitively", () => { + expect(names(filterAssistantsByMention(ASSISTANTS, "am"))).toEqual([ + "AMER Benefits", + "amer-payroll", + ]); + expect(names(filterAssistantsByMention(ASSISTANTS, "ORCH"))).toEqual([ + "Orchestrator", + ]); + }); + + it("matches the display name even when it differs from the raw name", () => { + // "HR Helper" matches "hr"; its raw name "internal-hr-bot" does not start with "hr". + expect(names(filterAssistantsByMention(ASSISTANTS, "hr"))).toEqual([ + "internal-hr-bot", + ]); + }); + + it("still matches the raw name when a display name is also set", () => { + // query "kb" matches raw name "kb-search" (display "Knowledge" does not). + expect(names(filterAssistantsByMention(ASSISTANTS, "kb"))).toEqual([ + "kb-search", + ]); + }); + + it("returns [] when nothing matches", () => { + expect(filterAssistantsByMention(ASSISTANTS, "zzz")).toEqual([]); + }); +}); + +describe("stripMentionToken (preserve typed text when an assistant is picked)", () => { + it("clears a start-of-message mention", () => { + expect(stripMentionToken("@AMER")).toBe(""); + expect(stripMentionToken("@a")).toBe(""); + expect(stripMentionToken("@")).toBe(""); + expect(stripMentionToken("hi @")).toBe("hi"); + }); + + it("removes only the trailing mention token, keeping the question", () => { + expect(stripMentionToken("what is the 401k @AMER")).toBe( + "what is the 401k" + ); + expect(stripMentionToken("line one\n@bob")).toBe("line one"); + }); + + it("leaves text without a trailing mention untouched", () => { + expect(stripMentionToken("plain question with no mention")).toBe( + "plain question with no mention" + ); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: compose the helpers exactly as ChatInputBar's handlers do, to +// exercise the end-to-end typeahead flow without a DOM harness. +// - handleInputChange: show suggestions iff getMentionQuery(text) !== null +// - filteredPersonas: filterAssistantsByMention(personas, query ?? "") +// - updateCurrentPersona (on select): message becomes stripMentionToken(message) +// --------------------------------------------------------------------------- +function typeahead(input: string, assistants: A[]) { + const query = getMentionQuery(input); + const showSuggestions = query !== null; + const suggestions = showSuggestions + ? filterAssistantsByMention(assistants, query ?? "") + : []; + return { showSuggestions, suggestions }; +} + +describe("integration: @mention typeahead flow", () => { + it("opens and narrows the list as the user types the mention", () => { + expect(typeahead("@", ASSISTANTS).suggestions).toHaveLength( + ASSISTANTS.length + ); + expect(names(typeahead("@am", ASSISTANTS).suggestions)).toEqual([ + "AMER Benefits", + "amer-payroll", + ]); + expect(names(typeahead("@orch", ASSISTANTS).suggestions)).toEqual([ + "Orchestrator", + ]); + }); + + it("ends the mention at a space (multi-word names aren't typeable past word 1)", () => { + // Known limitation: \w stops at the space, so "@amer b" is treated as a + // finished mention "@amer" + literal " b" — the typeahead closes. Users + // narrow with the first word ("@amer") then pick from the list. + expect(typeahead("@amer b", ASSISTANTS).showSuggestions).toBe(false); + }); + + it("opens for a mid-message mention after a typed question", () => { + const { showSuggestions, suggestions } = typeahead( + "what is the 401k policy @hr", + ASSISTANTS + ); + expect(showSuggestions).toBe(true); + expect(names(suggestions)).toEqual(["internal-hr-bot"]); // matched via display name + }); + + it("stays closed for ordinary text and email addresses", () => { + expect(typeahead("what is the 401k policy", ASSISTANTS).showSuggestions).toBe( + false + ); + expect(typeahead("email me@uipath", ASSISTANTS).showSuggestions).toBe(false); + }); + + it("preserves the typed question when an assistant is selected mid-message", () => { + const input = "what is the 401k policy @amer"; + // user picks an assistant -> ChatInputBar sets message = stripMentionToken(message) + expect(stripMentionToken(input)).toBe("what is the 401k policy"); + }); + + it("leaves an empty composer when the mention was the whole message", () => { + expect(stripMentionToken("@amer")).toBe(""); + }); +}); diff --git a/web/src/lib/assistants/mentions.ts b/web/src/lib/assistants/mentions.ts new file mode 100644 index 00000000000..a1852db48db --- /dev/null +++ b/web/src/lib/assistants/mentions.ts @@ -0,0 +1,48 @@ +// "@mention" assistant-picker helpers for the chat composer. +// +// An *active* mention token is an "@" that is either the first character of the +// input OR immediately preceded by whitespace, followed by the partial assistant +// name being typed, anchored at the END of the input (i.e. where the caret is). +// This lets the typeahead fire anywhere in the message — not just at the start — +// while ignoring "@" inside a word (e.g. an email address like "me@foo"). +// +// These are extracted as pure functions so the chat-input behavior is unit +// testable; ChatInputBar wires them to its textarea state. +import { assistantDisplayName } from "./displayName"; + +export const MENTION_TOKEN = /(?:\s|^)@(\w*)$/; + +/** + * The partial assistant name currently being typed after "@", or `null` when the + * caret is not inside an active mention token. An empty string ("") means "@" + * was just typed with no name yet — show the full list. + */ +export function getMentionQuery(text: string): string | null { + const match = text.match(MENTION_TOKEN); + return match ? match[1] : null; +} + +/** + * Assistants whose display name OR raw name starts with the (case-insensitive) + * mention query. An empty query returns all assistants (the just-typed-"@" case). + * Generic so callers can pass full `Persona`s (or trimmed test fixtures). + */ +export function filterAssistantsByMention< + T extends { name: string; display_name?: string | null }, +>(assistants: T[], query: string): T[] { + const q = query.toLowerCase(); + return assistants.filter( + (a) => + assistantDisplayName(a).toLowerCase().startsWith(q) || + a.name.toLowerCase().startsWith(q) + ); +} + +/** + * Remove only the trailing "@mention" token (and a single preceding space) the + * user was typing, preserving the rest of the message. Used when an assistant is + * picked so a question already typed around the mention isn't discarded. + */ +export function stripMentionToken(text: string): string { + return text.replace(/(?:\s)?@\w*$/, ""); +}