Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions backend/danswer/prompts/chat_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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()


Expand Down
14 changes: 13 additions & 1 deletion backend/danswer/tools/search/search_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
36 changes: 19 additions & 17 deletions web/src/app/chat/input/ChatInputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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<HTMLTextAreaElement>) => {
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);

Expand Down
171 changes: 171 additions & 0 deletions web/src/lib/assistants/mentions.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
48 changes: 48 additions & 0 deletions web/src/lib/assistants/mentions.ts
Original file line number Diff line number Diff line change
@@ -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*$/, "");
}
Loading