From 435b015c9c4684b4e3414eb860fe1e904fb22d46 Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Fri, 26 Jun 2026 17:55:45 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(chat):=20@mention=20assistant=20picker?= =?UTF-8?q?=20=E2=80=94=20mid-message=20+=20preserve=20typed=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat composer already supported answering a message with a non-default assistant (alternate_assistant_id), but the "@" typeahead only fired when "@" was the first character and selecting an assistant wiped the whole input. - Trigger the assistant typeahead for an "@mention" anywhere in the message (after any whitespace), not just at the start. - On select, strip only the "@mention" token and keep the rest of the message (previously the entire input was cleared, discarding a typed question). - Match the typeahead on display name OR raw name. Extracts the mention logic into a pure, testable module (lib/assistants/mentions.ts: getMentionQuery / filterAssistantsByMention / stripMentionToken) with unit + integration tests (mentions.test.ts). Per-message override and the visible-assistant scope are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/chat/input/ChatInputBar.tsx | 36 ++--- web/src/lib/assistants/mentions.test.ts | 171 ++++++++++++++++++++++++ web/src/lib/assistants/mentions.ts | 48 +++++++ 3 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/assistants/mentions.test.ts create mode 100644 web/src/lib/assistants/mentions.ts 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*$/, ""); +} From b3415f0415a80e5d8d1093474308e48f40a03e0b Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Wed, 24 Jun 2026 18:26:59 +0530 Subject: [PATCH 2/2] fix(search): stop chat query-rephraser compressing questions to keywords The chat flow rephrases every question (even single-turn) via history_based_query_rephrase, and HISTORY_QUERY_REPHRASE instructed the LLM to "compress to mainly keywords". For entity lookups against sources with many near-duplicate records this dropped the answer: "who is the TAM for pepsi" -> "TAM Pepsi", and since only 1 of ~50 indexed "Pepsi" account records holds the TAM/CSM fields, the bare-keyword query reranked PepsiCo, Inc. to #14 -- outside the 10 chunks fed to the LLM -- so the answer came back "TAM not available". Adding a second term ("TAM or CSM") was enough signal to pull the right record back into the window, which is why "TAM or CSM for pepsi" worked while "TAM for pepsi" did not (same pattern for every account). Verified by replay: full question -> rank #2/#3, "TAM Pepsi" -> rank #14. Slack/one-shot was unaffected (it leaves the first query verbatim). Two changes: - HISTORY_QUERY_REPHRASE now produces a STANDALONE natural-language question (resolve pronouns/references from history, but do NOT compress to keywords or drop meaningful words). Softened rephrase reranks the record #14 -> #3 (into context). Follow-up reference resolution is kept. - search_tool passes skip_first_rephrase=True so a first/single-turn question is searched verbatim (matches the Slack/one-shot flow), avoiding any rephrase round-trip when there's no history to fold in. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/danswer/prompts/chat_prompts.py | 15 +++++++++------ backend/danswer/tools/search/search_tool.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) 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}