From 5b1ede5ce1358a411e54edd9538bdd08ac6b957f Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Mon, 29 Jun 2026 17:54:16 +0530 Subject: [PATCH 01/41] fix(search): skip rephrase for first/single-turn chat questions Pulls only the skip_first_rephrase=True change from the rephrase fix (feature/assistant-mention): when there's no conversation history, the chat flow searches the user's full question verbatim instead of running it through history_based_query_rephrase. Matches the Slack/one-shot flow, which already leaves the first query untouched, and avoids an unnecessary rephrase round-trip when there's no prior turn to fold in. (The softened HISTORY_QUERY_REPHRASE prompt from that same fix is intentionally NOT included here.) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/danswer/tools/search/search_tool.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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} From 5a94a9c499faad466b8da5eae55f2f7ba653b090 Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Mon, 29 Jun 2026 18:15:10 +0530 Subject: [PATCH 02/41] feat(search): auto-routed one-shot Search tab (assistant routing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Google-like Search experience: the user types a question and the system picks the right assistant automatically, instead of selecting one manually. Built behind a staged rollout gate (admin-only by default) since chat is now GA. Backend: - Settings.auto_search_rollout (off | admin_only | everyone, default admin_only). A new Settings field is absent from the persisted dict, so existing deployments land on admin_only automatically; flip in Admin -> Settings, no redeploy. - Persona.routing_instructions: router-only free-text metadata (migration c0d1e2f3a4b5, nullable). NEVER shown to users; the router falls back to `description` when blank. Wired through CreatePersonaRequest / PersonaSnapshot / upsert_persona + an admin-editor textarea. - secondary_llm_flows/assistant_router.py: build_router_catalog (from an ACL-filtered persona list) + route_question (one fast-LLM call -> best persona id + confidence). Fail-OPEN: ambiguity / parse failure / LLM error -> None, and the caller uses the all-source default persona. parse_route_response also accepts the gateway model's single-quoted Python-dict output (json.loads -> ast.literal_eval fallback). Unit tests included. - POST /query/auto-search: enforces the rollout gate on the trusted side (403), routes, re-checks the chosen persona's ACL with get_persona_by_id(..., user=), then answers via the existing one-shot get_search_answer. Honors the routed assistant's prompt + doc-set scope. Passes the authenticated user so the Q + A persist per-user (one_shot=True); returns answered_by + chat_message_id. Frontend: - New /auto-search page: single search box, no pickers; renders the answer + "Answered by X" + sources + πŸ‘/πŸ‘Ž + text feedback (reuses the existing /chat/create-chat-message-feedback endpoint with the returned chat_message_id). Visibility gated by auto_search_rollout + user role (backend enforces for real). - Admin -> Settings: rollout selector. routing_instructions textarea in the assistant editor (labelled "not shown to users"). Validated end-to-end against prod personas + the live gateway LLM: routes Orchestrator/SRE/DU/AMERBenefits/help-ownership correctly and falls back for off-topic questions. The current Chat flow is untouched (purely additive). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...d1e2f3a4b5_persona_routing_instructions.py | 35 +++ backend/danswer/db/models.py | 6 + backend/danswer/db/persona.py | 4 + .../secondary_llm_flows/assistant_router.py | 174 +++++++++++++ .../danswer/server/features/persona/models.py | 4 + .../danswer/server/query_and_chat/models.py | 29 +++ .../server/query_and_chat/query_backend.py | 146 +++++++++++ backend/danswer/server/settings/models.py | 23 ++ .../test_assistant_router.py | 140 ++++++++++ .../app/admin/assistants/AssistantEditor.tsx | 10 + web/src/app/admin/assistants/interfaces.ts | 2 + web/src/app/admin/assistants/lib.ts | 4 + web/src/app/admin/settings/SettingsForm.tsx | 17 ++ web/src/app/admin/settings/interfaces.ts | 4 + web/src/app/auto-search/AutoSearch.tsx | 242 ++++++++++++++++++ web/src/app/auto-search/page.tsx | 19 ++ 16 files changed, 859 insertions(+) create mode 100644 backend/alembic/versions/c0d1e2f3a4b5_persona_routing_instructions.py create mode 100644 backend/danswer/secondary_llm_flows/assistant_router.py create mode 100644 backend/tests/unit/danswer/secondary_llm_flows/test_assistant_router.py create mode 100644 web/src/app/auto-search/AutoSearch.tsx create mode 100644 web/src/app/auto-search/page.tsx diff --git a/backend/alembic/versions/c0d1e2f3a4b5_persona_routing_instructions.py b/backend/alembic/versions/c0d1e2f3a4b5_persona_routing_instructions.py new file mode 100644 index 00000000000..9209578a23a --- /dev/null +++ b/backend/alembic/versions/c0d1e2f3a4b5_persona_routing_instructions.py @@ -0,0 +1,35 @@ +"""persona: add routing_instructions (router-only metadata) + +Adds persona.routing_instructions β€” admin-editable free-text guidance read ONLY +by the auto-routed Search tab's assistant router (which assistant to pick for a +question). It is NEVER rendered in the user-facing UI; `description` stays the +short human-facing label. Nullable with no backfill β€” the router falls back to +`description` when this is blank, so existing assistants route on what they have +today until an admin fills this in. See db/models.py::Persona.routing_instructions +and secondary_llm_flows/assistant_router. + +Revision ID: c0d1e2f3a4b5 +Revises: b9c0d1e2f3a4 +Create Date: 2026-06-29 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c0d1e2f3a4b5" +down_revision = "b9c0d1e2f3a4" +branch_labels: None = None +depends_on: None = None + + +def upgrade() -> None: + op.add_column( + "persona", + sa.Column("routing_instructions", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("persona", "routing_instructions") diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index cb69b89ec40..e226742fd7c 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -1012,6 +1012,12 @@ class Persona(Base): # Backfilled to `name`; chat falls back to `name` if blank. display_name: Mapped[str | None] = mapped_column(String, nullable=True) description: Mapped[str] = mapped_column(String) + # Router-only guidance for the auto-routed Search tab: free-text "route here + # for / example questions / do NOT route here (-> other assistant)". Read + # ONLY by the assistant router (secondary_llm_flows/assistant_router); NEVER + # rendered in the user-facing UI (that's what `description` is for). Router + # falls back to `description` when this is blank. + routing_instructions: Mapped[str | None] = mapped_column(Text, nullable=True) # Currently stored but unused, all flows use hybrid search_type: Mapped[SearchType] = mapped_column( Enum(SearchType, native_enum=False), default=SearchType.HYBRID diff --git a/backend/danswer/db/persona.py b/backend/danswer/db/persona.py index 600884f4499..1e3b10d4c0c 100644 --- a/backend/danswer/db/persona.py +++ b/backend/danswer/db/persona.py @@ -73,6 +73,7 @@ def create_update_persona( name=create_persona_request.name, display_name=create_persona_request.display_name, description=create_persona_request.description, + routing_instructions=create_persona_request.routing_instructions, num_chunks=create_persona_request.num_chunks, llm_relevance_filter=create_persona_request.llm_relevance_filter, llm_filter_extraction=create_persona_request.llm_filter_extraction, @@ -377,6 +378,7 @@ def upsert_persona( db_session: Session, rerank_enabled: bool = False, display_name: str | None = None, + routing_instructions: str | None = None, prompt_ids: list[int] | None = None, document_set_ids: list[int] | None = None, tool_ids: list[int] | None = None, @@ -425,6 +427,7 @@ def upsert_persona( persona.name = name persona.display_name = display_name or name persona.description = description + persona.routing_instructions = routing_instructions persona.num_chunks = num_chunks persona.llm_relevance_filter = llm_relevance_filter persona.llm_filter_extraction = llm_filter_extraction @@ -458,6 +461,7 @@ def upsert_persona( name=name, display_name=display_name or name, description=description, + routing_instructions=routing_instructions, num_chunks=num_chunks, llm_relevance_filter=llm_relevance_filter, llm_filter_extraction=llm_filter_extraction, diff --git a/backend/danswer/secondary_llm_flows/assistant_router.py b/backend/danswer/secondary_llm_flows/assistant_router.py new file mode 100644 index 00000000000..b68fc5736cc --- /dev/null +++ b/backend/danswer/secondary_llm_flows/assistant_router.py @@ -0,0 +1,174 @@ +"""Auto-route a question to the most relevant assistant (persona). + +Powers the one-shot Search tab so users don't pick an assistant manually. Given +the question + a catalog of the user's accessible assistants (name + the +router-only `routing_instructions`, falling back to `description`), ONE fast-LLM +call returns the best persona id + a confidence. + +Design notes: +- **ACL is the caller's job.** `build_router_catalog` only ever sees the list it + is handed β€” callers MUST pass the user's accessible, visible, non-Slack + personas. The /search/auto endpoint additionally re-checks the chosen persona + with `get_persona_by_id(..., user=)` on the trusted side before answering. +- **Fail-OPEN.** Any ambiguity, parse failure, or LLM error returns + `persona_id=None`; the caller then uses the all-source default persona, so the + search box never dead-ends. +- **Pure, testable core.** `parse_route_response` does the parsing/validation + with no I/O so routing logic is unit-testable without an LLM. +""" +import ast +import json +import re + +from pydantic import BaseModel + +from danswer.db.models import Persona +from danswer.llm.interfaces import LLM +from danswer.llm.utils import message_to_string +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +# Below this, treat the route as "not confident" -> fall back to all-source. +DEFAULT_MIN_CONFIDENCE = 0.5 + + +class RouterCatalogEntry(BaseModel): + persona_id: int + name: str + routing_text: str + + +class RouteResult(BaseModel): + # None => no confident match; caller uses the all-source fallback persona. + persona_id: int | None + confidence: float + + +def _routing_text(persona: Persona) -> str: + """Router signal for a persona: the router-only `routing_instructions`, or + `description` when that's blank (graceful fallback for un-curated assistants).""" + instructions = (persona.routing_instructions or "").strip() + if instructions: + return instructions + return (persona.description or "").strip() + + +def build_router_catalog(personas: list[Persona]) -> list[RouterCatalogEntry]: + """Build the routable-assistant catalog from an ALREADY ACL-filtered list. + + Caller passes the user's accessible + visible + non-Slack personas. Entries + with no routing text (nothing for the LLM to match on) are skipped.""" + catalog: list[RouterCatalogEntry] = [] + for persona in personas: + text = _routing_text(persona) + if not text: + continue + catalog.append( + RouterCatalogEntry( + persona_id=persona.id, name=persona.name, routing_text=text + ) + ) + return catalog + + +_ROUTER_PROMPT = """\ +You are a router that picks the single best assistant to answer a user's question. +Each assistant covers a specific product area or topic. + +USER QUESTION: +{question} + +AVAILABLE ASSISTANTS: +{catalog} + +Pick the ONE assistant whose scope best matches the question. Respond with ONLY a \ +JSON object: {{"persona_id": , "confidence": }}. +Use null with a low confidence if the question is generic, spans many areas, or no \ +assistant clearly fits β€” do not guess. +""" + + +def _render_catalog(catalog: list[RouterCatalogEntry]) -> str: + return "\n\n".join( + f"[id={entry.persona_id}] {entry.name}\n{entry.routing_text}" + for entry in catalog + ) + + +def parse_route_response( + raw: str, + valid_ids: set[int], + min_confidence: float = DEFAULT_MIN_CONFIDENCE, +) -> RouteResult: + """Parse the router LLM's JSON into a validated RouteResult. Pure / no I/O. + + Fail-OPEN: returns persona_id=None when the response is unparseable, names an + id not in the catalog, or reports confidence below the threshold.""" + match = re.search(r"\{[^{}]*\}", raw) + if not match: + return RouteResult(persona_id=None, confidence=0.0) + blob = match.group(0) + try: + data = json.loads(blob) + except (ValueError, TypeError): + # The gateway model (gpt-4.1-mini) often returns a PYTHON dict literal + # with single quotes (e.g. {'persona_id': 35, 'confidence': 1.0}), which + # json.loads rejects. ast.literal_eval safely parses literals. + try: + data = ast.literal_eval(blob) + except (ValueError, SyntaxError, TypeError): + return RouteResult(persona_id=None, confidence=0.0) + if not isinstance(data, dict): + return RouteResult(persona_id=None, confidence=0.0) + + raw_conf = data.get("confidence") + try: + confidence = float(raw_conf) + except (TypeError, ValueError): + confidence = 0.0 + confidence = max(0.0, min(1.0, confidence)) + + raw_id = data.get("persona_id") + # bool is an int subclass β€” reject it explicitly. + if not isinstance(raw_id, int) or isinstance(raw_id, bool): + return RouteResult(persona_id=None, confidence=confidence) + if raw_id not in valid_ids: + return RouteResult(persona_id=None, confidence=confidence) + if confidence < min_confidence: + return RouteResult(persona_id=None, confidence=confidence) + return RouteResult(persona_id=raw_id, confidence=confidence) + + +def route_question( + question: str, + catalog: list[RouterCatalogEntry], + llm: LLM, + min_confidence: float = DEFAULT_MIN_CONFIDENCE, +) -> RouteResult: + """One fast-LLM call -> the best persona id (or None to fall back). + + Fail-OPEN on empty input / LLM error -> persona_id=None.""" + if not catalog or not question.strip(): + return RouteResult(persona_id=None, confidence=0.0) + + prompt = _ROUTER_PROMPT.format( + question=question.strip(), catalog=_render_catalog(catalog) + ) + valid_ids = {entry.persona_id for entry in catalog} + try: + raw = message_to_string(llm.invoke(prompt)) + except Exception as e: + logger.warning("assistant router: LLM call failed, falling back: %s", e) + return RouteResult(persona_id=None, confidence=0.0) + + result = parse_route_response(raw, valid_ids, min_confidence=min_confidence) + logger.info( + "assistant router: question=%r -> persona_id=%s confidence=%.2f", + question[:80], + result.persona_id, + result.confidence, + ) + return result diff --git a/backend/danswer/server/features/persona/models.py b/backend/danswer/server/features/persona/models.py index 6ae3f59aef7..3415a0223c0 100644 --- a/backend/danswer/server/features/persona/models.py +++ b/backend/danswer/server/features/persona/models.py @@ -20,6 +20,8 @@ class CreatePersonaRequest(BaseModel): # Optional user-friendly label shown in chat; defaults to `name` if omitted. display_name: str | None = None description: str + # Router-only metadata for the auto-routed Search tab; never shown to users. + routing_instructions: str | None = None num_chunks: float llm_relevance_filter: bool is_public: bool @@ -49,6 +51,7 @@ class PersonaSnapshot(BaseModel): is_public: bool display_priority: int | None description: str + routing_instructions: str | None num_chunks: float | None llm_relevance_filter: bool llm_filter_extraction: bool @@ -87,6 +90,7 @@ def from_model( is_public=persona.is_public, display_priority=persona.display_priority, description=persona.description, + routing_instructions=persona.routing_instructions, num_chunks=persona.num_chunks, llm_relevance_filter=persona.llm_relevance_filter, llm_filter_extraction=persona.llm_filter_extraction, diff --git a/backend/danswer/server/query_and_chat/models.py b/backend/danswer/server/query_and_chat/models.py index bf6bf7b924f..08b4de5088d 100644 --- a/backend/danswer/server/query_and_chat/models.py +++ b/backend/danswer/server/query_and_chat/models.py @@ -4,6 +4,8 @@ from pydantic import BaseModel from pydantic import root_validator +from danswer.chat.models import CitationInfo +from danswer.chat.models import QADocsResponse from danswer.chat.models import RetrievalDocs from danswer.configs.constants import DocumentSource from danswer.configs.constants import MessageType @@ -231,5 +233,32 @@ class AdminSearchResponse(BaseModel): documents: list[SearchDoc] +class AutoSearchRequest(BaseModel): + """A single point-in-time question for the auto-routed Search tab.""" + + message: str + + +class AnsweredByAssistant(BaseModel): + persona_id: int + name: str + display_name: str | None + # True if the router picked this assistant; False if it fell back to the + # all-source default persona (low confidence / no clear match). + routed: bool + confidence: float + + +class AutoSearchResponse(BaseModel): + answer: str | None = None + citations: list[CitationInfo] | None = None + docs: QADocsResponse | None = None + # The persisted AI message id β€” used to attach πŸ‘/πŸ‘Ž + text feedback via the + # existing /chat/create-chat-message-feedback endpoint. + chat_message_id: int | None = None + answered_by: AnsweredByAssistant + error_msg: str | None = None + + class DanswerAnswer(BaseModel): answer: str | None diff --git a/backend/danswer/server/query_and_chat/query_backend.py b/backend/danswer/server/query_and_chat/query_backend.py index 57f49e143b6..037b66fae5b 100644 --- a/backend/danswer/server/query_and_chat/query_backend.py +++ b/backend/danswer/server/query_and_chat/query_backend.py @@ -15,8 +15,24 @@ from danswer.db.tag import get_tags_by_value_prefix_for_source_types from danswer.document_index.factory import get_default_document_index from danswer.document_index.vespa.index import VespaIndex +from danswer.auth.schemas import UserRole +from danswer.configs.constants import MessageType +from danswer.db.persona import get_persona_by_id +from danswer.db.persona import get_personas +from danswer.llm.factory import get_default_llms +from danswer.one_shot_answer.answer_question import get_search_answer from danswer.one_shot_answer.answer_question import stream_search_answer from danswer.one_shot_answer.models import DirectQARequest +from danswer.one_shot_answer.models import ThreadMessage +from danswer.search.models import OptionalSearchSetting +from danswer.search.models import RetrievalDetails +from danswer.secondary_llm_flows.assistant_router import build_router_catalog +from danswer.secondary_llm_flows.assistant_router import route_question +from danswer.server.query_and_chat.models import AnsweredByAssistant +from danswer.server.query_and_chat.models import AutoSearchRequest +from danswer.server.query_and_chat.models import AutoSearchResponse +from danswer.server.settings.models import AutoSearchRollout +from danswer.server.settings.store import load_settings from danswer.search.models import IndexFilters from danswer.search.models import SearchDoc from danswer.search.preprocessing.access_filters import build_access_filters_for_user @@ -174,3 +190,133 @@ def get_answer_with_quote( max_history_tokens=0, ) return StreamingResponse(packets, media_type="application/json") + + +# All-source default persona ("Darwin", id 0) β€” the router's fail-open fallback +# when no assistant clearly fits the question. +DEFAULT_SEARCH_PERSONA_ID = 0 + + +def _auto_search_allowed(rollout: AutoSearchRollout, user: User | None) -> bool: + """Trusted-side rollout gate for the auto-routed Search tab. OFF blocks + everyone; EVERYONE allows all; ADMIN_ONLY allows admins (and the no-auth + superuser context where user is None, mirroring get_personas' convention).""" + if rollout == AutoSearchRollout.OFF: + return False + if rollout == AutoSearchRollout.EVERYONE: + return True + # ADMIN_ONLY + if user is None: + return True + return user.role == UserRole.ADMIN + + +@basic_router.post("/auto-search") +def auto_search( + auto_search_request: AutoSearchRequest, + request: Request, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), + # Mirrors /chat/send-message + /stream-answer-with-quote: request-rate cap + # first (cheap when off), token-budget check second. + _rate_limit: None = Depends(check_message_request_rate_limit), + _: None = Depends(check_token_rate_limits), +) -> AutoSearchResponse: + """Auto-route a point-in-time question to the best assistant, then answer it + with the existing one-shot engine. Picks the assistant via the LLM router + over the user's ACL-filtered, visible assistants; falls back to the + all-source default persona when no assistant clearly fits.""" + # Trusted-side rollout gate β€” never rely on the FE hiding the tab. + if not _auto_search_allowed(load_settings().auto_search_rollout, user): + raise HTTPException( + status_code=403, + detail="Auto-search is not enabled for your account.", + ) + + question = auto_search_request.message + logger.info(f"Auto-search question: {question}") + + # Router catalog = the user's accessible, VISIBLE, non-Slack assistants. + # get_personas already enforces ACL (public / per-user / per-group). + personas = [ + persona + for persona in get_personas( + user_id=user.id if user else None, + db_session=db_session, + include_default=False, + include_slack_bot_personas=False, + ) + if persona.is_visible + ] + catalog = build_router_catalog(personas) + + # Route (fail-open): any LLM/availability issue -> fall back to all-source. + routed_persona_id: int | None = None + routed_confidence = 0.0 + try: + _, fast_llm = get_default_llms() + route = route_question(question, catalog, fast_llm) + routed_persona_id = route.persona_id + routed_confidence = route.confidence + except Exception as e: + logger.warning("Auto-search routing unavailable, using fallback: %s", e) + + target_persona_id = ( + routed_persona_id + if routed_persona_id is not None + else DEFAULT_SEARCH_PERSONA_ID + ) + + # Resolve persona with a trusted-side ACL re-check; on any issue fall back to + # the all-source default persona so the box never dead-ends. + try: + persona = get_persona_by_id( + target_persona_id, user=user, db_session=db_session, is_for_edit=False + ) + except Exception: + persona = get_persona_by_id( + DEFAULT_SEARCH_PERSONA_ID, + user=user, + db_session=db_session, + is_for_edit=False, + ) + + was_routed = routed_persona_id is not None and persona.id == routed_persona_id + prompt_id = persona.prompts[0].id if persona.prompts else 0 + + # Answer via the existing one-shot engine. Passing the authenticated `user` + # persists the Q + A under their id (one_shot=True) so we capture who-asked- + # what for quality analysis; the returned chat_message_id powers πŸ‘/πŸ‘Ž + text + # feedback via the existing /chat/create-chat-message-feedback endpoint. + qa_response = get_search_answer( + query_req=DirectQARequest( + messages=[ + ThreadMessage(message=question, sender=None, role=MessageType.USER) + ], + prompt_id=prompt_id, + persona_id=persona.id, + retrieval_options=RetrievalDetails( + run_search=OptionalSearchSetting.ALWAYS, real_time=False + ), + ), + user=user, + max_document_tokens=None, + max_history_tokens=0, + db_session=db_session, + use_citations=True, + ) + + return AutoSearchResponse( + answer=qa_response.answer, + citations=qa_response.citations, + docs=qa_response.docs, + chat_message_id=qa_response.chat_message_id, + error_msg=qa_response.error_msg, + answered_by=AnsweredByAssistant( + persona_id=persona.id, + name=persona.name, + display_name=persona.display_name, + routed=was_routed, + confidence=routed_confidence, + ), + ) diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py index 86a0a4607c1..69c4344fcd9 100644 --- a/backend/danswer/server/settings/models.py +++ b/backend/danswer/server/settings/models.py @@ -8,11 +8,34 @@ class PageType(str, Enum): SEARCH = "search" +class AutoSearchRollout(str, Enum): + """Staged rollout state for the auto-routed one-shot Search tab. + + - OFF: nobody (kill-switch). + - ADMIN_ONLY: admins only β€” lets admins clean assistant routing metadata and + test the routed answer before GA. This is the DEFAULT, so a fresh deploy + lands here automatically (a newly-added Settings field is absent from the + already-persisted settings dict, so it takes this default). + - EVERYONE: generally available. + + Flip in Admin -> Settings; no redeploy. Enforced on the trusted side in the + /search/auto endpoint, not just by hiding the tab. + """ + + OFF = "off" + ADMIN_ONLY = "admin_only" + EVERYONE = "everyone" + + class Settings(BaseModel): """General settings""" chat_page_enabled: bool = True search_page_enabled: bool = True + # Staged rollout of the auto-routed Search tab. Default ADMIN_ONLY so the + # feature ships dark-to-users on deploy; an admin flips it to EVERYONE in + # Admin -> Settings when ready. See AutoSearchRollout. + auto_search_rollout: AutoSearchRollout = AutoSearchRollout.ADMIN_ONLY # Fresh installs land on the chat page by default. NOTE: this only seeds # deployments with no stored settings yet β€” once settings are persisted, the # stored value wins, so flip it in Admin β†’ Settings on existing deployments. diff --git a/backend/tests/unit/danswer/secondary_llm_flows/test_assistant_router.py b/backend/tests/unit/danswer/secondary_llm_flows/test_assistant_router.py new file mode 100644 index 00000000000..c01b198efcc --- /dev/null +++ b/backend/tests/unit/danswer/secondary_llm_flows/test_assistant_router.py @@ -0,0 +1,140 @@ +"""Unit tests for the assistant router's pure logic. + +The router asks a fast LLM, in one call, to pick the best assistant for a +question and return JSON {"persona_id": N|null, "confidence": x}. The parsing +must: extract the object amid prose, validate the id is in the catalog, honor a +confidence threshold, and FAIL OPEN (persona_id=None -> caller uses the +all-source fallback) on anything unparseable. `build_router_catalog` must prefer +routing_instructions, fall back to description, and skip entries with neither. +""" +from types import SimpleNamespace + +from danswer.secondary_llm_flows.assistant_router import build_router_catalog +from danswer.secondary_llm_flows.assistant_router import parse_route_response + + +def _persona(persona_id: int, description: str = "", routing: str | None = None): + # build_router_catalog only reads id / name / description / routing_instructions. + return SimpleNamespace( + id=persona_id, + name=f"persona-{persona_id}", + description=description, + routing_instructions=routing, + ) + + +# --- build_router_catalog --------------------------------------------------- + + +def test_catalog_prefers_routing_instructions_over_description() -> None: + catalog = build_router_catalog( + [_persona(1, description="short desc", routing="exhaustive routing guide")] + ) + assert len(catalog) == 1 + assert catalog[0].persona_id == 1 + assert catalog[0].routing_text == "exhaustive routing guide" + + +def test_catalog_falls_back_to_description_when_routing_blank() -> None: + catalog = build_router_catalog( + [ + _persona(1, description="orchestrator help", routing=None), + _persona(2, description="apps help", routing=" "), # whitespace-only + ] + ) + assert {e.persona_id: e.routing_text for e in catalog} == { + 1: "orchestrator help", + 2: "apps help", + } + + +def test_catalog_skips_entries_with_no_routing_text() -> None: + catalog = build_router_catalog( + [_persona(1, description="", routing=None), _persona(2, description="real")] + ) + assert [e.persona_id for e in catalog] == [2] + + +# --- parse_route_response --------------------------------------------------- + +VALID = {1, 2, 3} + + +def test_parses_clean_object() -> None: + r = parse_route_response('{"persona_id": 2, "confidence": 0.9}', VALID) + assert r.persona_id == 2 + assert r.confidence == 0.9 + + +def test_parses_object_amid_prose() -> None: + r = parse_route_response( + 'Best match: {"persona_id": 3, "confidence": 0.8}. Hope that helps!', VALID + ) + assert r.persona_id == 3 + + +def test_null_persona_id_falls_back() -> None: + r = parse_route_response('{"persona_id": null, "confidence": 0.2}', VALID) + assert r.persona_id is None + assert r.confidence == 0.2 + + +def test_id_not_in_catalog_falls_back() -> None: + r = parse_route_response('{"persona_id": 99, "confidence": 0.95}', VALID) + assert r.persona_id is None + + +def test_low_confidence_falls_back_even_with_valid_id() -> None: + r = parse_route_response( + '{"persona_id": 1, "confidence": 0.3}', VALID, min_confidence=0.5 + ) + assert r.persona_id is None + assert r.confidence == 0.3 + + +def test_confidence_at_threshold_is_kept() -> None: + r = parse_route_response( + '{"persona_id": 1, "confidence": 0.5}', VALID, min_confidence=0.5 + ) + assert r.persona_id == 1 + + +def test_bool_persona_id_rejected() -> None: + # JSON `true` parses to Python True (an int subclass) β€” must not be treated as id 1. + r = parse_route_response('{"persona_id": true, "confidence": 0.9}', VALID) + assert r.persona_id is None + + +def test_confidence_clamped() -> None: + assert parse_route_response('{"persona_id": 1, "confidence": 5}', VALID).confidence == 1.0 + assert ( + parse_route_response('{"persona_id": 1, "confidence": -2}', VALID).confidence + == 0.0 + ) + + +def test_single_quoted_python_dict_parsed() -> None: + # The gateway model returns a Python dict literal (single quotes) in a + # ```json fence β€” json.loads rejects it; ast.literal_eval fallback handles it. + r = parse_route_response( + "```json\n{'persona_id': 2, 'confidence': 1.0}\n```", VALID + ) + assert r.persona_id == 2 + assert r.confidence == 1.0 + + +def test_single_quoted_null_persona() -> None: + r = parse_route_response("{'persona_id': None, 'confidence': 0.1}", VALID) + assert r.persona_id is None + + +def test_unparseable_fails_open() -> None: + assert parse_route_response("no json here", VALID).persona_id is None + assert parse_route_response("", VALID).persona_id is None + assert parse_route_response("{not valid json}", VALID).persona_id is None + + +def test_missing_confidence_treated_as_zero() -> None: + r = parse_route_response('{"persona_id": 1}', VALID, min_confidence=0.5) + assert r.persona_id is None + assert r.confidence == 0.0 diff --git a/web/src/app/admin/assistants/AssistantEditor.tsx b/web/src/app/admin/assistants/AssistantEditor.tsx index 3e1e2020336..f015b9c1598 100644 --- a/web/src/app/admin/assistants/AssistantEditor.tsx +++ b/web/src/app/admin/assistants/AssistantEditor.tsx @@ -173,6 +173,7 @@ export function AssistantEditor({ name: existingPersona?.name ?? "", display_name: existingPersona?.display_name ?? "", description: existingPersona?.description ?? "", + routing_instructions: existingPersona?.routing_instructions ?? "", system_prompt: existingPrompt?.system_prompt ?? "", task_prompt: existingPrompt?.task_prompt ?? "", is_public: existingPersona?.is_public ?? defaultPublic, @@ -419,6 +420,15 @@ export function AssistantEditor({ subtext="Provide a short descriptions which gives users a hint as to what they should use this Assistant for." /> + + + + { + value && + updateSettingField([ + { fieldName: "auto_search_rollout", newValue: value }, + ]); + }} + /> {isEnterpriseEnabled && ( <> Chat Settings diff --git a/web/src/app/admin/settings/interfaces.ts b/web/src/app/admin/settings/interfaces.ts index 17dd2da2a94..572bf8af0be 100644 --- a/web/src/app/admin/settings/interfaces.ts +++ b/web/src/app/admin/settings/interfaces.ts @@ -10,6 +10,10 @@ export interface Settings { // corresponding rerank / relevance toggles. rerank_enabled?: boolean; llm_relevance_filter_enabled?: boolean; + // Staged rollout of the auto-routed Search tab (mirrors backend + // AutoSearchRollout). The Search tab + endpoint are gated by this; the + // backend enforces it independently of the UI. + auto_search_rollout?: "off" | "admin_only" | "everyone"; } export interface EnterpriseSettings { diff --git a/web/src/app/auto-search/AutoSearch.tsx b/web/src/app/auto-search/AutoSearch.tsx new file mode 100644 index 00000000000..4d8a1f5cf74 --- /dev/null +++ b/web/src/app/auto-search/AutoSearch.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useContext, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { FiSearch, FiThumbsUp, FiThumbsDown } from "react-icons/fi"; +import { SettingsContext } from "@/components/settings/SettingsProvider"; + +// Mirrors the backend AutoSearchResponse shape. +interface AnsweredBy { + persona_id: number; + name: string; + display_name: string | null; + routed: boolean; + confidence: number; +} +interface AutoSearchDoc { + semantic_identifier: string; + link: string | null; +} +interface AutoSearchResponse { + answer: string | null; + docs: { top_documents: AutoSearchDoc[] } | null; + chat_message_id: number | null; + answered_by: AnsweredBy; + error_msg: string | null; +} + +function isVisible(rollout: string | undefined, userRole: string | null): boolean { + // Mirrors the backend gate; the endpoint enforces it for real. + if (rollout === "everyone") return true; + if (rollout === "admin_only") return userRole === "admin"; + return false; // "off" or unset-as-off +} + +export function AutoSearch({ userRole }: { userRole: string | null }) { + const settings = useContext(SettingsContext)?.settings; + const rollout = settings?.auto_search_rollout ?? "admin_only"; + + const [question, setQuestion] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + // chat_message_id -> the feedback already submitted for it (one per answer). + const [feedbackGiven, setFeedbackGiven] = useState<"like" | "dislike" | null>( + null + ); + const [feedbackText, setFeedbackText] = useState(""); + const [showFeedbackBox, setShowFeedbackBox] = useState(false); + + if (!isVisible(rollout, userRole)) { + return ( +
+ Search is not enabled for your account. +
+ ); + } + + async function runSearch() { + const trimmed = question.trim(); + if (!trimmed || isLoading) return; + setIsLoading(true); + setError(null); + setResult(null); + setFeedbackGiven(null); + setShowFeedbackBox(false); + setFeedbackText(""); + try { + const response = await fetch("/api/query/auto-search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: trimmed }), + }); + if (!response.ok) { + const detail = (await response.json().catch(() => null))?.detail; + setError(detail || `Search failed (${response.status}).`); + return; + } + setResult((await response.json()) as AutoSearchResponse); + } catch (e) { + setError("Something went wrong running the search."); + } finally { + setIsLoading(false); + } + } + + async function submitFeedback( + vote: "like" | "dislike", + text: string | null + ) { + if (result?.chat_message_id == null) return; + setFeedbackGiven(vote); + setShowFeedbackBox(false); + try { + await fetch("/api/chat/create-chat-message-feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_message_id: result.chat_message_id, + is_positive: vote === "like", + feedback_text: text || null, + }), + }); + } catch { + // Feedback is best-effort; don't disrupt the user if it fails. + } + } + + const answeredBy = result?.answered_by; + const answeredByLabel = + answeredBy && + (answeredBy.display_name?.trim() ? answeredBy.display_name : answeredBy.name); + const topDocs = result?.docs?.top_documents ?? []; + + return ( +
+

Search

+

+ Ask a question β€” the right assistant is chosen for you automatically. +

+ +
+ + setQuestion(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + runSearch(); + } + }} + /> + +
+ + {error && ( +
+ {error} +
+ )} + + {result && !error && ( +
+ {answeredByLabel && ( +
+ + Answered by {answeredByLabel} + {!answeredBy?.routed && " (searched all sources)"} + +
+ )} + +
+ + {result.answer || result.error_msg || "No answer was generated."} + +
+ + {topDocs.length > 0 && ( +
+ )} + + {result.chat_message_id != null && ( +
+ + + {feedbackGiven && !showFeedbackBox && ( + Thanks for the feedback! + )} +
+ )} + + {showFeedbackBox && ( +
+