diff --git a/AGENTS.md b/AGENTS.md index fe3c069bd08..fe960e6061e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -669,6 +669,18 @@ any connector type by introspecting `credential_json` keys. Backend just uses the existing `PATCH /api/manage/admin/connector/{id}` with `disabled: bool` flipped — no special bulk endpoint needed. +### Populate a local env from prod (Vespa + assistants/doc-sets) + +To test search / assistants / the auto-router locally against realistic +data, `backend/scripts/clone_prod_to_local.py` copies the **latest N +docs per source** from prod Vespa (with embeddings / ACLs / doc-set +membership) plus the personas, prompts, and document sets from the prod +DB into your local Vespa + Postgres. Two phases — `export` runs inside a +prod pod, `import` runs locally — connected by a `kubectl cp`'d bundle. +Requires the **same embedding model** locally (the script enforces it and +aborts on mismatch). Full steps + caveats: +[`docs/clone-prod-to-local.md`](./docs/clone-prod-to-local.md). + --- ## Conventions diff --git a/backend/alembic/versions/31f30b318163_persona_is_router_candidate.py b/backend/alembic/versions/31f30b318163_persona_is_router_candidate.py new file mode 100644 index 00000000000..be7653cbc0e --- /dev/null +++ b/backend/alembic/versions/31f30b318163_persona_is_router_candidate.py @@ -0,0 +1,35 @@ +"""persona is_router_candidate + +Revision ID: 31f30b318163 +Revises: a7b8c9d0e1f2 +Create Date: 2026-07-16 00:00:00.000000 + +Adds `is_router_candidate` to persona so an admin can exclude an assistant from +the auto-routed Search tab (both the keyword route and the kNN fallback) while +keeping it manually selectable. NOT NULL with server_default true, so existing +rows keep participating in routing (no behavior change on upgrade). +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "31f30b318163" +down_revision = "a7b8c9d0e1f2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "persona", + sa.Column( + "is_router_candidate", + sa.Boolean(), + nullable=False, + server_default="true", + ), + ) + + +def downgrade() -> None: + op.drop_column("persona", "is_router_candidate") diff --git a/backend/alembic/versions/a7b8c9d0e1f2_chat_feedback_sme_verification.py b/backend/alembic/versions/a7b8c9d0e1f2_chat_feedback_sme_verification.py new file mode 100644 index 00000000000..3c68abc55e7 --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_chat_feedback_sme_verification.py @@ -0,0 +1,34 @@ +"""chat_feedback SME verification + +Revision ID: a7b8c9d0e1f2 +Revises: f3a4b5c6d7e8 +Create Date: 2026-07-07 00:00:00.000000 + +Adds sme_verified_by / sme_verified_at to chat_feedback so the Slack "Verified by +an SME" flow can record who verified an answer and when (extends the existing +feedback table rather than adding a new one). +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "a7b8c9d0e1f2" +down_revision = "f3a4b5c6d7e8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "chat_feedback", + sa.Column("sme_verified_by", sa.String(), nullable=True), + ) + op.add_column( + "chat_feedback", + sa.Column("sme_verified_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("chat_feedback", "sme_verified_at") + op.drop_column("chat_feedback", "sme_verified_by") 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/alembic/versions/d1e2f3a4b5c6_persona_routing_keywords.py b/backend/alembic/versions/d1e2f3a4b5c6_persona_routing_keywords.py new file mode 100644 index 00000000000..495dc009937 --- /dev/null +++ b/backend/alembic/versions/d1e2f3a4b5c6_persona_routing_keywords.py @@ -0,0 +1,34 @@ +"""persona: add routing_keywords (deterministic keyword pre-route) + +Adds persona.routing_keywords — admin-editable, comma-separated phrases that +deterministically route a question to this assistant (case-insensitive substring +match) BEFORE the LLM router runs. Nullable, no backfill; blank means no keyword +override and the LLM router decides as before. Additive — existing routing is +unchanged when no keyword matches. See db/models.py::Persona.routing_keywords and +secondary_llm_flows/assistant_router.keyword_route. + +Revision ID: d1e2f3a4b5c6 +Revises: c0d1e2f3a4b5 +Create Date: 2026-06-30 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d1e2f3a4b5c6" +down_revision = "c0d1e2f3a4b5" +branch_labels: None = None +depends_on: None = None + + +def upgrade() -> None: + op.add_column( + "persona", + sa.Column("routing_keywords", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("persona", "routing_keywords") diff --git a/backend/alembic/versions/e2f3a4b5c6d7_persona_routing_intents.py b/backend/alembic/versions/e2f3a4b5c6d7_persona_routing_intents.py new file mode 100644 index 00000000000..7bc86952e51 --- /dev/null +++ b/backend/alembic/versions/e2f3a4b5c6d7_persona_routing_intents.py @@ -0,0 +1,35 @@ +"""persona: add routing_intents (semantic intent pre-route) + +Adds persona.routing_intents — admin-editable, NEWLINE-separated natural-language +intent phrases (one per line). The auto-router embeds them and routes a question +to this assistant when its nearest exemplar clears a similarity gate, BETWEEN the +keyword pre-route and the LLM router. Nullable, no backfill; blank means no +semantic override and routing is unchanged. Stored in Postgres only (never in +Vespa); embeddings are computed on demand and cached per-assistant. See +db/models.py::Persona.routing_intents and assistant_router.intent_route. + +Revision ID: e2f3a4b5c6d7 +Revises: d1e2f3a4b5c6 +Create Date: 2026-07-01 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e2f3a4b5c6d7" +down_revision = "d1e2f3a4b5c6" +branch_labels: None = None +depends_on: None = None + + +def upgrade() -> None: + op.add_column( + "persona", + sa.Column("routing_intents", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("persona", "routing_intents") diff --git a/backend/alembic/versions/f3a4b5c6d7e8_chat_referral.py b/backend/alembic/versions/f3a4b5c6d7e8_chat_referral.py new file mode 100644 index 00000000000..c1b7a4a3227 --- /dev/null +++ b/backend/alembic/versions/f3a4b5c6d7e8_chat_referral.py @@ -0,0 +1,55 @@ +"""chat_referral + +Revision ID: f3a4b5c6d7e8 +Revises: e2f3a4b5c6d7 +Create Date: 2026-07-05 00:00:00.000000 + +Adds the chat_referral table: one row per landing on the chat UI from an external +referral (e.g. a per-channel Slack "Ask Darwin" workflow), so inbound traffic can +be measured by source / channel / assistant with plain SQL. +""" +from alembic import op +import sqlalchemy as sa +import fastapi_users_db_sqlalchemy + +# revision identifiers, used by Alembic. +revision = "f3a4b5c6d7e8" +down_revision = "e2f3a4b5c6d7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "chat_referral", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("utm_source", sa.String(), nullable=False), + sa.Column("utm_medium", sa.String(), nullable=True), + sa.Column("utm_campaign", sa.String(), nullable=True), + sa.Column("utm_channel", sa.String(), nullable=True), + sa.Column("assistant_name", sa.String(), nullable=True), + sa.Column( + "user_id", + fastapi_users_db_sqlalchemy.generics.GUID(), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + # Traffic queries filter/group by source and time. + op.create_index( + "ix_chat_referral_source_created", + "chat_referral", + ["utm_source", "created_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_chat_referral_source_created", table_name="chat_referral") + op.drop_table("chat_referral") diff --git a/backend/danswer/chat/process_message.py b/backend/danswer/chat/process_message.py index 876636411e9..134e65bda4d 100644 --- a/backend/danswer/chat/process_message.py +++ b/backend/danswer/chat/process_message.py @@ -433,85 +433,111 @@ def stream_chat_message_objects( search_tool: SearchTool | None = None tool_dict: dict[int, list[Tool]] = {} # tool_id to tool for db_tool_model in persona.tools: - # handle in-code tools specially - if db_tool_model.in_code_tool_id: - tool_cls = get_built_in_tool_by_id(db_tool_model.id, db_session) - if tool_cls.__name__ == SearchTool.__name__ and not latest_query_files: - # Chat-page per-conversation toggles (default off, assistant - # settings intentionally ignored in chat). Each is gated by - # its global master switch; we pass explicit skip_* so - # retrieval_preprocessing uses these instead of the persona. - search_tool = SearchTool( - db_session=db_session, - user=user, - persona=persona, - retrieval_options=retrieval_options, - prompt_config=prompt_config, - llm=llm, - fast_llm=fast_llm, - pruning_config=document_pruning_config, - selected_docs=selected_llm_docs, - chunks_above=new_msg_req.chunks_above, - chunks_below=new_msg_req.chunks_below, - full_doc=new_msg_req.full_doc, - skip_rerank=not (RERANK_ENABLED and new_msg_req.use_reranking), - skip_llm_chunk_filter=not ( - LLM_RELEVANCE_FILTER_ENABLED - and new_msg_req.use_relevance_filter - ), - ) - tool_dict[db_tool_model.id] = [search_tool] - elif tool_cls.__name__ == ImageGenerationTool.__name__: - img_generation_llm_config: LLMConfig | None = None + # Guardrail: build each configured tool defensively. If a tool's + # prerequisites aren't met (e.g. Image Generation without an OpenAI + # key) or its setup otherwise fails, SKIP just that tool and log WHY + # at WARNING with the tool + persona identity — one misconfigured tool + # must never 500 the whole chat message, and the log keeps it debuggable. + try: + # handle in-code tools specially + if db_tool_model.in_code_tool_id: + tool_cls = get_built_in_tool_by_id(db_tool_model.id, db_session) if ( - llm - and llm.config.api_key - and llm.config.model_provider == "openai" + tool_cls.__name__ == SearchTool.__name__ + and not latest_query_files ): - img_generation_llm_config = llm.config - else: - llm_providers = fetch_existing_llm_providers(db_session) - openai_provider = next( - iter( - [ - llm_provider - for llm_provider in llm_providers - if llm_provider.provider == "openai" - ] + # Chat-page per-conversation toggles (default off, assistant + # settings intentionally ignored in chat). Each is gated by + # its global master switch; we pass explicit skip_* so + # retrieval_preprocessing uses these instead of the persona. + search_tool = SearchTool( + db_session=db_session, + user=user, + persona=persona, + retrieval_options=retrieval_options, + prompt_config=prompt_config, + llm=llm, + fast_llm=fast_llm, + pruning_config=document_pruning_config, + selected_docs=selected_llm_docs, + chunks_above=new_msg_req.chunks_above, + chunks_below=new_msg_req.chunks_below, + full_doc=new_msg_req.full_doc, + skip_rerank=not ( + RERANK_ENABLED and new_msg_req.use_reranking + ), + skip_llm_chunk_filter=not ( + LLM_RELEVANCE_FILTER_ENABLED + and new_msg_req.use_relevance_filter ), - None, ) - if not openai_provider or not openai_provider.api_key: - raise ValueError( - "Image generation tool requires an OpenAI API key" + tool_dict[db_tool_model.id] = [search_tool] + elif tool_cls.__name__ == ImageGenerationTool.__name__: + img_generation_llm_config: LLMConfig | None = None + if ( + llm + and llm.config.api_key + and llm.config.model_provider == "openai" + ): + img_generation_llm_config = llm.config + else: + llm_providers = fetch_existing_llm_providers(db_session) + openai_provider = next( + iter( + [ + llm_provider + for llm_provider in llm_providers + if llm_provider.provider == "openai" + ] + ), + None, ) - img_generation_llm_config = LLMConfig( - model_provider=openai_provider.provider, - model_name=openai_provider.default_model_name, - temperature=GEN_AI_TEMPERATURE, - api_key=openai_provider.api_key, - api_base=openai_provider.api_base, - api_version=openai_provider.api_version, - ) - tool_dict[db_tool_model.id] = [ - ImageGenerationTool( - api_key=cast(str, img_generation_llm_config.api_key), - api_base=img_generation_llm_config.api_base, - api_version=img_generation_llm_config.api_version, - additional_headers=litellm_additional_headers, - ) - ] + if not openai_provider or not openai_provider.api_key: + raise ValueError( + "Image generation tool requires an OpenAI " + "API key" + ) + img_generation_llm_config = LLMConfig( + model_provider=openai_provider.provider, + model_name=openai_provider.default_model_name, + temperature=GEN_AI_TEMPERATURE, + api_key=openai_provider.api_key, + api_base=openai_provider.api_base, + api_version=openai_provider.api_version, + ) + tool_dict[db_tool_model.id] = [ + ImageGenerationTool( + api_key=cast(str, img_generation_llm_config.api_key), + api_base=img_generation_llm_config.api_base, + api_version=img_generation_llm_config.api_version, + additional_headers=litellm_additional_headers, + ) + ] - continue + continue - # handle all custom tools - if db_tool_model.openapi_schema: - tool_dict[db_tool_model.id] = cast( - list[Tool], - build_custom_tools_from_openapi_schema( - db_tool_model.openapi_schema - ), + # handle all custom tools + if db_tool_model.openapi_schema: + tool_dict[db_tool_model.id] = cast( + list[Tool], + build_custom_tools_from_openapi_schema( + db_tool_model.openapi_schema + ), + ) + except Exception as tool_setup_error: + tool_label = ( + db_tool_model.in_code_tool_id + or db_tool_model.name + or f"tool-id={db_tool_model.id}" + ) + logger.warning( + "Skipping tool '%s' for persona '%s': prerequisite/setup " + "check failed: %s", + tool_label, + persona.name if persona else "(default)", + tool_setup_error, ) + continue tools: list[Tool] = [] for tool_list in tool_dict.values(): diff --git a/backend/danswer/configs/chat_configs.py b/backend/danswer/configs/chat_configs.py index 6c8d3f59c41..37cd5e8f780 100644 --- a/backend/danswer/configs/chat_configs.py +++ b/backend/danswer/configs/chat_configs.py @@ -87,6 +87,74 @@ AUTHORITATIVE_CITATION_RETENTION_ENABLED = ( os.environ.get("AUTHORITATIVE_CITATION_RETENTION_ENABLED", "").lower() == "true" ) +# Optional model override for the assistant ROUTER (the one-shot Search tab's +# automatic assistant picker). When BOTH are set, routing uses this gateway +# vendor/model instead of the default fast model — e.g. point it at Claude +# (ASSISTANT_ROUTER_LLM_VENDOR=awsbedrock + the gateway's Claude model id) for +# sharper assistant selection. Empty => use the default fast LLM. Note: a heavier +# model improves selection precision but adds latency/cost to the (already extra) +# router call; routing is a classification task that the fast model handles well, +# so treat this as an A/B lever rather than a default. +ASSISTANT_ROUTER_LLM_VENDOR = os.environ.get("ASSISTANT_ROUTER_LLM_VENDOR") or "" +ASSISTANT_ROUTER_LLM_MODEL = os.environ.get("ASSISTANT_ROUTER_LLM_MODEL") or "" +# How many assistants the auto-routed Search tab's router ranks per question. The +# #1 answers the question (single scope = its own document sets); ranks 2..N are +# surfaced as "recommended assistants" the user can chat with next if #1 wasn't +# right. Default 3 => 1 answerer + up to 2 recommendations. +AUTO_SEARCH_TOP_N = int(os.environ.get("AUTO_SEARCH_TOP_N") or 3) +# kNN-over-Slack router (the fallback after keyword_route; replaces the LLM +# routing-instructions logic). Number of slack thread-start neighbors to vote over. +SLACK_KNN_ROUTER_TOP_K = int(os.environ.get("SLACK_KNN_ROUTER_TOP_K") or 15) +# Below this vote confidence (winner_weight / total_weight) the router asks the fast +# LLM to break the tie among the retrieved neighbors' assistants. Tuned from the +# prototype where correct routes averaged ~0.70 and wrong ones ~0.43. +SLACK_KNN_ROUTER_LLM_CONF_THRESHOLD = float( + os.environ.get("SLACK_KNN_ROUTER_LLM_CONF_THRESHOLD") or 0.6 +) +# Minimum number of neighbor matches an assistant needs to appear as a "recommended +# assistant" (and in the compare union scope). Filters out single, incidental +# matches — the noise seen when a strong-but-unmapped channel is discarded and +# 1-vote channels fill the slots. Set via configmap (env) to tune without a deploy. +SLACK_KNN_ROUTER_MIN_RECOMMENDATION_VOTES = int( + os.environ.get("SLACK_KNN_ROUTER_MIN_RECOMMENDATION_VOTES") or 2 +) +# Side-by-side "compare" answer for the auto-routed Search tab: alongside the +# single top-1 answer, also answer over the UNION of the router's top-N assistants' +# document sets, so the user can compare a narrow (single-assistant) answer with a +# broader (multi-assistant) one. Only runs on LLM-router picks (keyword routes and +# @mentions stay single-scope). Gated by Settings.auto_search_compare_enabled. +# +# Which persona answers the union scope. MUST be fence-less (no document_sets of its +# own) so the union document_set filter applies as-is — a persona with its own fence +# would INTERSECT and shrink the scope. The all-source default persona (0) is the +# natural choice. +AUTO_SEARCH_UNION_PERSONA_ID = int(os.environ.get("AUTO_SEARCH_UNION_PERSONA_ID") or 0) +# Model for the union answer. Default: the router LLM (Sonnet) — a stronger +# synthesizer for the wider, multi-source context. Empty => union persona default. +AUTO_SEARCH_UNION_LLM_VENDOR = ( + os.environ.get("AUTO_SEARCH_UNION_LLM_VENDOR") or ASSISTANT_ROUTER_LLM_VENDOR +) +AUTO_SEARCH_UNION_LLM_MODEL = ( + os.environ.get("AUTO_SEARCH_UNION_LLM_MODEL") or ASSISTANT_ROUTER_LLM_MODEL +) +# Optional override for the default (top-1) answer model. Empty => the routed +# persona's own default model (prod: gpt-4o). Set to force a specific model. +AUTO_SEARCH_DEFAULT_LLM_VENDOR = os.environ.get("AUTO_SEARCH_DEFAULT_LLM_VENDOR") or "" +AUTO_SEARCH_DEFAULT_LLM_MODEL = os.environ.get("AUTO_SEARCH_DEFAULT_LLM_MODEL") or "" +# Third compare tab: answer scoped to a fixed, small set of SOURCE TYPES (not +# document sets) — by default HighSpot (sales enablement) + the docs.uipath.com web +# crawls ("all the docs sites"). Rides along with the compare view (only shown when +# compare_enabled). Comma-separated DocumentSource values. +AUTO_SEARCH_SOURCE_TAB_ENABLED = ( + os.environ.get("AUTO_SEARCH_SOURCE_TAB_ENABLED") or "true" +).lower() == "true" +AUTO_SEARCH_SOURCE_TAB_SOURCES = [ + s.strip().lower() + for s in (os.environ.get("AUTO_SEARCH_SOURCE_TAB_SOURCES") or "highspot,web").split( + "," + ) + if s.strip() +] # Versioned-docs dedup at final doc selection. Documentation sites publish the # SAME page under one URL per product version (e.g. docs.uipath.com/.../2024.10/… # and /.../2023.10/… and /.../2.2510/…). Retrieval then floods the LLM context diff --git a/backend/danswer/danswerbot/slack/blocks.py b/backend/danswer/danswerbot/slack/blocks.py index 54828969579..9e4f4719a86 100644 --- a/backend/danswer/danswerbot/slack/blocks.py +++ b/backend/danswer/danswerbot/slack/blocks.py @@ -30,6 +30,7 @@ from danswer.danswerbot.slack.constants import FOLLOWUP_BUTTON_RESOLVED_ACTION_ID from danswer.danswerbot.slack.constants import IMMEDIATE_RESOLVED_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import LIKE_BLOCK_ACTION_ID +from danswer.danswerbot.slack.constants import SME_VALIDATE_BUTTON_ACTION_ID from danswer.danswerbot.slack.icons import source_to_github_img_link from danswer.danswerbot.slack.utils import build_feedback_id from danswer.danswerbot.slack.utils import remove_slack_text_interactions @@ -467,6 +468,46 @@ def build_follow_up_block(message_id: int | None) -> ActionsBlock: ) +def build_sme_validation_block(message_id: int | None) -> ActionsBlock: + """Unverified state: a red button prompting an SME to verify the answer. Only + members of the channel's configured Slack user group can actually verify (the + handler enforces it); everyone sees the button. `message_id` rides in the + block_id so the handler can attribute the verification.""" + return ActionsBlock( + block_id=build_feedback_id(message_id) if message_id is not None else None, + elements=[ + ButtonElement( + action_id=SME_VALIDATE_BUTTON_ACTION_ID, + style="danger", + text="Awaiting SME Review", + ) + ], + ) + + +def build_sme_verified_blocks( + validator_name: str, + when: str, + message_id: int | None, +) -> list[Block]: + """Verified state: a green button + a line naming the SME and when. The button + keeps the same action_id so a re-click is handled idempotently (no-op).""" + button_block = ActionsBlock( + block_id=build_feedback_id(message_id) if message_id is not None else None, + elements=[ + ButtonElement( + action_id=SME_VALIDATE_BUTTON_ACTION_ID, + style="primary", + text=":white_check_mark: Verified by an SME", + ) + ], + ) + context_block = ContextBlock( + elements=[MarkdownTextObject(text=f"Verified by {validator_name} · {when}")] + ) + return [button_block, context_block] + + def build_follow_up_resolved_blocks( tag_ids: list[str], group_ids: list[str], diff --git a/backend/danswer/danswerbot/slack/constants.py b/backend/danswer/danswerbot/slack/constants.py index f98cf9ddcbf..3d98ea79d7d 100644 --- a/backend/danswer/danswerbot/slack/constants.py +++ b/backend/danswer/danswerbot/slack/constants.py @@ -8,6 +8,10 @@ FOLLOWUP_BUTTON_RESOLVED_ACTION_ID = "followup-resolved-button" SLACK_CHANNEL_ID = "channel_id" VIEW_DOC_FEEDBACK_ID = "view-doc-feedback" +# "Verify this answer (SMEs)" button — opt-in per channel. Only members of the +# channel's configured Slack user group may verify; on success the answer gets a +# green "Verified by an SME" badge so readers can trust it. +SME_VALIDATE_BUTTON_ACTION_ID = "sme-validate-answer" class FeedbackVisibility(str, Enum): diff --git a/backend/danswer/danswerbot/slack/handlers/handle_buttons.py b/backend/danswer/danswerbot/slack/handlers/handle_buttons.py index b1ff4ca61c8..f2569395759 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_buttons.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_buttons.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any from typing import cast @@ -12,6 +13,7 @@ from danswer.configs.danswerbot_configs import DANSWER_FOLLOWUP_EMOJI from danswer.connectors.slack.utils import make_slack_api_rate_limited from danswer.danswerbot.slack.blocks import build_follow_up_resolved_blocks +from danswer.danswerbot.slack.blocks import build_sme_verified_blocks from danswer.danswerbot.slack.blocks import get_document_feedback_blocks from danswer.danswerbot.slack.config import get_slack_bot_config_for_channel from danswer.danswerbot.slack.constants import CURATED_RESPONSE_CONFIG_KEY @@ -20,6 +22,7 @@ from danswer.danswerbot.slack.constants import FeedbackVisibility from danswer.danswerbot.slack.constants import LIKE_BLOCK_ACTION_ID from danswer.danswerbot.slack.constants import RESPONSE_MESSAGE_KEY +from danswer.danswerbot.slack.constants import SME_VALIDATE_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import USER_ID_KEY from danswer.danswerbot.slack.constants import USER_KEY from danswer.danswerbot.slack.constants import USER_PROFILE_KEY @@ -41,6 +44,7 @@ from danswer.db.engine import get_sqlalchemy_engine from danswer.db.feedback import create_chat_message_feedback from danswer.db.feedback import create_doc_retrieval_feedback +from danswer.db.feedback import mark_message_sme_verified from danswer.document_index.document_index_utils import get_both_index_names from danswer.document_index.factory import get_default_document_index from danswer.utils.logger import setup_logger @@ -227,6 +231,197 @@ def handle_slack_feedback( ) +def _sme_ephemeral(web_client: WebClient, channel: str, user: str, text: str) -> None: + try: + make_slack_api_rate_limited(web_client.chat_postEphemeral)( + channel=channel, user=user, text=text + ) + except Exception: + logger_base.exception("Failed to post SME ephemeral message") + + +def _is_sme_action_block(block: dict[str, Any]) -> bool: + if block.get("type") != "actions": + return False + return any( + el.get("action_id") == SME_VALIDATE_BUTTON_ACTION_ID + for el in block.get("elements", []) + ) + + +def handle_sme_validate_button( + req: SocketModeRequest, + client: SocketModeClient, +) -> None: + """'Awaiting SME Review' button. Only members of the channel's + configured Slack user group may verify (checked live, so leavers are handled). + On success the red button is swapped for a green 'Verified by an SME' badge + naming the verifier. Non-members get a private rejection; re-clicks are no-ops.""" + payload = req.payload + user_id = payload["user"]["id"] + container = payload.get("container", {}) + channel_id = container.get("channel_id") or payload.get("channel", {}).get("id") + message_ts = container.get("message_ts") + blocks: list[dict[str, Any]] = payload.get("message", {}).get("blocks", []) + web = client.web_client + + if not channel_id or not message_ts: + return + + # Idempotent: if already verified (the green/primary SME button is present), stop. + already_verified = any( + el.get("action_id") == SME_VALIDATE_BUTTON_ACTION_ID + and el.get("style") == "primary" + for b in blocks + if b.get("type") == "actions" + for el in b.get("elements", []) + ) + if already_verified: + _sme_ephemeral(web, channel_id, user_id, "This answer is already verified.") + return + + # Resolve the channel's SME user group from its config. + with Session(get_sqlalchemy_engine()) as db_session: + channel_name, _ = get_channel_name_from_id(client=web, channel_id=channel_id) + cfg = get_slack_bot_config_for_channel( + channel_name=channel_name, db_session=db_session + ) + channel_conf = cfg.channel_config if cfg else None + sme_group_name = channel_conf.get("sme_group_name") if channel_conf else None + if ( + not channel_conf + or not channel_conf.get("enable_sme_validation") + or not sme_group_name + ): + _sme_ephemeral( + web, + channel_id, + user_id, + "SME verification isn't configured for this channel.", + ) + return + + # `sme_group_name` is a comma-separated list of group names/@handles; a clicker + # who belongs to ANY of them may verify. + sme_group_names = [n.strip() for n in sme_group_name.split(",") if n.strip()] + if not sme_group_names: + _sme_ephemeral( + web, + channel_id, + user_id, + "SME verification isn't configured for this channel.", + ) + return + + # Resolve the configured group names/@handles -> ids (live), so config stays + # human-friendly and renames of members are irrelevant. + group_ids, failed_names = fetch_groupids_from_names(sme_group_names, web) + if failed_names: + logger_base.error("SME group(s) not found in workspace: %r", failed_names) + if not group_ids: + _sme_ephemeral( + web, + channel_id, + user_id, + "The configured SME group couldn't be found — please check the channel setup.", + ) + return + + # AUTHORIZE (trusted side): clicker must be a LIVE member of at least one SME + # user group. Fetch members per group and union them; a per-group fetch error + # is skipped (other groups can still authorize). + members: set[str] = set() + fetch_errors = 0 + for gid in group_ids: + try: + members.update( + make_slack_api_rate_limited(web.usergroups_users_list)(usergroup=gid)[ + "users" + ] + ) + except Exception: + fetch_errors += 1 + logger_base.exception("Failed to fetch SME user group %s", gid) + if not members and fetch_errors: + _sme_ephemeral( + web, + channel_id, + user_id, + "Couldn't check your SME membership just now — please try again.", + ) + return + if user_id not in members: + _sme_ephemeral( + web, + channel_id, + user_id, + "Only members of the SME group can verify answers.", + ) + return + + # Recover the message_id encoded in the SME block's block_id (best effort). + message_id: int | None = None + for b in blocks: + if _is_sme_action_block(b) and b.get("block_id"): + try: + message_id, _, _ = decompose_action_id(b["block_id"]) + except ValueError: + message_id = None + break + + # Swap the red button for the green verified state; keep everything else. + when = datetime.now().strftime("%b %d, %Y") + verified_blocks = [ + blk.to_dict() + for blk in build_sme_verified_blocks( + validator_name=f"<@{user_id}>", when=when, message_id=message_id + ) + ] + new_blocks = [b for b in blocks if not _is_sme_action_block(b)] + verified_blocks + + try: + make_slack_api_rate_limited(web.chat_update)( + channel=channel_id, + ts=message_ts, + blocks=new_blocks, + text="This answer has been verified by an SME.", + ) + except Exception: + logger_base.exception("Failed to update message with SME verification") + _sme_ephemeral( + web, channel_id, user_id, "Couldn't mark this verified — please retry." + ) + return + + # Persist the verification (extends chat_feedback) for reporting. Best-effort: + # a storage hiccup must not undo the visible badge. Record the verifier's email + # when resolvable (more portable than a Slack id), else the Slack id. + if message_id is not None: + verifier = user_id + try: + info = web.users_info(user=user_id) + verifier = info.get("user", {}).get("profile", {}).get("email") or user_id + except Exception: + pass + try: + with Session(get_sqlalchemy_engine()) as db_session: + mark_message_sme_verified( + chat_message_id=message_id, + verified_by=verifier, + db_session=db_session, + ) + except Exception: + logger_base.exception("Failed to persist SME verification") + + logger_base.info( + "SME verification: channel=%s ts=%s message_id=%s verified_by=%s", + channel_id, + message_ts, + message_id, + user_id, + ) + + def handle_followup_button( req: SocketModeRequest, client: SocketModeClient, diff --git a/backend/danswer/danswerbot/slack/handlers/handle_message.py b/backend/danswer/danswerbot/slack/handlers/handle_message.py index d7e263a94c7..9bceabb0589 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_message.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_message.py @@ -30,6 +30,7 @@ from danswer.danswerbot.slack.blocks import build_documents_blocks from danswer.danswerbot.slack.blocks import build_follow_up_block from danswer.danswerbot.slack.blocks import build_qa_response_blocks +from danswer.danswerbot.slack.blocks import build_sme_validation_block from danswer.danswerbot.slack.blocks import build_sources_blocks from danswer.danswerbot.slack.blocks import get_feedback_reminder_blocks from danswer.danswerbot.slack.blocks import get_restate_blocks @@ -50,14 +51,15 @@ from danswer.db.models import SlackBotConfig from danswer.db.models import SlackBotResponseType from danswer.db.persona import fetch_persona_by_id +from danswer.db.persona import get_persona_with_docset_and_prompts +from danswer.db.persona import get_personas from danswer.db.slack_response_blocklist import ( get_slack_response_blocklisted_emails, ) -from danswer.db.persona import get_persona_with_docset_and_prompts -from danswer.db.persona import get_personas from danswer.db.users import add_slack_persona_for_user from danswer.db.users import add_user_slack_persona from danswer.db.users import fetch_user_slack_persona +from danswer.db.users import get_user_by_email from danswer.llm.answering.prompts.citations_prompt import ( compute_max_document_tokens_for_persona, ) @@ -239,9 +241,7 @@ def handle_message( if sender_id: try: with Session(get_sqlalchemy_engine()) as db_session: - blocklisted_emails = get_slack_response_blocklisted_emails( - db_session - ) + blocklisted_emails = get_slack_response_blocklisted_emails(db_session) except Exception: # Fail open: if the blocklist can't be read (e.g. the table doesn't # exist yet mid-migration, or a transient DB error), respond as @@ -327,7 +327,7 @@ def handle_message( persona = get_persona_with_docset_and_prompts( persona_id=slack_persona_id, db_session=db_session ) - persona_name = persona.name + persona_name = persona.display_name or persona.name else: persona = None else: @@ -337,9 +337,36 @@ def handle_message( command = message_info.command if command == "/personas": with Session(get_sqlalchemy_engine()) as db_session: + # ACL scope: only offer personas this Slack user can access. + # Resolve the sender to a Danswer user via their Slack email; + # known users get public + shared personas, unknown/external + # senders get public personas only. Mirrors the web app — no + # assistant a user couldn't otherwise see is selectable here. + # Fail-open: any hiccup resolving the user falls back to the + # public-persona list, so this never breaks the /personas command. + acl_user = None + try: + slack_user_email = ( + client.users_info(user=sender_id) + .data["user"]["profile"] # type: ignore + .get("email") + ) + if slack_user_email: + acl_user = get_user_by_email( + email=slack_user_email, db_session=db_session + ) + except Exception: + logger.warning( + "Unable to resolve Slack sender for persona ACL; " + "falling back to public personas" + ) personas = get_personas( - user_id=None, db_session=db_session, include_default=True + user_id=acl_user.id if acl_user else None, + db_session=db_session, + include_default=True, ) + if acl_user is None: + personas = [p for p in personas if p.is_public] if not personas: respond_in_thread( @@ -356,8 +383,15 @@ def handle_message( and message_info.thread_messages[0].message.strip() ): persona_name = message_info.thread_messages[0].message.strip() + # Match the friendly display name OR the internal name, both + # case-insensitive — so "/personas Automation Suite" and + # "/personas AutomationSuite" both resolve. + query_name = persona_name.lower() matching_personas = [ - p for p in personas if p.name.lower() == persona_name.lower() + p + for p in personas + if (p.display_name or p.name).lower() == query_name + or p.name.lower() == query_name ] if matching_personas: @@ -382,7 +416,7 @@ def handle_message( respond_in_thread( client=client, channel=channel, - text=f"Persona '{persona.name}' has been set!", + text=f"Persona '{persona.display_name or persona.name}' has been set!", thread_ts=message_ts_to_respond_to, ) return @@ -396,7 +430,9 @@ def handle_message( ) return - sorted_personas = sorted(personas, key=lambda x: x.name.lower()) + sorted_personas = sorted( + personas, key=lambda x: (x.display_name or x.name).lower() + ) # Create select menu options for all personas select_options = [] @@ -405,7 +441,9 @@ def handle_message( { "text": { "type": "plain_text", - "text": f"{persona.name} • {persona.description}"[:75], + "text": f"{persona.display_name or persona.name} • {persona.description}"[ + :75 + ], "emoji": True, }, "value": str(persona.id), @@ -910,6 +948,11 @@ def _get_answer(new_message_request: DirectQARequest) -> OneShotQAResponse | Non if channel_conf and channel_conf.get("follow_up_tags") is not None: all_blocks.append(build_follow_up_block(message_id=answer.chat_message_id)) + # Opt-in per channel: prompt SMEs to verify the answer (red button → green once + # a member of the channel's Slack user group verifies it). + if channel_conf and channel_conf.get("enable_sme_validation"): + all_blocks.append(build_sme_validation_block(message_id=answer.chat_message_id)) + try: respond_in_thread( client=client, diff --git a/backend/danswer/danswerbot/slack/handlers/handle_modal.py b/backend/danswer/danswerbot/slack/handlers/handle_modal.py index 523ebd355a1..9a610ba1966 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_modal.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_modal.py @@ -9,9 +9,11 @@ ) from danswer.db.engine import get_sqlalchemy_engine from danswer.db.persona import fetch_persona_by_id +from danswer.db.persona import get_personas from danswer.db.users import add_slack_persona_for_user from danswer.db.users import add_user_slack_persona from danswer.db.users import fetch_user_slack_persona +from danswer.db.users import get_user_by_email from danswer.utils.logger import setup_logger logger = setup_logger() @@ -41,6 +43,47 @@ def handle_modal_submission(client: WebClient, body: dict[str, Any]) -> None: "errors": {"persona_selection": "Persona not found."}, } + # ACL: the modal payload is user-controlled, so re-check that the + # selected persona is one this Slack user can access before saving. + # Resolve the Slack user -> Danswer user; known users get public + + # shared personas, unknown senders get public only. + # Fail-open: any hiccup resolving the user falls back to the + # public-persona set (never blocks a legitimate global pick). + acl_user = None + try: + acl_email = ( + client.users_info(user=user_id) + .data["user"]["profile"] # type: ignore + .get("email") + ) + if acl_email: + acl_user = get_user_by_email( + email=acl_email, db_session=db_session + ) + except Exception: + logger.warning( + "Unable to resolve Slack user for persona ACL; " + "falling back to public personas" + ) + accessible = get_personas( + user_id=acl_user.id if acl_user else None, + db_session=db_session, + include_default=True, + ) + if acl_user is None: + accessible = [p for p in accessible if p.is_public] + if persona.id not in {p.id for p in accessible}: + logger.warning( + f"Slack user {user_id} tried to select inaccessible " + f"persona {persona.id}" + ) + return { + "response_action": "errors", + "errors": { + "persona_selection": "You don't have access to that assistant." + }, + } + user_slack_persona = fetch_user_slack_persona( db_session=db_session, sender_id=user_id ) @@ -50,7 +93,8 @@ def handle_modal_submission(client: WebClient, body: dict[str, Any]) -> None: persona=persona, user_slack_persona=user_slack_persona, ) - response_text = f"Persona '{persona.name}' has been set!" + persona_label = persona.display_name or persona.name + response_text = f"Persona '{persona_label}' has been set!" client.chat_postMessage(channel=channel_id, text=response_text) return {"response_action": "clear"} @@ -58,9 +102,10 @@ def handle_modal_submission(client: WebClient, body: dict[str, Any]) -> None: add_user_slack_persona( db_session=db_session, sender_id=user_id, persona=persona ) + persona_label = persona.display_name or persona.name client.chat_postMessage( channel=channel_id, - text=f"'{persona.name}' has been successfully set as the current persona.", + text=f"'{persona_label}' has been successfully set as the current persona.", ) return {"response_action": "clear"} diff --git a/backend/danswer/danswerbot/slack/listener.py b/backend/danswer/danswerbot/slack/listener.py index ce2398afe2c..0cecbab3edf 100644 --- a/backend/danswer/danswerbot/slack/listener.py +++ b/backend/danswer/danswerbot/slack/listener.py @@ -22,6 +22,7 @@ from danswer.danswerbot.slack.constants import IMMEDIATE_RESOLVED_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import LIKE_BLOCK_ACTION_ID from danswer.danswerbot.slack.constants import SLACK_CHANNEL_ID +from danswer.danswerbot.slack.constants import SME_VALIDATE_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import VIEW_DOC_FEEDBACK_ID from danswer.danswerbot.slack.handlers.handle_buttons import handle_doc_feedback_button from danswer.danswerbot.slack.handlers.handle_buttons import handle_followup_button @@ -29,6 +30,7 @@ handle_followup_resolved_button, ) from danswer.danswerbot.slack.handlers.handle_buttons import handle_slack_feedback +from danswer.danswerbot.slack.handlers.handle_buttons import handle_sme_validate_button from danswer.danswerbot.slack.handlers.handle_message import handle_message from danswer.danswerbot.slack.handlers.handle_message import ( remove_scheduled_feedback_reminder, @@ -412,6 +414,9 @@ def action_routing(req: SocketModeRequest, client: SocketModeClient) -> None: if actions := req.payload.get("actions"): action = cast(dict[str, Any], actions[0]) + if action["action_id"] == SME_VALIDATE_BUTTON_ACTION_ID: + # SME "verify this answer" button (opt-in per channel) + return handle_sme_validate_button(req, client) if action["action_id"] in [DISLIKE_BLOCK_ACTION_ID, LIKE_BLOCK_ACTION_ID]: # AI Answer feedback return process_feedback(req, client) diff --git a/backend/danswer/db/feedback.py b/backend/danswer/db/feedback.py index bb7da0864f2..71fb0dad832 100644 --- a/backend/danswer/db/feedback.py +++ b/backend/danswer/db/feedback.py @@ -1,3 +1,5 @@ +from datetime import datetime +from datetime import timezone from uuid import UUID from sqlalchemy import asc @@ -175,3 +177,21 @@ def create_chat_message_feedback( db_session.add(message_feedback) db_session.commit() + + +def mark_message_sme_verified( + chat_message_id: int, + verified_by: str, + db_session: Session, +) -> None: + """Record that an SME verified an answer (Slack "Verified by an SME" flow). + Stored on chat_feedback (extends the existing feedback table) so it's queryable + alongside likes/dislikes and follow-up requests.""" + db_session.add( + ChatMessageFeedback( + chat_message_id=chat_message_id, + sme_verified_by=verified_by, + sme_verified_at=datetime.now(timezone.utc), + ) + ) + db_session.commit() diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index cb69b89ec40..ff01513bf14 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -854,6 +854,12 @@ class ChatMessageFeedback(Base): required_followup: Mapped[bool | None] = mapped_column(Boolean, nullable=True) feedback_text: Mapped[str | None] = mapped_column(Text, nullable=True) predefined_feedback: Mapped[str | None] = mapped_column(String, nullable=True) + # SME verification (Slack "Verified by an SME" flow): who verified this answer + # and when. Null unless an SME has verified it. + sme_verified_by: Mapped[str | None] = mapped_column(String, nullable=True) + sme_verified_at: Mapped[datetime.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) chat_message: Mapped[ChatMessage] = relationship( "ChatMessage", @@ -1012,6 +1018,27 @@ 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) + # DEPRECATED / inert. Was free-text LLM-router guidance; the kNN-over-Slack + # router replaced that logic, so nothing reads or writes this anymore. Column + # retained (no migration) to avoid dropping data; safe to drop later. + routing_instructions: Mapped[str | None] = mapped_column(Text, nullable=True) + # Comma-separated phrases that DETERMINISTICALLY route a question to this + # assistant (case-insensitive substring match) BEFORE the LLM router runs — + # a hard override for unambiguous terms (e.g. "automation suite, aks + # deployment"). Blank => no keyword override; the LLM router decides. See + # secondary_llm_flows/assistant_router.keyword_route. + routing_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) + # Whether this assistant is a candidate for the auto-routed Search tab. When + # false it's excluded from BOTH the keyword route and the kNN fallback (still + # manually selectable / @mentionable). Default true => every assistant + # participates in routing. + is_router_candidate: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true" + ) + # DEPRECATED / inert. Was the semantic intent-phrase pre-route; the kNN-over- + # Slack router replaced that logic, so nothing reads or writes this anymore. + # Column retained (no migration) to avoid dropping data; safe to drop later. + routing_intents: 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 @@ -1124,6 +1151,12 @@ class ChannelConfig(TypedDict): prioritized_sources: NotRequired[list[str]] # OpsGenie schedule name for DRI on-call opsgenie_schedule: NotRequired[str] + # Opt-in: show an "Awaiting SME Review" button on bot answers in this + # channel. `sme_group_name` is a comma-separated list of Slack user groups + # (display name or @handle — resolved to ids live); a member of ANY listed + # group can verify, so leavers are handled automatically. + enable_sme_validation: NotRequired[bool] + sme_group_name: NotRequired[str] # JIRA title filter for creating tickets jira_title_filter: NotRequired[list[str]] # Title filter for sending personalised response if user asks for more help @@ -1655,3 +1688,31 @@ class AnalyticsPersonaDailyStats(Base): server_default=func.now(), onupdate=func.now(), ) + + +class ChatReferral(Base): + """A landing on the chat UI from an external referral — e.g. a per-channel + Slack 'Ask Darwin' workflow linking to /chat?assistant=...&utm_source=slack. + Logged fire-and-forget from the client on page load when a utm_source is + present, so inbound traffic can be measured by source / channel / assistant + with a plain SQL query and no log-aggregation pipeline. + + All fields are free-form strings from the URL (length-capped at write time); + they are stored as data only and never interpolated into queries or rendered + as HTML.""" + + __tablename__ = "chat_referral" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + utm_source: Mapped[str] = mapped_column(String, nullable=False) + utm_medium: Mapped[str | None] = mapped_column(String, nullable=True) + utm_campaign: Mapped[str | None] = mapped_column(String, nullable=True) + # The channel the link lived in (e.g. "help-orchestrator"). + utm_channel: Mapped[str | None] = mapped_column(String, nullable=True) + # The `assistant` URL param as provided (a name); the id is resolved elsewhere. + assistant_name: Mapped[str | None] = mapped_column(String, nullable=True) + # Who landed (null for anonymous / auth-disabled). + user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) diff --git a/backend/danswer/db/persona.py b/backend/danswer/db/persona.py index 600884f4499..eb50675ab7e 100644 --- a/backend/danswer/db/persona.py +++ b/backend/danswer/db/persona.py @@ -73,6 +73,8 @@ def create_update_persona( name=create_persona_request.name, display_name=create_persona_request.display_name, description=create_persona_request.description, + routing_keywords=create_persona_request.routing_keywords, + is_router_candidate=create_persona_request.is_router_candidate, 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 +379,8 @@ def upsert_persona( db_session: Session, rerank_enabled: bool = False, display_name: str | None = None, + routing_keywords: str | None = None, + is_router_candidate: bool = True, prompt_ids: list[int] | None = None, document_set_ids: list[int] | None = None, tool_ids: list[int] | None = None, @@ -425,6 +429,8 @@ def upsert_persona( persona.name = name persona.display_name = display_name or name persona.description = description + persona.routing_keywords = routing_keywords + persona.is_router_candidate = is_router_candidate persona.num_chunks = num_chunks persona.llm_relevance_filter = llm_relevance_filter persona.llm_filter_extraction = llm_filter_extraction @@ -458,6 +464,8 @@ def upsert_persona( name=name, display_name=display_name or name, description=description, + routing_keywords=routing_keywords, + is_router_candidate=is_router_candidate, num_chunks=num_chunks, llm_relevance_filter=llm_relevance_filter, llm_filter_extraction=llm_filter_extraction, @@ -524,6 +532,19 @@ def update_persona_visibility( invalidate_personas_all() +def update_persona_is_router_candidate( + persona_id: int, + is_router_candidate: bool, + db_session: Session, +) -> None: + """Toggle whether an assistant participates in the auto-routed Search tab. + Invalidates the persona cache so the router catalog reflects it immediately.""" + persona = get_persona_by_id(persona_id=persona_id, user=None, db_session=db_session) + persona.is_router_candidate = is_router_candidate + db_session.commit() + invalidate_personas_all() + + def check_user_can_edit_persona(user: User | None, persona: Persona) -> None: # if user is None, assume that no-auth is turned on if user is None: diff --git a/backend/danswer/llm/factory.py b/backend/danswer/llm/factory.py index ab67e43939c..0c53ee613a2 100644 --- a/backend/danswer/llm/factory.py +++ b/backend/danswer/llm/factory.py @@ -55,6 +55,11 @@ def get_llm( timeout: int = QA_TIMEOUT, additional_headers: dict[str, str] | None = None, ) -> LLM: + # NOTE: all LLMs (answer generation AND the assistant router) go through + # CustomModelServer, which builds its gateway URL from (provider, model) — + # i.e. GEN_AI_VENDOR/GEN_AI_MODEL_NAME by default (or the router's overrides). + # It does NOT read GEN_AI_API_ENDPOINT or the DB llm_provider row, so those + # can look stale/wrong without affecting the model that actually answers. return CustomModelServer( timeout=timeout, api_key=api_key, diff --git a/backend/danswer/one_shot_answer/answer_question.py b/backend/danswer/one_shot_answer/answer_question.py index 3131406cab5..024061bbdd6 100644 --- a/backend/danswer/one_shot_answer/answer_question.py +++ b/backend/danswer/one_shot_answer/answer_question.py @@ -32,6 +32,7 @@ from danswer.llm.answering.models import QuotesConfig from danswer.llm.factory import get_llms_for_persona from danswer.llm.factory import get_main_llm_from_tuple +from danswer.llm.override_models import LLMOverride from danswer.llm.utils import get_default_llm_token_encode from danswer.one_shot_answer.models import DirectQARequest from danswer.one_shot_answer.models import OneShotQAResponse @@ -91,6 +92,9 @@ def stream_answer_objects( retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None] | None = None, rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, + # Force a specific answer model (vendor/version), bypassing the persona default. + # Used by the Search-tab compare flow to answer the union scope with Sonnet. + llm_override: LLMOverride | None = None, ) -> AnswerObjectIterator: """Streams in order: 1. [always] Retrieved documents, stops flow if nothing is found @@ -157,7 +161,9 @@ def stream_answer_objects( commit=True, ) - llm, fast_llm = get_llms_for_persona(persona=chat_session.persona) + llm, fast_llm = get_llms_for_persona( + persona=chat_session.persona, llm_override=llm_override + ) prompt_config = PromptConfig.from_model(prompt) document_pruning_config = DocumentPruningConfig( max_chunks=int( @@ -189,7 +195,11 @@ def stream_answer_objects( question=query_msg.message, answer_style_config=answer_config, prompt_config=PromptConfig.from_model(prompt), - llm=get_main_llm_from_tuple(get_llms_for_persona(persona=chat_session.persona)), + llm=get_main_llm_from_tuple( + get_llms_for_persona( + persona=chat_session.persona, llm_override=llm_override + ) + ), single_message_history=history_str, tools=[search_tool], force_use_tool=ForceUseTool( @@ -308,6 +318,7 @@ def get_search_answer( retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None] | None = None, rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, + llm_override: LLMOverride | None = None, ) -> OneShotQAResponse: """Collects the streamed one shot answer responses into a single object""" max_attempts = 5 @@ -329,6 +340,7 @@ def get_search_answer( timeout=answer_generation_timeout, retrieval_metrics_callback=retrieval_metrics_callback, rerank_metrics_callback=rerank_metrics_callback, + llm_override=llm_override, ) answer = "" 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/secondary_llm_flows/assistant_router.py b/backend/danswer/secondary_llm_flows/assistant_router.py new file mode 100644 index 00000000000..1e10675a752 --- /dev/null +++ b/backend/danswer/secondary_llm_flows/assistant_router.py @@ -0,0 +1,185 @@ +"""Deterministic keyword pre-route + shared routing types for the Search tab. + +The router pipeline is: + 1. `keyword_route` (here) — deterministic, no LLM. Fires only when a question + matches an assistant's configured `routing_keywords`. + 2. kNN-over-Slack fallback (see `slack_knn_router`) — when no keyword matches. + +The old LLM routing-instructions logic (an LLM ranking assistants by their +curated `routing_instructions` / `routing_intents`) has been removed in favor of +the self-maintaining kNN router; `RouteResult` is shared by both stages. + +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 catalog's persona-id set also fences the kNN router (it can only + route to an assistant present here). +- **Fail-OPEN.** No keyword match -> `keyword_route` returns None and the caller + falls through to the kNN router; a dead-end never happens. +- **Pure, testable core.** No I/O here. +""" +import re +from collections.abc import Sequence +from typing import Protocol + +from pydantic import BaseModel + +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +class RoutableAssistant(Protocol): + """Structural type for anything the router can catalog — satisfied by both + the Persona ORM model and PersonaSnapshot (the cached form). The endpoint + feeds cached PersonaSnapshots; the unit tests feed lightweight stand-ins.""" + + id: int + name: str + routing_keywords: str | None + + +class RouterCatalogEntry(BaseModel): + persona_id: int + name: str + # Deterministic keyword overrides (lowercased phrases). Empty => no keyword + # override for this assistant (it can still be reached via the kNN router). + keywords: list[str] = [] + + +class RouteResult(BaseModel): + # The confident #1 pick, or None when no route was found (caller then uses the + # all-source default persona). + persona_id: int | None + confidence: float + # The full best-first ranking (up to N) for the "recommended assistants" UI. + # Empty for the deterministic keyword route. + ranked_ids: list[int] = [] + # True when the route was a close call — the kNN vote was below the confidence + # threshold and needed the LLM tiebreak. The endpoint offers the side-by-side + # compare (single pick vs. union of the top-N) only in this ambiguous case. + ambiguous: bool = False + + +def build_router_catalog( + personas: Sequence[RoutableAssistant], +) -> list[RouterCatalogEntry]: + """Build the routable-assistant catalog from an ALREADY ACL-filtered list. + + Caller passes the user's accessible + visible + non-Slack personas. EVERY + persona is included (its id fences the kNN router) — an assistant needs no + keywords to be routable, since the kNN fallback routes without them.""" + catalog: list[RouterCatalogEntry] = [] + for persona in personas: + keywords = [ + kw.strip().lower() + for kw in (persona.routing_keywords or "").split(",") + if kw.strip() + ] + catalog.append( + RouterCatalogEntry( + persona_id=persona.id, + name=persona.name, + keywords=keywords, + ) + ) + return catalog + + +_WORD_RE = re.compile(r"[a-z0-9]+") +# A short keyword token must match a WHOLE word; a longer one (>= this) may also +# match as a start-anchored prefix, so stems like "expir"/"licens" hit +# "expired"/"licensing" without the substring traps (e.g. "form" != "perform"). +_PREFIX_MIN_LEN = 4 + + +def _keyword_tokens(keyword: str) -> list[str]: + return _WORD_RE.findall((keyword or "").lower()) + + +def _token_present(token: str, words: set[str]) -> bool: + if token in words: + return True + if len(token) >= _PREFIX_MIN_LEN: + return any(w.startswith(token) for w in words) + return False + + +def _word_token_match(token: str, word: str) -> bool: + """One position: exact word, or start-anchored prefix for tokens >= 4 chars.""" + return word == token or (len(token) >= _PREFIX_MIN_LEN and word.startswith(token)) + + +def _is_contiguous(tokens: list[str], word_list: list[str]) -> bool: + """True if the keyword's tokens appear as an adjacent, in-order run of words.""" + n = len(tokens) + for i in range(len(word_list) - n + 1): + if all(_word_token_match(tokens[j], word_list[i + j]) for j in range(n)): + return True + return False + + +def keyword_route( + question: str, catalog: list[RouterCatalogEntry] +) -> RouteResult | None: + """Deterministic pre-route: route straight to an assistant when the question + matches one of its configured keywords. Returns None when nothing matches + (caller then runs the kNN router), so this is additive. + + Keyword forms (all in the one comma-separated column): + - FUZZY (unquoted, e.g. `task sla`): matches when EVERY word appears in the + question, any order/position — exact, or a start-anchored prefix for words + >= 4 chars (so `sla expir` hits "the SLA ... expired"). + - EXACT (quoted, e.g. `"as environment"`): matches only as a contiguous phrase. + Use for abbreviations that are also common words (AS = Automation Suite). + - ALWAYS-WINS (`!` prefix, e.g. `!automation suite` or `!"as environment"`): + a priority flag — if it matches it outranks every non-priority match. + + Ranking among matches (highest wins): + (priority, contiguous, exact-word-count, num_words, char_len, -persona_id) + So an explicitly-tagged keyword wins first; otherwise a keyword whose words + appear ADJACENT and EXACT beats one that only matched scattered / via prefix + (e.g. `automation suite` verbatim beats `integration service` matched on the + incidental words "integrations"/"services"). confidence=1.0.""" + word_list = _WORD_RE.findall((question or "").lower()) + words = set(word_list) + if not words: + return None + q_lower = question.lower() + best_key: tuple | None = None + best_id: int | None = None + for entry in catalog: + for raw in entry.keywords: + kw = raw.strip() + priority = kw.startswith("!") + if priority: + kw = kw[1:].strip() + if len(kw) >= 2 and kw[0] == '"' and kw[-1] == '"': + # Quoted -> exact contiguous phrase match. + phrase = kw[1:-1].strip() + tokens = _keyword_tokens(phrase) + if not phrase or phrase not in q_lower: + continue + contiguous = True + else: + # Unquoted -> fuzzy: all words present, any order/position. + tokens = _keyword_tokens(kw) + if not tokens or not all(_token_present(t, words) for t in tokens): + continue + contiguous = _is_contiguous(tokens, word_list) + exact = sum(1 for t in tokens if t in words) + key = ( + priority, + contiguous, + exact, + len(tokens), + len("".join(tokens)), + -entry.persona_id, + ) + if best_key is None or key > best_key: + best_key = key + best_id = entry.persona_id + if best_id is None: + return None + logger.info("assistant router: keyword override -> persona_id=%s", best_id) + return RouteResult(persona_id=best_id, confidence=1.0) diff --git a/backend/danswer/secondary_llm_flows/slack_knn_router.py b/backend/danswer/secondary_llm_flows/slack_knn_router.py new file mode 100644 index 00000000000..7da1d98234f --- /dev/null +++ b/backend/danswer/secondary_llm_flows/slack_knn_router.py @@ -0,0 +1,264 @@ +"""kNN-over-Slack assistant router — the fallback when `keyword_route` misses. + +Replaces the LLM routing-instructions logic. Routes by nearest-neighbor over the +EXISTING Vespa slack corpus (thread-start chunks, `chunk_id=0`), labeled by +`channel -> persona` via `slack_bot_config`. Self-maintaining: there are no +per-assistant routing instructions to curate — new product -> new help channel -> +questions flow in -> the router learns it. + +Pipeline: + embed question + -> Vespa kNN (source_type=slack, chunk_id=0) + -> similarity-weighted vote over neighbors' personas, intersected with the + caller's ACL catalog (so it can never route to an inaccessible assistant) + -> confidence gate + -> on LOW confidence, one fast-LLM tiebreak among the neighbors' assistants. + NOTE: that LLM only ever sees the retrieved neighbor questions — never + `routing_instructions` — so it is consistent with removing that logic. + +Fail-OPEN everywhere: any error / empty result -> RouteResult(persona_id=None), +and the caller falls back to the all-source default persona. + +Prototype validation (leave-one-out, prod, n=285/51 channels): vote-only 64.6% +top-1, vote+LLM tiebreak 70.5% top-1, 85.3% top-3. +""" +import json +from collections import defaultdict + +import requests +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from danswer.configs.chat_configs import SLACK_KNN_ROUTER_LLM_CONF_THRESHOLD +from danswer.configs.chat_configs import SLACK_KNN_ROUTER_MIN_RECOMMENDATION_VOTES +from danswer.configs.chat_configs import SLACK_KNN_ROUTER_TOP_K +from danswer.configs.constants import CHUNK_ID +from danswer.configs.constants import CONTENT +from danswer.configs.constants import DOCUMENT_ID +from danswer.configs.constants import EMBEDDINGS +from danswer.configs.constants import METADATA +from danswer.configs.constants import SOURCE_TYPE +from danswer.db.embedding_model import get_current_db_embedding_model +from danswer.db.models import SlackBotConfig +from danswer.document_index.vespa.index import SEARCH_ENDPOINT +from danswer.llm.interfaces import LLM +from danswer.llm.utils import message_to_string +from danswer.search.enums import EmbedTextType +from danswer.search.search_nlp_models import EmbeddingModel +from danswer.secondary_llm_flows.assistant_router import RouterCatalogEntry +from danswer.secondary_llm_flows.assistant_router import RouteResult +from danswer.utils.logger import setup_logger +from shared_configs.configs import MODEL_SERVER_HOST +from shared_configs.configs import MODEL_SERVER_PORT + +logger = setup_logger() + +_VESPA_TIMEOUT = "3s" + + +class SlackNeighbor(BaseModel): + channel: str + score: float + content: str + + +def build_channel_persona_map(db_session: Session) -> dict[str, int]: + """`slack help-channel name -> persona_id`, from every SlackBotConfig. + + Raw map: it may include the generic/Slack-bot personas. The caller intersects + with its ACL catalog (which already excludes the default 'Darwin' + Slack-bot + + hidden personas), so no channel needs special-casing here.""" + mapping: dict[str, int] = {} + for cfg in db_session.execute(select(SlackBotConfig)).scalars(): + if cfg.persona_id is None: + continue + for channel in (cfg.channel_config or {}).get("channel_names", []) or []: + mapping[channel] = cfg.persona_id + return mapping + + +def _channel_of(fields: dict) -> str | None: + """metadata comes back from Vespa as a JSON string (or dict); pull Channel.""" + meta = fields.get(METADATA) + if isinstance(meta, str): + try: + meta = json.loads(meta) + except ValueError: + return None + return meta.get("Channel") if isinstance(meta, dict) else None + + +def retrieve_slack_neighbors( + question: str, + db_session: Session, + top_k: int = SLACK_KNN_ROUTER_TOP_K, +) -> list[SlackNeighbor]: + """Embed the question and kNN it against slack thread-start chunks + (source_type=slack, chunk_id=0). Fail-OPEN -> [] on any error.""" + if not question.strip(): + return [] + try: + db_embedding_model = get_current_db_embedding_model(db_session) + embedder = EmbeddingModel( + model_name=db_embedding_model.model_name, + query_prefix=db_embedding_model.query_prefix, + passage_prefix=db_embedding_model.passage_prefix, + normalize=db_embedding_model.normalize, + server_host=MODEL_SERVER_HOST, + server_port=MODEL_SERVER_PORT, + ) + embedding = embedder.encode([question], EmbedTextType.QUERY)[0] + index_name = db_embedding_model.index_name + yql = ( + f"select {DOCUMENT_ID}, {METADATA}, {CONTENT} from {index_name} where " + f'{SOURCE_TYPE} contains "slack" and {CHUNK_ID} = 0 and ' + f"(({{targetHits:{10 * top_k}}}nearestNeighbor({EMBEDDINGS}, query_embedding)))" + ) + resp = requests.post( + SEARCH_ENDPOINT, + json={ + "yql": yql, + "input.query(query_embedding)": str(embedding), + "ranking.profile": f"hybrid_search{len(embedding)}", + "hits": top_k, + "timeout": _VESPA_TIMEOUT, + }, + ) + resp.raise_for_status() + hits = resp.json().get("root", {}).get("children", []) or [] + except Exception as e: + logger.warning("slack-knn router: neighbor retrieval failed: %s", e) + return [] + + neighbors: list[SlackNeighbor] = [] + for hit in hits: + fields = hit.get("fields", {}) + channel = _channel_of(fields) + if not channel: + continue + neighbors.append( + SlackNeighbor( + channel=channel, + score=float(hit.get("relevance") or 0.0), + content=(fields.get(CONTENT) or "").replace("\n", " "), + ) + ) + return neighbors + + +_TIEBREAK_PROMPT = """\ +You route an internal user question to exactly ONE assistant. + +USER QUESTION: +"{question}" + +Similar past questions and the assistant that answered each: +{examples} + +Valid assistants: {names}. +Reply with ONLY the assistant name, exactly as written above.""" + + +def _llm_tiebreak( + question: str, + examples: list[tuple[str, str]], # (assistant_name, neighbor_snippet) + name_to_id: dict[str, int], + llm: LLM, +) -> int | None: + """Ask the fast LLM to pick one assistant among the retrieved neighbors. + Only sees neighbor questions (never routing_instructions). None on any issue.""" + rendered = "\n".join( + f'{i}. [{name}] "{snippet[:160]}"' + for i, (name, snippet) in enumerate(examples[:10], 1) + ) + prompt = _TIEBREAK_PROMPT.format( + question=question[:400], + examples=rendered, + names=", ".join(name_to_id), + ) + try: + out = message_to_string(llm.invoke(prompt)).strip() + except Exception as e: + logger.warning("slack-knn router: tiebreak LLM call failed: %s", e) + return None + lowered = out.lower() + for name, pid in name_to_id.items(): + if name.lower() == lowered: + return pid + for name, pid in name_to_id.items(): + if name.lower() in lowered: + return pid + return None + + +def knn_route( + question: str, + neighbors: list[SlackNeighbor], + channel_to_persona: dict[str, int], + catalog: list[RouterCatalogEntry], + llm: LLM, + top_n: int = 3, + conf_threshold: float = SLACK_KNN_ROUTER_LLM_CONF_THRESHOLD, + min_recommendation_votes: int = SLACK_KNN_ROUTER_MIN_RECOMMENDATION_VOTES, +) -> RouteResult: + """Vote over the neighbors' personas (kept to the ACL catalog), gate on + confidence, and LLM-tiebreak the low-confidence cases. Fail-OPEN. + + `ranked_ids` (the recommended assistants + compare union scope) only includes + assistants matched by >= `min_recommendation_votes` neighbors, filtering out + single incidental matches. The answering pick (`persona_id`) is chosen + independently of that threshold — something always answers.""" + catalog_ids = {entry.persona_id for entry in catalog} + id_to_name = {entry.persona_id: entry.name for entry in catalog} + + votes: dict[int, float] = defaultdict(float) + counts: dict[int, int] = defaultdict(int) # neighbor matches per persona + examples: list[tuple[str, str]] = [] # (name, snippet) best-first, catalog-only + for neighbor in neighbors: + pid = channel_to_persona.get(neighbor.channel) + if pid is None or pid not in catalog_ids: + continue + votes[pid] += neighbor.score + counts[pid] += 1 + examples.append((id_to_name[pid], neighbor.content)) + + if not votes: + return RouteResult(persona_id=None, confidence=0.0) + + ranked = sorted(votes.items(), key=lambda kv: kv[1], reverse=True) + top_id, top_weight = ranked[0] + total_weight = sum(votes.values()) + confidence = top_weight / total_weight if total_weight else 0.0 + + final_id = top_id + ambiguous = confidence < conf_threshold and len(votes) > 1 + if ambiguous: + name_to_id = {id_to_name[pid]: pid for pid in votes} + picked = _llm_tiebreak(question, examples, name_to_id, llm) + if picked is not None: + final_id = picked + + # Recommendations / union scope: only assistants with >= min votes (drop + # single incidental matches). Lead with the answering pick when it qualifies. + recommended = [pid for pid, _ in ranked if counts[pid] >= min_recommendation_votes] + if final_id in recommended: + recommended.remove(final_id) + recommended.insert(0, final_id) + ranked_ids = recommended[:top_n] + + logger.info( + "slack-knn router: question=%r -> persona_id=%s confidence=%.2f ranked=%s " + "(min_votes=%d)", + question[:80], + final_id, + confidence, + ranked_ids, + min_recommendation_votes, + ) + return RouteResult( + persona_id=final_id, + confidence=confidence, + ranked_ids=ranked_ids, + ambiguous=ambiguous, + ) diff --git a/backend/danswer/server/features/persona/api.py b/backend/danswer/server/features/persona/api.py index 87a3435d4e9..4c89157e6d2 100644 --- a/backend/danswer/server/features/persona/api.py +++ b/backend/danswer/server/features/persona/api.py @@ -16,6 +16,7 @@ from danswer.db.persona import mark_persona_as_deleted from danswer.db.persona import mark_persona_as_not_deleted from danswer.db.persona import update_all_personas_display_priority +from danswer.db.persona import update_persona_is_router_candidate from danswer.db.persona import update_persona_shared_users from danswer.db.persona import update_persona_visibility from danswer.db.persona_cache import get_personas_for_user_cached @@ -53,6 +54,24 @@ def patch_persona_visibility( ) +class IsRouterCandidateRequest(BaseModel): + is_router_candidate: bool + + +@admin_router.patch("/{persona_id}/router-candidate") +def patch_persona_router_candidate( + persona_id: int, + request: IsRouterCandidateRequest, + _: User | None = Depends(current_admin_user), + db_session: Session = Depends(get_session), +) -> None: + update_persona_is_router_candidate( + persona_id=persona_id, + is_router_candidate=request.is_router_candidate, + db_session=db_session, + ) + + @admin_router.put("/display-priority") def patch_persona_display_priority( display_priority_request: DisplayPriorityRequest, diff --git a/backend/danswer/server/features/persona/models.py b/backend/danswer/server/features/persona/models.py index 6ae3f59aef7..5dcef670a85 100644 --- a/backend/danswer/server/features/persona/models.py +++ b/backend/danswer/server/features/persona/models.py @@ -20,6 +20,12 @@ class CreatePersonaRequest(BaseModel): # Optional user-friendly label shown in chat; defaults to `name` if omitted. display_name: str | None = None description: str + # Comma-separated keywords that deterministically route to this assistant + # (the auto-routed Search tab; never shown to users). + routing_keywords: str | None = None + # Whether this assistant participates in the auto-routed Search tab + # (keyword route + kNN fallback). Default true. + is_router_candidate: bool = True num_chunks: float llm_relevance_filter: bool is_public: bool @@ -49,6 +55,8 @@ class PersonaSnapshot(BaseModel): is_public: bool display_priority: int | None description: str + routing_keywords: str | None + is_router_candidate: bool = True num_chunks: float | None llm_relevance_filter: bool llm_filter_extraction: bool @@ -87,6 +95,8 @@ def from_model( is_public=persona.is_public, display_priority=persona.display_priority, description=persona.description, + routing_keywords=persona.routing_keywords, + is_router_candidate=persona.is_router_candidate, 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/manage/models.py b/backend/danswer/server/manage/models.py index c7f18d4ee92..4a2a8d7888f 100644 --- a/backend/danswer/server/manage/models.py +++ b/backend/danswer/server/manage/models.py @@ -117,6 +117,10 @@ class SlackBotConfigCreationRequest(BaseModel): # List of source types to prioritize in search results prioritized_sources: list[str] | None = None opsgenie_schedule: str | None = None + # SME verification: opt-in "Verify this answer" button gated to a Slack user + # group (by display name or @handle). + enable_sme_validation: bool = False + sme_group_name: str | None = None jira_config: dict[str, Any] | None = None curated_response_config: dict[str, Any] | None = None jira_title_filter: list[str] | None = None diff --git a/backend/danswer/server/manage/slack_bot.py b/backend/danswer/server/manage/slack_bot.py index 6bb6709be57..dfce7ef1f95 100644 --- a/backend/danswer/server/manage/slack_bot.py +++ b/backend/danswer/server/manage/slack_bot.py @@ -95,6 +95,12 @@ def _form_channel_config( channel_config["prioritized_sources"] = prioritized_sources if opsgenie_schedule: channel_config["opsgenie_schedule"] = opsgenie_schedule + if slack_bot_config_creation_request.enable_sme_validation: + channel_config["enable_sme_validation"] = True + if slack_bot_config_creation_request.sme_group_name: + channel_config[ + "sme_group_name" + ] = slack_bot_config_creation_request.sme_group_name if jira_config: channel_config["jira_config"] = jira_config if jira_title_filter: diff --git a/backend/danswer/server/query_and_chat/chat_backend.py b/backend/danswer/server/query_and_chat/chat_backend.py index 1aee9c3d3d4..f73848c994a 100644 --- a/backend/danswer/server/query_and_chat/chat_backend.py +++ b/backend/danswer/server/query_and_chat/chat_backend.py @@ -37,6 +37,7 @@ from danswer.db.engine import get_session from danswer.db.feedback import create_chat_message_feedback from danswer.db.feedback import create_doc_retrieval_feedback +from danswer.db.models import ChatReferral from danswer.db.models import User from danswer.db.persona import get_persona_by_id from danswer.document_index.document_index_utils import get_both_index_names @@ -853,3 +854,51 @@ def fetch_chat_file( except Exception as e: logger.error(f"Error fetching file {file_id}: {str(e)}") raise HTTPException(status_code=404, detail="File not found") + + +# --- Referral logging ------------------------------------------------------ +# One row per landing on the chat UI from an external referral (e.g. a +# per-channel Slack "Ask Darwin" workflow: /chat?assistant=...&utm_source=slack). +# Fired fire-and-forget by the client on page load; lets us measure inbound +# traffic by source/channel/assistant with plain SQL, no log pipeline. + +# Cap free-form URL fields so a crafted link can't bloat the row/table. +_REFERRAL_FIELD_MAX = 200 + + +class ChatReferralRequest(BaseModel): + utm_source: str + utm_medium: str | None = None + utm_campaign: str | None = None + utm_channel: str | None = None + assistant: str | None = None + + +@router.post("/referral") +def log_chat_referral( + referral: ChatReferralRequest, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> None: + def _cap(value: str | None) -> str | None: + return value[:_REFERRAL_FIELD_MAX] if value else value + + source = _cap(referral.utm_source) + if not source: + # utm_source is the whole point of the event; ignore empty pings. + return None + + # Stored as data only (parameterized via the ORM); never interpolated or + # rendered as HTML. user_id is the authenticated landing user, if any. + db_session.add( + ChatReferral( + utm_source=source, + utm_medium=_cap(referral.utm_medium), + utm_campaign=_cap(referral.utm_campaign), + utm_channel=_cap(referral.utm_channel), + assistant_name=_cap(referral.assistant), + user_id=user.id if user else None, + ) + ) + db_session.commit() + return None diff --git a/backend/danswer/server/query_and_chat/models.py b/backend/danswer/server/query_and_chat/models.py index bf6bf7b924f..0c23b91897d 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,88 @@ class AdminSearchResponse(BaseModel): documents: list[SearchDoc] +class AutoSearchRequest(BaseModel): + """A single point-in-time question for the auto-routed Search tab.""" + + message: str + # When the user explicitly picks an assistant via "@mention", its id is sent + # here — the router (LLM selection) step is skipped and this assistant is + # invoked directly (still ACL-re-checked on the trusted side). None => auto-route. + persona_id: int | None = None + + +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 SearchedAssistant(BaseModel): + """A next-best assistant the router ranked below the one that answered — shown + as 'Recommended assistants' the user can chat with next if #1 wasn't right.""" + + persona_id: int + name: str + display_name: str | None + + +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 + # Next-best assistants (router ranks 2..N) offered as a chat-further affordance + # from the SAME router call — no extra LLM call. Empty for keyword routes and + # explicit @mentions (nothing to recommend). + other_recommended: list[SearchedAssistant] = [] + error_msg: str | None = None + # --- Side-by-side compare (Settings.auto_search_compare_enabled) ----------- + # True when compare applies (LLM route + gate on): tells the UI to render the + # tabbed layout and lazy-fetch the union answer via /query/auto-search/union. + # False => single-answer layout (keyword/@mention routes, or gate off). + compare_enabled: bool = False + # The assistants whose document sets the union answer should span (the router's + # top-N picks). The UI passes these ids to the union endpoint — no re-routing, + # so the union scope is guaranteed consistent with this response. Computing this + # is cheap (no second answer generation), so the top-1 answer isn't held up. + union_assistants: list[SearchedAssistant] = [] + # True when the third compare tab applies: a fixed source-scoped answer + # (HighSpot + the docs sites). Rides along with compare_enabled; the UI + # lazy-fetches it via /query/auto-search/sources. + source_tab_enabled: bool = False + + +class AutoSearchSourcesRequest(BaseModel): + """Lazy third-answer request for the compare view: answer scoped to a fixed set + of source types (HighSpot + docs sites), configured server-side. Only `message` + is needed — the sources are not client-controlled.""" + + message: str + + +class AutoSearchUnionRequest(BaseModel): + """Lazy second-answer request for the Search tab's compare view: answer over the + UNION of the given assistants' document sets. Fired by the UI after the primary + answer arrives, so the two answers never block each other.""" + + message: str + # The assistants to union — the union_assistants ids from AutoSearchResponse. + persona_ids: list[int] + + +class AutoSearchUnionResponse(BaseModel): + answer: str | None = None + citations: list[CitationInfo] | None = None + docs: QADocsResponse | None = None + 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..ea2803fd64f 100644 --- a/backend/danswer/server/query_and_chat/query_backend.py +++ b/backend/danswer/server/query_and_chat/query_backend.py @@ -6,35 +6,74 @@ from sqlalchemy.orm import Session from danswer.auth.api_key import validate_api_key +from danswer.auth.schemas import UserRole from danswer.auth.users import current_admin_user from danswer.auth.users import current_user +from danswer.configs.chat_configs import ASSISTANT_ROUTER_LLM_MODEL +from danswer.configs.chat_configs import ASSISTANT_ROUTER_LLM_VENDOR +from danswer.configs.chat_configs import AUTO_SEARCH_DEFAULT_LLM_MODEL +from danswer.configs.chat_configs import AUTO_SEARCH_DEFAULT_LLM_VENDOR +from danswer.configs.chat_configs import AUTO_SEARCH_SOURCE_TAB_ENABLED +from danswer.configs.chat_configs import AUTO_SEARCH_SOURCE_TAB_SOURCES +from danswer.configs.chat_configs import AUTO_SEARCH_TOP_N +from danswer.configs.chat_configs import AUTO_SEARCH_UNION_LLM_MODEL +from danswer.configs.chat_configs import AUTO_SEARCH_UNION_LLM_VENDOR +from danswer.configs.chat_configs import AUTO_SEARCH_UNION_PERSONA_ID from danswer.configs.constants import DocumentSource +from danswer.configs.constants import MessageType +from danswer.db.constants import SLACK_BOT_PERSONA_PREFIX from danswer.db.embedding_model import get_current_db_embedding_model from danswer.db.engine import get_session +from danswer.db.models import Persona from danswer.db.models import User +from danswer.db.persona import get_persona_by_id +from danswer.db.persona_cache import get_personas_for_user_cached 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.llm.factory import get_default_llms +from danswer.llm.factory import get_llm +from danswer.llm.interfaces import LLM +from danswer.llm.override_models import LLMOverride +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 BaseFilters from danswer.search.models import IndexFilters +from danswer.search.models import OptionalSearchSetting +from danswer.search.models import RetrievalDetails from danswer.search.models import SearchDoc from danswer.search.preprocessing.access_filters import build_access_filters_for_user from danswer.search.preprocessing.danswer_helper import recommend_search_flow from danswer.search.utils import chunks_or_sections_to_search_docs +from danswer.secondary_llm_flows.assistant_router import build_router_catalog +from danswer.secondary_llm_flows.assistant_router import keyword_route from danswer.secondary_llm_flows.query_validation import get_query_answerability from danswer.secondary_llm_flows.query_validation import stream_query_answerability +from danswer.secondary_llm_flows.slack_knn_router import build_channel_persona_map +from danswer.secondary_llm_flows.slack_knn_router import knn_route +from danswer.secondary_llm_flows.slack_knn_router import retrieve_slack_neighbors from danswer.server.middleware.request_rate_limit import ( check_message_request_rate_limit, ) from danswer.server.query_and_chat.models import AdminSearchRequest from danswer.server.query_and_chat.models import AdminSearchResponse +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.query_and_chat.models import AutoSearchSourcesRequest +from danswer.server.query_and_chat.models import AutoSearchUnionRequest +from danswer.server.query_and_chat.models import AutoSearchUnionResponse from danswer.server.query_and_chat.models import HelperResponse from danswer.server.query_and_chat.models import QueryValidationResponse +from danswer.server.query_and_chat.models import SearchedAssistant from danswer.server.query_and_chat.models import SimpleQueryRequest from danswer.server.query_and_chat.models import SourceTag from danswer.server.query_and_chat.models import TagResponse from danswer.server.query_and_chat.token_limit import check_token_rate_limits +from danswer.server.settings.models import AutoSearchRollout +from danswer.server.settings.store import load_settings from danswer.utils.logger import setup_logger logger = setup_logger() @@ -174,3 +213,397 @@ 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 + + +def _get_router_llm() -> LLM: + """The LLM used for assistant routing. When ASSISTANT_ROUTER_LLM_VENDOR + + ASSISTANT_ROUTER_LLM_MODEL are set (e.g. awsbedrock + Claude Sonnet), route + with that model for sharper selection; otherwise use the default fast LLM.""" + if ASSISTANT_ROUTER_LLM_VENDOR and ASSISTANT_ROUTER_LLM_MODEL: + return get_llm( + provider=ASSISTANT_ROUTER_LLM_VENDOR, model=ASSISTANT_ROUTER_LLM_MODEL + ) + _, fast_llm = get_default_llms() + return fast_llm + + +def _get_router_catalog(user: User | None, db_session: Session) -> list: + """Build the router catalog from the user's accessible assistants. + + Uses the Redis-backed persona cache (get_personas_for_user_cached), which is + ACL-filtered AND write-through invalidated on EVERY persona mutation (see + invalidate_personas_all() calls in db/persona.py) — so the catalog bursts and + repopulates whenever an admin updates an assistant, cross-worker, not just on + a TTL. When PERSONA_CACHE_ENABLED is false it falls back to a direct DB read. + We then drop the default ('Darwin', the fallback), Slack-bot, and hidden + personas — none are routing targets.""" + snapshots = get_personas_for_user_cached( + user_id=user.id if user else None, db_session=db_session + ) + routable = [ + snapshot + for snapshot in snapshots + if snapshot.is_visible + and not snapshot.default_persona + and not snapshot.name.startswith(SLACK_BOT_PERSONA_PREFIX) + # Admin opt-out: exclude assistants flagged out of auto-routing. + and snapshot.is_router_candidate + ] + return build_router_catalog(routable) + + +@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.""" + settings = load_settings() + # Trusted-side rollout gate — never rely on the FE hiding the tab. + if not _auto_search_allowed(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}") + + routed_confidence = 0.0 + # The router's best-first top-N ranking. The #1 answers the question (single + # scope); the rest become "recommended assistants" the user can chat with next. + # Empty for keyword routes and @mentions (nothing to recommend). + ranked_ids: list[int] = [] + # True only when the kNN router had to break a close call — the trigger for the + # side-by-side compare (single pick vs. union of the top-N). + route_ambiguous = False + if auto_search_request.persona_id is not None: + # User explicitly @mentioned an assistant — skip the LLM router and + # invoke it directly. Still ACL-re-checked below via get_persona_by_id. + target_persona_id = auto_search_request.persona_id + routed_confidence = 1.0 + else: + # Auto-route. Catalog = the user's accessible, VISIBLE, non-Slack + # assistants via the Redis persona cache (busted on every assistant + # mutation). Fail-open: any LLM/availability issue -> all-source fallback. + catalog = _get_router_catalog(user, db_session) + target_persona_id = DEFAULT_SEARCH_PERSONA_ID + # 1) Deterministic keyword override (no LLM call) — additive: only fires + # when a configured keyword matches; otherwise falls through. + kw = keyword_route(question, catalog) + if kw is not None and kw.persona_id is not None: + target_persona_id = kw.persona_id + routed_confidence = kw.confidence + else: + # 2) kNN-over-Slack fallback (replaces the LLM routing-instructions + # logic): nearest-neighbor over past slack help-channel questions, + # labeled channel -> persona, weighted vote + an LLM tiebreak on the + # low-confidence cases. Self-maintaining; no routing_instructions. + # The vote ranking populates ranks 2..N (the recommendations). + try: + neighbors = retrieve_slack_neighbors(question, db_session) + channel_map = build_channel_persona_map(db_session) + route = knn_route( + question, + neighbors, + channel_map, + catalog, + _get_router_llm(), + top_n=AUTO_SEARCH_TOP_N, + ) + if route.persona_id is not None: + target_persona_id = route.persona_id + routed_confidence = route.confidence + ranked_ids = route.ranked_ids + route_ambiguous = route.ambiguous + except Exception as e: + logger.warning("Auto-search routing unavailable, using fallback: %s", e) + + def _resolve(pid: int) -> Persona | None: + """ACL-checked persona fetch; None if inaccessible/missing.""" + try: + return get_persona_by_id( + pid, user=user, db_session=db_session, is_for_edit=False + ) + except Exception: + return None + + # SINGLE-SCOPE: answer with the ONE routed assistant (its own document sets + # fence retrieval). On any issue (incl. an explicit persona_id the user can't + # access) fall back to the all-source default persona so the box never dead-ends. + persona = _resolve(target_persona_id) or get_persona_by_id( + DEFAULT_SEARCH_PERSONA_ID, user=user, db_session=db_session, is_for_edit=False + ) + was_routed = persona.id != DEFAULT_SEARCH_PERSONA_ID + prompt_id = persona.prompts[0].id if persona.prompts else 0 + + # Recommended assistants: the router's next-best picks (ranks 2..N), which the + # user can click to chat further if #1 wasn't right. Only populated on the LLM + # route (ranked_ids is empty for keyword routes / @mentions). No extra LLM call. + other_recommended: list[SearchedAssistant] = [] + for alt_id in ranked_ids: + if alt_id == persona.id or len(other_recommended) >= 2: + continue + alt = _resolve(alt_id) + if alt is None: + continue + other_recommended.append( + SearchedAssistant( + persona_id=alt.id, name=alt.name, display_name=alt.display_name + ) + ) + + # 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. + # Optional model override for the top-1 answer (default: the persona's own + # model). Configurable so admins can A/B the default answerer. + default_llm_override = ( + LLMOverride( + model_provider=AUTO_SEARCH_DEFAULT_LLM_VENDOR, + model_version=AUTO_SEARCH_DEFAULT_LLM_MODEL, + ) + if AUTO_SEARCH_DEFAULT_LLM_VENDOR and AUTO_SEARCH_DEFAULT_LLM_MODEL + else None + ) + 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, + llm_override=default_llm_override, + ) + + # SIDE-BY-SIDE COMPARE (metadata only): mark that a union answer is available + # and list the assistants it should span (the router's top-N). The union answer + # itself is produced by /query/auto-search/union, lazy-fetched by the UI AFTER + # this response — so the top-1 answer is never held up by a second generation. + # Only offered when the kNN router had to break a close call (route_ambiguous): + # the pick is uncertain, so we show its single-scope answer next to the broader + # top-N union. Confident keyword / high-confidence kNN routes / @mentions stay + # single-answer. + compare_enabled = False + union_assistants: list[SearchedAssistant] = [] + if settings.auto_search_compare_enabled and route_ambiguous and ranked_ids: + for pid in ranked_ids[:AUTO_SEARCH_TOP_N]: + alt = _resolve(pid) + if alt is not None: + union_assistants.append( + SearchedAssistant( + persona_id=alt.id, name=alt.name, display_name=alt.display_name + ) + ) + compare_enabled = len(union_assistants) > 0 + + 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, + other_recommended=other_recommended, + answered_by=AnsweredByAssistant( + persona_id=persona.id, + name=persona.name, + display_name=persona.display_name, + routed=was_routed, + confidence=routed_confidence, + ), + compare_enabled=compare_enabled, + union_assistants=union_assistants, + # Third tab (HighSpot + docs sites) rides along with the compare view. + source_tab_enabled=compare_enabled and AUTO_SEARCH_SOURCE_TAB_ENABLED, + ) + + +def _compare_answer( + question: str, + filters: BaseFilters, + user: User | None, + db_session: Session, +) -> AutoSearchUnionResponse: + """Answer `question` with the fence-less union persona under `filters` (which IS + the scope, since the persona has no document sets of its own). Shared by the + union (document_set) and sources (source_type) compare tabs; uses the compare + model (default Sonnet). Fail-safe -> error_msg.""" + try: + union_persona = get_persona_by_id( + AUTO_SEARCH_UNION_PERSONA_ID, + user=user, + db_session=db_session, + is_for_edit=False, + ) + except Exception: + union_persona = get_persona_by_id( + DEFAULT_SEARCH_PERSONA_ID, + user=user, + db_session=db_session, + is_for_edit=False, + ) + prompt_id = union_persona.prompts[0].id if union_persona.prompts else 0 + override = ( + LLMOverride( + model_provider=AUTO_SEARCH_UNION_LLM_VENDOR, + model_version=AUTO_SEARCH_UNION_LLM_MODEL, + ) + if AUTO_SEARCH_UNION_LLM_VENDOR and AUTO_SEARCH_UNION_LLM_MODEL + else None + ) + try: + response = get_search_answer( + query_req=DirectQARequest( + messages=[ + ThreadMessage(message=question, sender=None, role=MessageType.USER) + ], + prompt_id=prompt_id, + persona_id=union_persona.id, + retrieval_options=RetrievalDetails( + run_search=OptionalSearchSetting.ALWAYS, + real_time=False, + filters=filters, + ), + ), + user=user, + max_document_tokens=None, + max_history_tokens=0, + db_session=db_session, + use_citations=True, + llm_override=override, + ) + except Exception as e: + logger.warning("Auto-search compare answer failed: %s", e) + return AutoSearchUnionResponse( + error_msg="Could not generate the compare answer." + ) + return AutoSearchUnionResponse( + answer=response.answer, + citations=response.citations, + docs=response.docs, + error_msg=response.error_msg, + ) + + +@basic_router.post("/auto-search/union") +def auto_search_union( + union_request: AutoSearchUnionRequest, + request: Request, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), + _rate_limit: None = Depends(check_message_request_rate_limit), + _: None = Depends(check_token_rate_limits), +) -> AutoSearchUnionResponse: + """Second (compare) answer for the Search tab: answer the question over the UNION + of the given assistants' document sets, using the compare model (default Sonnet). + Lazy-fetched by the UI after the primary answer arrives, so neither answer blocks + the other. The caller passes the persona ids from AutoSearchResponse.union_assistants + (no re-routing here — the union scope stays consistent with the primary response). + """ + settings = load_settings() + if not _auto_search_allowed(settings.auto_search_rollout, user): + raise HTTPException( + status_code=403, detail="Auto-search is not enabled for your account." + ) + if not settings.auto_search_compare_enabled: + raise HTTPException(status_code=403, detail="Compare answers are disabled.") + + question = union_request.message + + # Union the requested assistants' document sets (ACL-checked fetch; skip any the + # user can't access). The union persona is fence-less, so this set IS the scope. + union_set_names: set[str] = set() + resolved = 0 + for pid in union_request.persona_ids: + try: + alt = get_persona_by_id( + pid, user=user, db_session=db_session, is_for_edit=False + ) + except Exception: + continue + resolved += 1 + union_set_names.update(ds.name for ds in alt.document_sets) + if resolved == 0: + return AutoSearchUnionResponse(error_msg="No accessible assistants to compare.") + + return _compare_answer( + question, + BaseFilters(document_set=sorted(union_set_names) or None), + user, + db_session, + ) + + +@basic_router.post("/auto-search/sources") +def auto_search_sources( + sources_request: AutoSearchSourcesRequest, + request: Request, + user: User = Depends(current_user), + db_session: Session = Depends(get_session), + _rate_limit: None = Depends(check_message_request_rate_limit), + _: None = Depends(check_token_rate_limits), +) -> AutoSearchUnionResponse: + """Third (compare) answer for the Search tab: answer scoped to a FIXED set of + SOURCE TYPES (default HighSpot + the docs.uipath.com web crawls), configured via + AUTO_SEARCH_SOURCE_TAB_SOURCES. Lazy-fetched by the UI alongside the union tab; + uses the compare model. Sources are server-side (not client-controlled).""" + settings = load_settings() + if not _auto_search_allowed(settings.auto_search_rollout, user): + raise HTTPException( + status_code=403, detail="Auto-search is not enabled for your account." + ) + if not (settings.auto_search_compare_enabled and AUTO_SEARCH_SOURCE_TAB_ENABLED): + raise HTTPException( + status_code=403, detail="The source compare tab is disabled." + ) + + try: + source_types = [DocumentSource(s) for s in AUTO_SEARCH_SOURCE_TAB_SOURCES] + except ValueError as e: + logger.error("Invalid AUTO_SEARCH_SOURCE_TAB_SOURCES: %s", e) + return AutoSearchUnionResponse(error_msg="Source compare tab is misconfigured.") + + return _compare_answer( + sources_request.message, + BaseFilters(source_type=source_types or None), + user, + db_session, + ) diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py index 86a0a4607c1..31e62cdf2f9 100644 --- a/backend/danswer/server/settings/models.py +++ b/backend/danswer/server/settings/models.py @@ -8,15 +8,57 @@ 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 + # Semantic intent pre-route (the LLM phrase router between keyword and the LLM + # instruction router). Default False — OFF — so it stays dark until an admin + # enables it in Admin → Settings; keyword + instruction routing are unaffected. + auto_search_intent_enabled: bool = False + # Side-by-side compare on the Search tab: alongside the single top-1 answer, + # also answer over the UNION of the router's top-N assistants' document sets + # (answered by AUTO_SEARCH_UNION_LLM_*, default Sonnet). Only runs on LLM-router + # picks (keyword routes / @mentions stay single-scope). Default True — ON — for + # now (the comparison/testing phase); an admin flips it in Admin → Settings. + # NOTE: adds a second full search+answer pass per question, so it roughly + # doubles latency and cost while on. + auto_search_compare_enabled: bool = True # 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. default_page: PageType = PageType.CHAT + # Show the "Create assistant" entry points to end users: the Create buttons on + # the My Assistants + Assistant Gallery pages and the "Create a new assistant" + # row in the chat @-mention menu. Default False so creation ships hidden on + # deploy (a newly-added Settings field is absent from the already-persisted + # settings dict, so it takes this default); an admin flips it in Admin → + # Settings. Gates UI only; the /assistants/new route itself still exists. + enable_assistant_creation: bool = False maximum_chat_retention_days: int | None = None # Env-driven (CHAT_FILE_MAX_SIZE_MB), injected in load_settings — surfaced # here so the chat UI pre-checks against the SAME value the backend enforces diff --git a/backend/danswer/tools/built_in_tools.py b/backend/danswer/tools/built_in_tools.py index 94ffb085748..238a97573e9 100644 --- a/backend/danswer/tools/built_in_tools.py +++ b/backend/danswer/tools/built_in_tools.py @@ -1,14 +1,15 @@ from typing import Type from typing import TypedDict +from sqlalchemy import delete from sqlalchemy import not_ from sqlalchemy import or_ from sqlalchemy import select from sqlalchemy.orm import Session from danswer.db.models import Persona +from danswer.db.models import Persona__Tool from danswer.db.models import Tool as ToolDBModel -from danswer.tools.images.image_generation_tool import ImageGenerationTool from danswer.tools.search.search_tool import SearchTool from danswer.tools.tool import Tool from danswer.utils.logger import setup_logger @@ -22,20 +23,17 @@ class InCodeToolInfo(TypedDict): in_code_tool_id: str +# ImageGenerationTool was removed: this gateway-based deployment has no OpenAI +# provider key, so it could never run — and load_builtin_tools() below deletes any +# in-code tool no longer listed here, so on startup the stale `tool` row is +# cleaned up automatically. (The class still exists for the guarded branch in +# process_message.py, which simply never fires once no persona references it.) BUILT_IN_TOOLS: list[InCodeToolInfo] = [ { "cls": SearchTool, "description": "The Search Tool allows the Assistant to search through connected knowledge to help build an answer.", "in_code_tool_id": SearchTool.__name__, }, - { - "cls": ImageGenerationTool, - "description": ( - "The Image Generation Tool allows the assistant to use DALL-E 3 to generate images. " - "The tool will be used when the user asks the assistant to generate an image." - ), - "in_code_tool_id": ImageGenerationTool.__name__, - }, ] @@ -66,10 +64,16 @@ def load_builtin_tools(db_session: Session) -> None: db_session.add(new_tool) logger.info(f"Added new tool: {tool_name}") - # Remove tools that are no longer in BUILT_IN_TOOLS + # Remove tools that are no longer in BUILT_IN_TOOLS. Detach any persona links + # first so a still-referenced tool (e.g. a persona that used to have Image + # Generation) can't FK-violate and crash startup — the tool just quietly + # disappears from those personas. built_in_ids = {tool_info["in_code_tool_id"] for tool_info in BUILT_IN_TOOLS} for tool_id, tool in list(in_code_tool_id_to_tool.items()): if tool_id not in built_in_ids: + db_session.execute( + delete(Persona__Tool).where(Persona__Tool.tool_id == tool.id) + ) db_session.delete(tool) logger.info(f"Removed tool no longer in built-in list: {tool.name}") 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/backend/pyproject.toml b/backend/pyproject.toml index a9cf3650e13..af8eac1857a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,6 +4,10 @@ mypy_path = "$MYPY_CONFIG_FILE_DIR" explicit_package_bases = true disallow_untyped_defs = true +[[tool.mypy.overrides]] +module = "google.*" +ignore_missing_imports = true + [tool.ruff] ignore = [] line-length = 130 diff --git a/backend/scripts/clone_prod_to_local.py b/backend/scripts/clone_prod_to_local.py new file mode 100644 index 00000000000..a0d301f58a5 --- /dev/null +++ b/backend/scripts/clone_prod_to_local.py @@ -0,0 +1,369 @@ +"""Clone a representative slice of PROD into a LOCAL dev environment. + +Copies, into a local setup that already runs the app + an empty Vespa + a +migrated Postgres: + + 1. Vespa: the latest N documents (default 500) PER SOURCE — all their chunks, + including embeddings, ACLs and document-set membership. + 2. Postgres: assistants (personas) + their prompts + document sets + the + persona<->prompt and persona<->document_set associations. + +so local retrieval + the assistant router + answering closely resemble prod and +you can test before pushing. + +============================================================================== +HOW TO RUN (two phases, connected by a bundle directory) +============================================================================== +Phase 1 — EXPORT (run INSIDE a prod pod, which can reach prod Vespa + prod DB): + + POD=$(kubectl get pods -n darwin -l app=api-server \ + --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') + kubectl cp backend/scripts/clone_prod_to_local.py darwin/$POD:/tmp/clone.py + kubectl exec -n darwin $POD -- python /tmp/clone.py export --out /tmp/clone_bundle --per-source 500 + kubectl cp darwin/$POD:/tmp/clone_bundle ./clone_bundle + +Phase 2 — IMPORT (run LOCALLY, with your LOCAL env: VESPA_HOST=localhost, +local POSTGRES_*; from the backend/ dir so `danswer` imports): + + cd backend + PYTHONPATH=. python scripts/clone_prod_to_local.py import --in ../clone_bundle --make-public + +============================================================================== +IMPORTANT CONSTRAINTS / CAVEATS +============================================================================== +* EMBEDDING MODEL must match. The Vespa index name is model-specific + (danswer_chunk_). Export records the source index name; import refuses + if the local index name differs (different model => copied vectors are + meaningless). Prod = intfloat/e5-base-v2; this fork's image pre-bakes it, so a + default local setup matches. +* ACLs: chunks keep their prod access_control_list. Local users won't match + private (user/group) ACLs, so those docs won't surface locally. Pass + --make-public on import to rewrite every chunk's ACL to PUBLIC — LOCAL DEV + ONLY; never point import at a shared/prod Vespa with that flag. +* Connectors/credentials are NOT cloned. Doc-set scoping at query time uses the + doc-set NAME (the chunks already carry their document_sets membership), so a + bare document_set row (no connectors) is enough for search filtering. The + admin "Document Sets" page will show 0 connectors for them — expected. +* Personas are imported with user_id=NULL and is_public=true so your local admin + sees them; persona<->user / persona<->user_group grants are NOT copied. +* Re-runnable: DB rows upsert by primary key; Vespa chunks PUT (idempotent). +""" +import argparse +import gzip +import json +import sys +import urllib.parse +from collections.abc import Iterator +from pathlib import Path + +import httpx +from sqlalchemy import MetaData +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from danswer.configs.app_configs import VESPA_FEED_HOST +from danswer.configs.app_configs import VESPA_FEED_PORT +from danswer.configs.app_configs import VESPA_HOST +from danswer.configs.app_configs import VESPA_PORT +from danswer.db.embedding_model import get_current_db_embedding_model +from danswer.db.engine import get_sqlalchemy_engine + +# Same container URLs the app builds (search -> query container, document/visit +# -> feed container). +VESPA_APP_CONTAINER_URL = f"http://{VESPA_HOST}:{VESPA_PORT}" +VESPA_FEED_CONTAINER_URL = f"http://{VESPA_FEED_HOST}:{VESPA_FEED_PORT}" + + +# Lowercase source_type values as stored in Vespa (confirmed against prod). +DEFAULT_SOURCES = [ + "confluence", + "slack", + "web", + "salesforce", + "jira", + "outsystems", + "sfkbarticles", + "highspot", + "github_files", + "file", +] + +# DB tables cloned, in FK-safe insert order. (joins last.) +DB_TABLES = [ + "prompt", + "document_set", + "persona", + "persona__prompt", + "persona__document_set", +] + +DOC_ID_BATCH = 15 # doc_ids per Vespa visit selection +HTTP_TIMEOUT = 60.0 + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def _index_name() -> str: + with Session(get_sqlalchemy_engine()) as db: + return get_current_db_embedding_model(db).index_name + + +def _esc(s: str) -> str: + """Escape a string for a Vespa selection single-quoted literal (the format + the app uses in document_index/vespa/index.py).""" + return s.replace("\\", "\\\\").replace("'", "\\'") + + +def _docid_from_vespa_id(vespa_id: str) -> str: + # "id:default:::" -> "" + return vespa_id.split("::", 1)[1] + + +# --------------------------------------------------------------------------- # +# EXPORT +# --------------------------------------------------------------------------- # +def _latest_doc_ids(client: httpx.Client, index: str, source: str, n: int) -> list[str]: + """The N most-recently-updated distinct document_ids for a source.""" + yql = ( + f"select * from {index} where source_type contains @src | " + f"all(group(document_id) max({n}) order(-max(doc_updated_at)) each())" + ) + resp = client.post( + f"{VESPA_APP_CONTAINER_URL}/search/", + json={"yql": yql, "src": source, "hits": 0, "timeout": "30s"}, + ) + resp.raise_for_status() + doc_ids: list[str] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + # group leaf: {"value": "", "fields": {...}} + if ( + "value" in node + and isinstance(node["value"], str) + and "children" not in node + ): + doc_ids.append(node["value"]) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for x in node: + walk(x) + + # only walk into the grouping subtree to avoid catching unrelated "value"s + root = resp.json().get("root", {}) + walk(root.get("children", [])) + # de-dup, preserve order + seen: set[str] = set() + ordered = [d for d in doc_ids if not (d in seen or seen.add(d))] + return ordered[:n] + + +def _iter_chunks_for_docs( + client: httpx.Client, index: str, doc_ids: list[str] +) -> "Iterator[dict]": + """Yield chunks for the given document_ids via the Vespa visit API. + + Streams (yields one chunk at a time) rather than accumulating — a source's + 500 docs can be many chunks each carrying a 768-float embedding, so buffering + them all OOM-kills the exporter in a memory-limited pod. + """ + url = f"{VESPA_FEED_CONTAINER_URL}/document/v1/default/{index}/docid" + for i in range(0, len(doc_ids), DOC_ID_BATCH): + batch = doc_ids[i : i + DOC_ID_BATCH] + selection = " or ".join(f"{index}.document_id=='{_esc(d)}'" for d in batch) + cont: str | None = None + while True: + params = {"selection": selection, "wantedDocumentCount": 1000} + if cont: + params["continuation"] = cont + r = client.get(url, params=params) + r.raise_for_status() + data = r.json() + for doc in data.get("documents", []): + yield {"id": doc["id"], "fields": doc["fields"]} + cont = data.get("continuation") + if not cont: + break + + +def export(args: argparse.Namespace) -> None: + out_dir = Path(args.out) + (out_dir / "vespa").mkdir(parents=True, exist_ok=True) + index = _index_name() + sources = args.sources or DEFAULT_SOURCES + print( + f"[export] index_name={index} sources={sources} per_source={args.per_source}" + ) + + meta = {"index_name": index, "per_source": args.per_source, "sources": sources} + (out_dir / "meta.json").write_text(json.dumps(meta, indent=2)) + + if not args.db_only: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + for src in sources: + doc_ids = _latest_doc_ids(client, index, src, args.per_source) + if not doc_ids: + print(f"[export] {src}: 0 documents", flush=True) + continue + # Stream straight to a gzip file: bounded memory + a much smaller + # bundle (embedding float-text compresses well). + path = out_dir / "vespa" / f"{src}.jsonl.gz" + n = 0 + with gzip.open(path, "wt") as f: + for c in _iter_chunks_for_docs(client, index, doc_ids): + f.write(json.dumps(c) + "\n") + n += 1 + print( + f"[export] {src}: {len(doc_ids)} docs, {n} chunks -> {path.name}", + flush=True, + ) + + if not args.vespa_only: + db_dump: dict[str, list[dict]] = {} + with Session(get_sqlalchemy_engine()) as db: + for tbl in DB_TABLES: + rows = [ + dict(r._mapping) for r in db.execute(text(f"SELECT * FROM {tbl}")) + ] + db_dump[tbl] = rows + print(f"[export] db.{tbl}: {len(rows)} rows") + (out_dir / "db.json").write_text(json.dumps(db_dump, indent=2, default=str)) + print(f"[export] done -> {out_dir}") + + +# --------------------------------------------------------------------------- # +# IMPORT +# --------------------------------------------------------------------------- # +def _import_vespa(out_dir: Path, local_index: str, make_public: bool) -> None: + vespa_dir = out_dir / "vespa" + files = sorted(vespa_dir.glob("*.jsonl.gz")) + sorted(vespa_dir.glob("*.jsonl")) + if not files: + print("[import] no vespa/*.jsonl(.gz) found, skipping Vespa") + return + total = 0 + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + for path in files: + n = 0 + opener = gzip.open if path.suffix == ".gz" else open + with opener(path, "rt") as fh: + lines = fh.readlines() + for line in lines: + if not line.strip(): + continue + rec = json.loads(line) + fields = rec["fields"] + if make_public: + fields["access_control_list"] = {"PUBLIC": 1} + docid = _docid_from_vespa_id(rec["id"]) + quoted = urllib.parse.quote(docid, safe="") + url = ( + f"{VESPA_FEED_CONTAINER_URL}/document/v1/default/" + f"{local_index}/docid/{quoted}" + ) + r = client.post(url, json={"fields": fields}) + r.raise_for_status() + n += 1 + total += n + print(f"[import] {path.stem}: fed {n} chunks") + print(f"[import] vespa: {total} chunks total") + + +def _import_db(out_dir: Path) -> None: + db_path = out_dir / "db.json" + if not db_path.exists(): + print("[import] no db.json found, skipping DB") + return + dump = json.loads(db_path.read_text()) + engine = get_sqlalchemy_engine() + md = MetaData() + md.reflect(bind=engine, only=DB_TABLES) + + # Per-table overrides so prod rows are usable locally (no prod users/grants). + overrides = { + "persona": {"user_id": None, "is_public": True}, + "document_set": {"user_id": None, "is_public": True, "is_up_to_date": True}, + "prompt": {"user_id": None}, + } + + with engine.begin() as conn: + for tbl in DB_TABLES: # FK-safe order + rows = dump.get(tbl, []) + table = md.tables[tbl] + colnames = {c.name for c in table.columns} + pk = [c.name for c in table.primary_key] + for row in rows: + row = {k: v for k, v in row.items() if k in colnames} + row.update(overrides.get(tbl, {})) + stmt = pg_insert(table).values(**row) + update_cols = {c: stmt.excluded[c] for c in row if c not in pk} + if update_cols: + stmt = stmt.on_conflict_do_update( + index_elements=pk, set_=update_cols + ) + else: + stmt = stmt.on_conflict_do_nothing(index_elements=pk) + conn.execute(stmt) + print(f"[import] db.{tbl}: upserted {len(rows)} rows") + # keep the id sequence ahead of the explicit ids we just inserted + if "id" in colnames: + conn.execute( + text( + f"SELECT setval(pg_get_serial_sequence('{tbl}', 'id'), " + f"GREATEST((SELECT COALESCE(MAX(id), 1) FROM {tbl}), 1))" + ) + ) + + +def import_(args: argparse.Namespace) -> None: + out_dir = Path(args.in_dir) + meta = json.loads((out_dir / "meta.json").read_text()) + src_index = meta["index_name"] + local_index = _index_name() + if src_index != local_index: + sys.exit( + f"[import] ABORT: embedding-model/index mismatch.\n" + f" exported from: {src_index}\n local: {local_index}\n" + f" The local embedding model must match prod (intfloat/e5-base-v2) " + f"or copied vectors are meaningless." + ) + print(f"[import] index_name={local_index} make_public={args.make_public}") + if not args.db_only: + _import_vespa(out_dir, local_index, args.make_public) + if not args.vespa_only: + _import_db(out_dir) + print("[import] done. Restart the local API server if it was already running.") + + +# --------------------------------------------------------------------------- # +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + pe = sub.add_parser("export", help="export from PROD (run inside a prod pod)") + pe.add_argument("--out", required=True, help="bundle output directory") + pe.add_argument("--per-source", type=int, default=500) + pe.add_argument("--sources", nargs="*", help=f"defaults to: {DEFAULT_SOURCES}") + pe.add_argument("--vespa-only", action="store_true") + pe.add_argument("--db-only", action="store_true") + pe.set_defaults(func=export) + + pi = sub.add_parser("import", help="import into LOCAL (run locally)") + pi.add_argument("--in", dest="in_dir", required=True, help="bundle directory") + pi.add_argument( + "--make-public", + action="store_true", + help="rewrite every chunk ACL to PUBLIC (LOCAL DEV ONLY)", + ) + pi.add_argument("--vespa-only", action="store_true") + pi.add_argument("--db-only", action="store_true") + pi.set_defaults(func=import_) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() 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..9087ee081ce --- /dev/null +++ b/backend/tests/unit/danswer/secondary_llm_flows/test_assistant_router.py @@ -0,0 +1,181 @@ +"""Unit tests for the assistant router's pure logic. + +The router pipeline is: `keyword_route` (deterministic, here) -> kNN-over-Slack +fallback (see test_slack_knn_router). `build_router_catalog` includes EVERY +ACL-filtered persona (its id fences the kNN router) and parses each one's +comma-separated `routing_keywords`. +""" +from types import SimpleNamespace + +from danswer.secondary_llm_flows.assistant_router import build_router_catalog +from danswer.secondary_llm_flows.assistant_router import keyword_route +from danswer.secondary_llm_flows.assistant_router import RouterCatalogEntry + + +def _persona(persona_id: int, keywords: str | None = None): + return SimpleNamespace( + id=persona_id, + name=f"persona-{persona_id}", + routing_keywords=keywords, + ) + + +# --- build_router_catalog --------------------------------------------------- + + +def test_catalog_includes_every_persona_even_without_keywords() -> None: + # The kNN router routes by id; an assistant needs no keywords to be routable, + # so NONE are skipped (unlike the old routing_instructions-gated catalog). + catalog = build_router_catalog([_persona(1), _persona(2, keywords="foo")]) + assert [e.persona_id for e in catalog] == [1, 2] + assert catalog[0].keywords == [] + + +def test_build_catalog_parses_keywords() -> None: + cat = build_router_catalog([_persona(1, keywords="Foo, Bar Baz , ")]) + assert cat[0].keywords == ["foo", "bar baz"] # trimmed + lowercased, blanks dropped + + +# --- keyword_route (deterministic pre-route) -------------------------------- + + +def _entry(pid, name, keywords): + return RouterCatalogEntry(persona_id=pid, name=name, keywords=keywords) + + +_KW_CATALOG = [ + _entry( + 1, + "AutomationSuite", + ["automation suite", "as environment", "aks deployment", "eks deployment"], + ), + _entry(2, "Orchestrator", ["orchestrator"]), + _entry(3, "NoKeywords", []), +] + + +def test_keyword_route_matches_case_insensitively() -> None: + assert ( + keyword_route("AUTOMATION SUITE install on openshift", _KW_CATALOG).persona_id + == 1 + ) + assert ( + keyword_route( + "migration to Unified on their AS Environment", _KW_CATALOG + ).persona_id + == 1 + ) + assert keyword_route("is AKS deployment supported", _KW_CATALOG).persona_id == 1 + + +def test_keyword_route_none_when_no_match() -> None: + # falls through to the kNN router (purely additive) + assert keyword_route("what is the weather today", _KW_CATALOG) is None + assert keyword_route("", _KW_CATALOG) is None + + +def test_keyword_route_longest_match_wins() -> None: + cat = [ + _entry(1, "AS", ["as"]), + _entry(2, "AutomationSuite", ["automation suite"]), + ] + # "automation suite" (longer) beats the substring "as" + assert keyword_route("automation suite sizing", cat).persona_id == 2 + + +# --- fuzzy keyword matching (all words present, any order/position) ----------- + +_FUZZY = [_entry(1, "AC", ["task sla", "round robin", "form task", "sla expir"])] + + +def test_keyword_route_all_words_anywhere_non_adjacent() -> None: + # "task sla" -> both words present, not adjacent, reversed order + assert keyword_route("the SLA on that task was breached", _FUZZY).persona_id == 1 + + +def test_keyword_route_requires_all_words() -> None: + # only "task" present, "sla" missing -> no hit + assert keyword_route("the task failed to complete", _FUZZY) is None + + +def test_keyword_route_prefix_matches_stem() -> None: + # "sla expir" -> "expir" (>=4) prefixes "expired" + assert keyword_route("our sla expired yesterday", _FUZZY).persona_id == 1 + + +def test_keyword_route_prefix_is_start_anchored_not_substring() -> None: + # "form task": "form" must be a word or prefix — NOT a substring of "perform" + assert keyword_route("perform this task now", _FUZZY) is None + assert keyword_route("fill the form for this task", _FUZZY).persona_id == 1 + + +def test_keyword_route_hyphen_is_tokenized() -> None: + # "round robin" keyword hits a hyphenated "round-robin" in the question + assert keyword_route("assign it round-robin to the team", _FUZZY).persona_id == 1 + + +# quoted keyword -> exact contiguous phrase (for abbreviations that are also +# common words, e.g. AS = Automation Suite) +_QUOTED = [_entry(1, "AS", ['"as environment"'])] + + +def test_keyword_route_quoted_requires_contiguous_phrase() -> None: + # fires on the adjacent phrase... + assert ( + keyword_route("migrating their AS Environment to unified", _QUOTED).persona_id + == 1 + ) + # ...but NOT when "as" and "environment" are merely both present, scattered + assert keyword_route("as a user, how do I set up the environment?", _QUOTED) is None + + +def test_keyword_route_quoted_and_fuzzy_coexist() -> None: + cat = [_entry(1, "AS", ['"as environment"']), _entry(2, "AC", ["task sla"])] + assert keyword_route("issue with the AS Environment install", cat).persona_id == 1 + assert keyword_route("the sla on this task", cat).persona_id == 2 + + +# quality-aware ranking: contiguous/exact match beats scattered/prefix match +_AS_IS = [ + _entry(1, "AutomationSuite", ["automation suite"]), + _entry(2, "IntegrationService", ["integration service"]), +] +# both fuzzy-match this AutomationSuite question: "automation suite" is verbatim, +# "integration service" only via the incidental plurals integration(s)/service(s) +_ARCH_Q = "architecture diagram for the automation suite release with integrations and different services" + + +def test_keyword_route_contiguous_exact_beats_scattered_prefix() -> None: + # AutomationSuite wins: its words are adjacent + exact; IntegrationService's + # matched only scattered via prefix. (Char length would have picked IS before.) + assert keyword_route(_ARCH_Q, _AS_IS).persona_id == 1 + + +def test_keyword_route_priority_always_wins() -> None: + # Tag IntegrationService's keyword as always-wins ("!") -> it beats the + # stronger AutomationSuite match. + cat = [ + _entry(1, "AutomationSuite", ["automation suite"]), + _entry(2, "IntegrationService", ["!integration service"]), + ] + assert keyword_route(_ARCH_Q, cat).persona_id == 2 + + +def test_keyword_route_priority_beats_across_the_board() -> None: + # A priority keyword outranks a non-priority one even if the latter is more + # specific (more words / longer). + cat = [ + _entry(1, "A", ["!coupa"]), + _entry(2, "B", ["procure to pay solution"]), + ] + assert ( + keyword_route("coupa invoice in the procure to pay solution", cat).persona_id + == 1 + ) + + +def test_keyword_route_priority_exact_combo() -> None: + cat = [_entry(1, "AC", ['!"validation station"'])] + assert keyword_route("stuck in the validation station today", cat).persona_id == 1 + # quoted still requires the contiguous phrase even with priority + assert keyword_route("validation of the station data", cat) is None diff --git a/backend/tests/unit/danswer/secondary_llm_flows/test_slack_knn_router.py b/backend/tests/unit/danswer/secondary_llm_flows/test_slack_knn_router.py new file mode 100644 index 00000000000..419e9daef20 --- /dev/null +++ b/backend/tests/unit/danswer/secondary_llm_flows/test_slack_knn_router.py @@ -0,0 +1,148 @@ +"""Unit tests for the kNN-over-Slack router's pure logic (vote / gate / tiebreak / +ACL intersection). The Vespa + embedding I/O (`retrieve_slack_neighbors`) is not +exercised here — `knn_route` takes already-retrieved neighbors so the decision +logic is testable without a live index.""" +from danswer.secondary_llm_flows.assistant_router import RouterCatalogEntry +from danswer.secondary_llm_flows.slack_knn_router import _channel_of +from danswer.secondary_llm_flows.slack_knn_router import knn_route +from danswer.secondary_llm_flows.slack_knn_router import SlackNeighbor + + +class _FakeLLM: + """Returns a fixed string; records whether it was invoked.""" + + def __init__(self, out: str) -> None: + self.out = out + self.called = False + + def invoke(self, prompt, tools=None, tool_choice=None): + self.called = True + + class _Msg: + content = self.out + + return _Msg() + + +class _BoomLLM: + def invoke(self, prompt, tools=None, tool_choice=None): + raise RuntimeError("llm down") + + +_CATALOG = [ + RouterCatalogEntry(persona_id=1, name="Orchestrator", keywords=[]), + RouterCatalogEntry(persona_id=2, name="IntegrationService", keywords=[]), +] + + +def _n(channel, score, content="q"): + return SlackNeighbor(channel=channel, score=score, content=content) + + +CH_MAP = {"help-orchestrator": 1, "help-integration-service": 2, "help-ownership": 99} + + +# --- _channel_of ------------------------------------------------------------ + + +def test_channel_of_parses_json_string_and_dict() -> None: + assert _channel_of({"metadata": '{"Channel": "help-x"}'}) == "help-x" + assert _channel_of({"metadata": {"Channel": "help-y"}}) == "help-y" + assert _channel_of({"metadata": "not json"}) is None + assert _channel_of({}) is None + + +# --- knn_route: vote -------------------------------------------------------- + + +def test_knn_route_confident_vote_no_llm() -> None: + llm = _FakeLLM("Orchestrator") + neighbors = [ + _n("help-orchestrator", 0.9), + _n("help-orchestrator", 0.8), + _n("help-integration-service", 0.2), + ] + res = knn_route("q", neighbors, CH_MAP, _CATALOG, llm) + assert res.persona_id == 1 + assert res.confidence > 0.6 + assert not llm.called # high confidence -> no tiebreak + assert res.ranked_ids[0] == 1 + assert res.ambiguous is False # confident -> no compare + + +def test_knn_route_no_votes_fails_open() -> None: + # neighbors map to no persona in the catalog -> None (caller uses default) + res = knn_route("q", [_n("help-unknown", 0.9)], CH_MAP, _CATALOG, _FakeLLM("x")) + assert res.persona_id is None + + +def test_knn_route_ignores_personas_outside_catalog() -> None: + # help-ownership -> persona 99, which is NOT in the ACL catalog -> not counted + neighbors = [_n("help-ownership", 0.9), _n("help-orchestrator", 0.5)] + res = knn_route("q", neighbors, CH_MAP, _CATALOG, _FakeLLM("x")) + assert res.persona_id == 1 # only the in-catalog vote survives + + +# --- knn_route: LLM tiebreak on low confidence ------------------------------ + + +def test_knn_route_low_conf_triggers_llm_tiebreak() -> None: + # near-even split -> confidence < 0.6 -> LLM picks. min_recommendation_votes=1 + # so the single-vote personas still populate ranked_ids for this assertion. + neighbors = [_n("help-orchestrator", 0.51), _n("help-integration-service", 0.49)] + llm = _FakeLLM("IntegrationService") + res = knn_route("q", neighbors, CH_MAP, _CATALOG, llm, min_recommendation_votes=1) + assert llm.called + assert res.persona_id == 2 # LLM's pick overrides the vote winner + assert res.ranked_ids[0] == 2 # override reflected as #1 recommendation + assert res.ambiguous is True # close call -> compare offered + + +def test_knn_route_min_votes_filters_single_vote_recommendations() -> None: + # Orchestrator gets 2 neighbor votes, IntegrationService 1. With the default + # threshold (2), only Orchestrator is a recommendation; the incidental 1-vote + # match is dropped (the RTO-query noise fix). + neighbors = [ + _n("help-orchestrator", 0.9), + _n("help-orchestrator", 0.85), + _n("help-integration-service", 0.8), + ] + res = knn_route("q", neighbors, CH_MAP, _CATALOG, _FakeLLM("x")) + assert res.persona_id == 1 + assert res.ranked_ids == [1] # IntegrationService (1 vote) excluded + + +def test_knn_route_min_votes_is_configurable() -> None: + neighbors = [_n("help-orchestrator", 0.9), _n("help-integration-service", 0.8)] + # threshold 1 -> both qualify as recommendations + r1 = knn_route( + "q", neighbors, CH_MAP, _CATALOG, _FakeLLM("x"), min_recommendation_votes=1 + ) + assert set(r1.ranked_ids) == {1, 2} + # threshold 2 -> neither 1-vote persona qualifies; empty recs, but still answers + r2 = knn_route( + "q", neighbors, CH_MAP, _CATALOG, _FakeLLM("x"), min_recommendation_votes=2 + ) + assert r2.ranked_ids == [] + assert r2.persona_id in (1, 2) + + +def test_knn_route_tiebreak_falls_back_to_vote_on_bad_llm_output() -> None: + neighbors = [_n("help-orchestrator", 0.51), _n("help-integration-service", 0.49)] + llm = _FakeLLM("some unrelated text") # matches no candidate name + res = knn_route("q", neighbors, CH_MAP, _CATALOG, llm) + assert res.persona_id == 1 # falls back to the vote winner + + +def test_knn_route_tiebreak_fails_open_on_llm_error() -> None: + neighbors = [_n("help-orchestrator", 0.51), _n("help-integration-service", 0.49)] + res = knn_route("q", neighbors, CH_MAP, _CATALOG, _BoomLLM()) + assert res.persona_id == 1 # vote winner survives an LLM exception + + +def test_knn_route_single_candidate_skips_llm_even_if_low_conf() -> None: + # only one distinct persona voted -> nothing to break, no LLM call + llm = _FakeLLM("IntegrationService") + res = knn_route("q", [_n("help-orchestrator", 0.05)], CH_MAP, _CATALOG, llm) + assert res.persona_id == 1 + assert not llm.called diff --git a/backend/tests/unit/danswer/server/query_and_chat/__init__.py b/backend/tests/unit/danswer/server/query_and_chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/backend/tests/unit/danswer/server/query_and_chat/test_auto_search_gate.py b/backend/tests/unit/danswer/server/query_and_chat/test_auto_search_gate.py new file mode 100644 index 00000000000..90ce0747e56 --- /dev/null +++ b/backend/tests/unit/danswer/server/query_and_chat/test_auto_search_gate.py @@ -0,0 +1,45 @@ +"""Unit tests for the auto-search rollout gate (_auto_search_allowed). + +This is the security boundary for the admin-only / staged rollout of the +auto-routed Search tab — the /auto-search endpoint relies on it to 403 +non-admins, independent of the UI hiding the tab. Parity with the rollout +states matters, so it's locked down here. +""" +from types import SimpleNamespace + +from danswer.auth.schemas import UserRole +from danswer.server.query_and_chat.query_backend import _auto_search_allowed +from danswer.server.settings.models import AutoSearchRollout + + +_ADMIN = SimpleNamespace(role=UserRole.ADMIN) +_BASIC = SimpleNamespace(role=UserRole.BASIC) + + +def test_off_blocks_everyone() -> None: + assert _auto_search_allowed(AutoSearchRollout.OFF, _ADMIN) is False + assert _auto_search_allowed(AutoSearchRollout.OFF, _BASIC) is False + assert _auto_search_allowed(AutoSearchRollout.OFF, None) is False + + +def test_everyone_allows_all() -> None: + assert _auto_search_allowed(AutoSearchRollout.EVERYONE, _ADMIN) is True + assert _auto_search_allowed(AutoSearchRollout.EVERYONE, _BASIC) is True + assert _auto_search_allowed(AutoSearchRollout.EVERYONE, None) is True + + +def test_admin_only_allows_admins_and_noauth_blocks_basic() -> None: + # admin yes; basic NO (the gate that protects the staged rollout); the + # no-auth/superuser context (user is None) is treated as admin, matching + # get_personas' convention so a no-auth deployment isn't locked out. + assert _auto_search_allowed(AutoSearchRollout.ADMIN_ONLY, _ADMIN) is True + assert _auto_search_allowed(AutoSearchRollout.ADMIN_ONLY, _BASIC) is False + assert _auto_search_allowed(AutoSearchRollout.ADMIN_ONLY, None) is True + + +def test_default_rollout_is_admin_only() -> None: + # A fresh deploy must land admin-only with no manual seeding — this is what + # makes "ship to prod, fix metadata, then flip to everyone" safe. + from danswer.server.settings.models import Settings + + assert Settings().auto_search_rollout == AutoSearchRollout.ADMIN_ONLY diff --git a/docs/clone-prod-to-local.md b/docs/clone-prod-to-local.md new file mode 100644 index 00000000000..7d1a56f94b5 --- /dev/null +++ b/docs/clone-prod-to-local.md @@ -0,0 +1,93 @@ +# Clone prod into a local dev environment + +`backend/scripts/clone_prod_to_local.py` populates a **local** dev setup with a +representative slice of **prod**, so you can test search, assistants, and the +auto-routed Search tab against realistic data before pushing changes. + +It copies: + +1. **Vespa** — the latest *N* documents (default 500) **per source**, including + all their chunks with embeddings, ACLs, and document-set membership. +2. **Postgres** — assistants (`persona`), their `prompt`s and `document_set`s, + and the `persona__prompt` / `persona__document_set` associations. + +After a run, local retrieval + the assistant router + answering closely resemble +prod. + +## Prerequisites + +- Local app running with an **empty-ish Vespa** and a **migrated Postgres** + (`alembic upgrade head`). +- `kubectl` access to the prod `darwin` namespace (the export runs inside a prod + pod, which can reach prod Vespa + the prod DB). +- **The same embedding model locally as prod** (`intfloat/e5-base-v2`). The Vespa + index name is model-specific (`danswer_chunk_`); the script records the + source index name on export and **aborts on import if the local index name + differs** — copied vectors are meaningless to a different model. This fork's + image pre-bakes `e5-base-v2`, so a default local setup matches. + +## Usage + +### Phase 1 — export (from prod) + +```bash +POD=$(kubectl get pods -n darwin -l app=api-server \ + --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') + +kubectl cp backend/scripts/clone_prod_to_local.py darwin/$POD:/tmp/clone.py +kubectl exec -n darwin $POD -- python /tmp/clone.py export --out /tmp/clone_bundle --per-source 500 +kubectl cp darwin/$POD:/tmp/clone_bundle ./clone_bundle +``` + +The bundle is `meta.json` + `db.json` + `vespa/.jsonl` (one chunk per +line). + +### Phase 2 — import (locally) + +Run from `backend/` with your **local** env. A single-node local Vespa serves the +document API on the same port as the query API, so point both at it (commonly +`8081`): + +```bash +cd backend +PYTHONPATH=. \ + VESPA_HOST=localhost VESPA_PORT=8081 \ + VESPA_FEED_HOST=localhost VESPA_FEED_PORT=8081 \ + python scripts/clone_prod_to_local.py import --in ../clone_bundle --make-public +``` + +Restart the local API server afterward if it was already running. + +## Flags + +| Flag | Phase | Effect | +|---|---|---| +| `--per-source N` | export | docs per source (default 500), latest by `doc_updated_at` | +| `--sources a b c` | export | limit to specific sources (default: all) | +| `--vespa-only` / `--db-only` | both | copy only one half | +| `--make-public` | import | rewrite every chunk's ACL to `PUBLIC` so local users see everything — **local dev only** | + +Sources (lowercase, as stored in Vespa): `confluence`, `slack`, `web`, +`salesforce`, `jira`, `outsystems`, `sfkbarticles`, `highspot`, `github_files`, +`file`. + +## What it does NOT copy (and why that's fine) + +- **Connectors / credentials / cc-pairs.** Doc-set scoping at query time uses the + doc-set *name*, and the copied chunks already carry their `document_sets` + membership — so a bare `document_set` row is enough for search filtering. The + admin "Document Sets" page will show 0 connectors for imported sets; that's + expected. +- **Persona ↔ user / user-group grants.** Personas import with `user_id=NULL` and + `is_public=true` so your local admin sees them. + +## Caveats + +- **ACLs:** without `--make-public`, chunks keep their prod ACLs, so docs scoped + to specific prod users/groups won't surface for your local user. Use + `--make-public` for friction-free local testing. **Never** point an import with + `--make-public` at a shared/prod Vespa. +- **Re-runnable:** DB rows upsert by primary key; Vespa chunks are PUT + (idempotent). Safe to run repeatedly. +- **Cost/size:** chunky sources (e.g. Confluence) have many chunks per document, + so 500 docs can be a lot of chunks. Use `--per-source` / `--sources` to trim. diff --git a/docs/how-darwin-answers-questions.md b/docs/how-darwin-answers-questions.md index a54c125effa..91126ab9b80 100644 --- a/docs/how-darwin-answers-questions.md +++ b/docs/how-darwin-answers-questions.md @@ -19,6 +19,7 @@ enough specifics that engineers trust it. ## 1. The moving parts ```mermaid +%%{init: {'theme':'base','themeVariables':{'textColor':'#1e293b','lineColor':'#64748b','edgeLabelBackground':'#ffffff','clusterBorder':'#cbd5e1'}}}%% flowchart LR SL["💬 Slack"]:::surface WEB["🖥️ Web chat"]:::surface @@ -75,6 +76,7 @@ The same pipeline serves **both** Slack and web chat — they differ only in ent point and presentation, not in how retrieval/ranking work. ```mermaid +%%{init: {'theme':'base','themeVariables':{'textColor':'#1e293b','lineColor':'#64748b','actorBkg':'#eef2ff','actorTextColor':'#1e293b','actorBorder':'#6366f1','actorLineColor':'#94a3b8','signalColor':'#334155','signalTextColor':'#1e293b','noteBkgColor':'#fef9c3','noteTextColor':'#713f12','noteBorderColor':'#eab308','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#6366f1','labelTextColor':'#1e293b','sequenceNumberColor':'#ffffff','activationBkgColor':'#e0e7ff','activationBorderColor':'#6366f1'}}}%% sequenceDiagram autonumber actor U as 👤 User @@ -136,6 +138,7 @@ precision last: ``` ```mermaid +%%{init: {'theme':'base','themeVariables':{'textColor':'#1e293b','lineColor':'#64748b','edgeLabelBackground':'#ffffff','clusterBorder':'#cbd5e1'}}}%% flowchart TB CORP["📚 Entire knowledge base
(100k+ chunks)"]:::broad CORP --> S1["① Hybrid retrieval · Vespa
vector + keyword, recency-weighted"]:::retrieve @@ -173,6 +176,7 @@ Every candidate's score blends two signals, then is nudged by freshness and huma feedback: ```mermaid +%%{init: {'theme':'base','themeVariables':{'textColor':'#1e293b','lineColor':'#64748b','edgeLabelBackground':'#ffffff','clusterBorder':'#cbd5e1'}}}%% flowchart LR SEM["🧭 Semantic similarity
meaning match (vectors)"]:::sem KW["🔤 Keyword match
exact terms (BM25)"]:::kw @@ -238,6 +242,7 @@ behavior). | **Guardrails** | built in | — | ACL filtering · rate limiting · retry/backoff | ```mermaid +%%{init: {'theme':'base','themeVariables':{'textColor':'#1e293b','lineColor':'#64748b','edgeLabelBackground':'#ffffff','clusterBorder':'#cbd5e1'}}}%% flowchart LR QQ(["❓ Same question"]):::q QQ --> A1["🅰️ Assistant A
rerank OFF · broad scope"]:::dim @@ -264,6 +269,7 @@ A weekend RAG demo is: embed docs → nearest-neighbor → stuff prompt. Darwin the parts that decide whether answers are **trustworthy at scale**: ```mermaid +%%{init: {'theme':'base','themeVariables':{'textColor':'#1e293b','lineColor':'#64748b','edgeLabelBackground':'#ffffff','clusterBorder':'#cbd5e1'}}}%% flowchart TB subgraph TOY["🧪 Toy RAG"] direction TB diff --git a/k8s/base/web-server.yaml b/k8s/base/web-server.yaml index 42f6ea6ab44..de31e9efcff 100644 --- a/k8s/base/web-server.yaml +++ b/k8s/base/web-server.yaml @@ -38,6 +38,24 @@ spec: ports: - containerPort: 3000 protocol: TCP + # Zero-downtime rolling upgrade: the Service only sends traffic once the + # new pod actually serves, and (with maxUnavailable rounding to 0) the old + # pod is kept until the new one is Ready — so no request hits a booting pod. + readinessProbe: + httpGet: + path: / + port: 3000 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + # Grace for the Next.js server to come up before readiness is enforced. + startupProbe: + httpGet: + path: / + port: 3000 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 30 resources: requests: cpu: 500m diff --git a/k8s/overlays/prod/env.properties b/k8s/overlays/prod/env.properties index f48d1bdeb28..95e21ec5cc0 100644 --- a/k8s/overlays/prod/env.properties +++ b/k8s/overlays/prod/env.properties @@ -126,6 +126,22 @@ GEN_AI_ACCOUNT_ID=bc2ddac5-57bc-40e6-93fe-3b319b60ce36 GEN_AI_TENANT_ID=e367ca54-053b-4b86-89a2-6b9e89e85e7a GEN_AI_API_ENDPOINT=https://alpha.uipath.com/bc2ddac5-57bc-40e6-93fe-3b319b60ce36/e367ca54-053b-4b86-89a2-6b9e89e85e7a/llmgateway_/api/raw/vendor/openai/model/gpt-4.1-mini-2025-04-14/completions GEN_AI_IDENTITY_ENDPOINT=https://alpha.uipath.com/identity_/connect/token +# Assistant-router model (the one-shot Search tab's automatic assistant picker). +# Routes with Claude Sonnet 4.5 for sharper assistant selection instead of the +# default fast model. Both must be set to take effect; empty => default fast LLM. +# Verified working gateway id (EU/data-residency): awsbedrock / +# anthropic.claude-sonnet-4-5-20250929-v1:0. +ASSISTANT_ROUTER_LLM_VENDOR=awsbedrock +ASSISTANT_ROUTER_LLM_MODEL=anthropic.claude-sonnet-4-5-20250929-v1:0 +# Min neighbor votes for an assistant to appear as a "recommended assistant" (and +# in the compare union scope) on the auto-routed Search tab. Filters single, +# incidental kNN matches. Raise to be stricter (fewer recommendations), lower to 1 +# to show every matched assistant. Requires an api-server restart to take effect. +SLACK_KNN_ROUTER_MIN_RECOMMENDATION_VOTES=2 +# How many nearest Slack thread-start (chunk_id=0) neighbors to retrieve for the +# vote. Larger => more effective votes after discarding disabled/unmapped-channel +# neighbors (avoids a catch-all like Highspot crowding out the pool). Restart to apply. +SLACK_KNN_ROUTER_TOP_K=25 # --- Query options --- QA_TIMEOUT=60 diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml index c6f327e5f98..d04b06b4f26 100644 --- a/k8s/overlays/prod/kustomization.yaml +++ b/k8s/overlays/prod/kustomization.yaml @@ -30,10 +30,10 @@ namespace: darwin images: - name: danswer-backend newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-backend - newTag: vha-204 + newTag: vha-216 - name: danswer-web-server newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-web-server - newTag: vha-103 + newTag: vha-116 - name: danswer-model-server newName: danswer/danswer-model-server newTag: v0.3.94 diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 00000000000..0eb45c88291 Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 00000000000..b9880b41d90 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 00000000000..9b7ee1ec1f1 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/icon-maskable-512.png b/web/public/icons/icon-maskable-512.png new file mode 100644 index 00000000000..5049caae7db Binary files /dev/null and b/web/public/icons/icon-maskable-512.png differ diff --git a/web/src/app/admin/assistants/AssistantEditor.tsx b/web/src/app/admin/assistants/AssistantEditor.tsx index 3e1e2020336..10989205fc3 100644 --- a/web/src/app/admin/assistants/AssistantEditor.tsx +++ b/web/src/app/admin/assistants/AssistantEditor.tsx @@ -9,6 +9,7 @@ import { FieldArray, Form, Formik, + useField, } from "formik"; import * as Yup from "yup"; @@ -57,6 +58,144 @@ function SubLabel({ children }: { children: string | JSX.Element }) { return
{children}
; } +// Row editor for `routing_keywords`. Purely a visual layer over the single +// comma-separated Text column: an "Exact phrase" row round-trips as a "quoted" +// keyword, an "All words" row as an unquoted (fuzzy) keyword. +interface KeywordRow { + text: string; + exact: boolean; + priority: boolean; +} + +function parseKeywords(value: string): KeywordRow[] { + if (!value.trim()) { + return []; + } + return value.split(",").map((raw) => { + let t = raw.trim(); + let priority = false; + if (t.startsWith("!")) { + priority = true; + t = t.slice(1).trim(); + } + let exact = false; + if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) { + exact = true; + t = t.slice(1, -1).trim(); + } + return { text: t, exact, priority }; + }); +} + +function serializeKeywords(rows: KeywordRow[]): string { + return rows + .filter((r) => r.text.trim()) + .map((r) => { + let s = r.exact ? `"${r.text.trim()}"` : r.text.trim(); + if (r.priority) { + s = `!${s}`; + } + return s; + }) + .join(", "); +} + +function RoutingKeywordsField() { + const [field, , helpers] = useField("routing_keywords"); + const [rows, setRowsState] = useState(() => + parseKeywords(field.value || "") + ); + const setRows = (r: KeywordRow[]) => { + setRowsState(r); + helpers.setValue(serializeKeywords(r)); + }; + + return ( +
+ + + { + "Keywords that DEFINITELY route a question here (checked case-insensitively BEFORE the AI router). “All words” fires when every word of the keyword appears in the question, in any order (e.g. “task sla” matches “the SLA on this task”). “Exact phrase” requires the words adjacent — use it for an abbreviation that is also a common word, e.g. “as environment” (AS = Automation Suite). Tick “Always wins” to make this keyword beat any other assistant’s match when both fire (use only for unambiguous product terms)." + } + +
+ {rows.length === 0 && ( +

+ No keywords yet — add one below. +

+ )} + {rows.map((row, i) => ( +
+ + setRows( + rows.map((r, j) => + j === i ? { ...r, text: e.target.value } : r + ) + ) + } + placeholder="e.g. task sla" + className="flex-1 rounded-md border border-border px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-accent" + /> + + + +
+ ))} + +
+
+ ); +} + export function AssistantEditor({ existingPersona, ccPairs, @@ -173,6 +312,8 @@ export function AssistantEditor({ name: existingPersona?.name ?? "", display_name: existingPersona?.display_name ?? "", description: existingPersona?.description ?? "", + routing_keywords: existingPersona?.routing_keywords ?? "", + is_router_candidate: existingPersona?.is_router_candidate ?? true, system_prompt: existingPrompt?.system_prompt ?? "", task_prompt: existingPrompt?.task_prompt ?? "", is_public: existingPersona?.is_public ?? defaultPublic, @@ -469,6 +610,31 @@ export function AssistantEditor({ + + <> +

+ Controls how the auto-routed Search tab decides when to + send a question to this Assistant. Not shown to users. + Evaluated in order: keywords first, then a fallback that + matches the question to similar past questions from this + Assistant's Slack help channels. +

+ + + + + +
+ + + <> {ccPairs.length > 0 && searchTool && ( @@ -518,7 +684,9 @@ export function AssistantEditor({ render={(arrayHelpers: ArrayHelpers) => { const selectedDocumentSets = documentSets.filter((ds) => - values.document_set_ids.includes(ds.id) + values.document_set_ids.includes( + ds.id + ) ); const availableDocumentSets = documentSets.filter( @@ -547,7 +715,9 @@ export function AssistantEditor({ documentSet.id ); if (ind !== -1) { - arrayHelpers.remove(ind); + arrayHelpers.remove( + ind + ); } }} /> diff --git a/web/src/app/admin/assistants/PersonaTable.tsx b/web/src/app/admin/assistants/PersonaTable.tsx index e18c1d9c86e..0f8cecd2215 100644 --- a/web/src/app/admin/assistants/PersonaTable.tsx +++ b/web/src/app/admin/assistants/PersonaTable.tsx @@ -83,7 +83,14 @@ export function PersonasTable({ personas }: { personas: Persona[] }) { { return { id: persona.id.toString(), @@ -159,6 +166,44 @@ export function PersonasTable({ personas }: { personas: Persona[] }) { , +
{ + const next = !(persona.is_router_candidate ?? true); + const response = await fetch( + `/api/admin/persona/${persona.id}/router-candidate`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ is_router_candidate: next }), + } + ); + if (response.ok) { + router.refresh(); + } else { + setPopup({ + type: "error", + message: `Failed to update persona - ${await response.text()}`, + }); + } + }} + className="px-1 py-0.5 hover:bg-hover-light rounded flex cursor-pointer select-none w-fit" + > +
+ {(persona.is_router_candidate ?? true) ? ( + "On" + ) : ( +
Off
+ )} +
+
+ +
+
,
{!persona.default_persona ? ( diff --git a/web/src/app/admin/assistants/interfaces.ts b/web/src/app/admin/assistants/interfaces.ts index 4e0d11a1448..7b25de9d61e 100644 --- a/web/src/app/admin/assistants/interfaces.ts +++ b/web/src/app/admin/assistants/interfaces.ts @@ -28,6 +28,10 @@ export interface Persona { is_public: boolean; display_priority: number | null; description: string; + // Comma-separated keywords that deterministically route to this assistant. + routing_keywords?: string | null; + // Whether this assistant participates in auto-routing (Search tab). + is_router_candidate?: boolean; document_sets: DocumentSet[]; prompts: Prompt[]; tools: ToolSnapshot[]; diff --git a/web/src/app/admin/assistants/lib.ts b/web/src/app/admin/assistants/lib.ts index 26ce54e66d4..58924cfbc95 100644 --- a/web/src/app/admin/assistants/lib.ts +++ b/web/src/app/admin/assistants/lib.ts @@ -4,6 +4,8 @@ interface PersonaCreationRequest { name: string; display_name: string | null; description: string; + routing_keywords: string | null; + is_router_candidate: boolean; system_prompt: string; task_prompt: string; document_set_ids: number[]; @@ -26,6 +28,8 @@ interface PersonaUpdateRequest { name: string; display_name: string | null; description: string; + routing_keywords: string | null; + is_router_candidate: boolean; system_prompt: string; task_prompt: string; document_set_ids: number[]; @@ -108,6 +112,8 @@ function buildPersonaAPIBody( name, display_name, description, + routing_keywords, + is_router_candidate, document_set_ids, num_chunks, llm_relevance_filter, @@ -122,6 +128,8 @@ function buildPersonaAPIBody( name, display_name, description, + routing_keywords, + is_router_candidate, num_chunks, llm_relevance_filter, rerank_enabled, diff --git a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx index d38baedccdf..1e8e9041848 100644 --- a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx +++ b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx @@ -31,10 +31,7 @@ import { import { useRouter } from "next/navigation"; import { Persona } from "../assistants/interfaces"; import { useState } from "react"; -import { - LLM_MODELS_BY_VENDOR, - LLM_VENDORS, -} from "@/lib/llm/models"; +import { LLM_MODELS_BY_VENDOR, LLM_VENDORS } from "@/lib/llm/models"; import { BookmarkIcon, RobotIcon } from "@/components/icons/icons"; import { SourceIcon } from "@/components/SourceIcon"; import { getSourceMetadata } from "@/lib/sources"; @@ -101,6 +98,11 @@ export const SlackBotCreationForm = ({ existingSlackBotConfig?.channel_config?.follow_up_tags, opsgenie_schedule: existingSlackBotConfig?.channel_config?.opsgenie_schedule || "", + enable_sme_validation: + existingSlackBotConfig?.channel_config?.enable_sme_validation || + false, + sme_group_name: + existingSlackBotConfig?.channel_config?.sme_group_name || "", document_sets: existingSlackBotConfig && existingSlackBotConfig.persona ? existingSlackBotConfig.persona.document_sets.map( @@ -267,6 +269,8 @@ export const SlackBotCreationForm = ({ ), usePersona: usingPersonas, opsgenie_schedule: values.opsgenie_schedule || undefined, + enable_sme_validation: values.enable_sme_validation ?? false, + sme_group_name: values.sme_group_name || undefined, jira_config: { enable_jira_integration: values.jira_config.enable_jira_integration ?? false, @@ -346,6 +350,19 @@ export const SlackBotCreationForm = ({ subtext="The name of the OpsGenie schedule to use for getting the DRI on call when someone requests more help" /> + + {values.enable_sme_validation && ( + + )} + = {}) { + return { + document_sets: [], + persona_id: 1, + channel_names: ["help-hitl"], + answer_validity_check_enabled: false, + questionmark_prefilter_enabled: false, + respond_tag_only: false, + respond_to_bots: false, + respond_team_member_list: [], + respond_slack_group_list: [], + usePersona: true, + response_type: "citations", + opsgenie_schedule: "as-oncall", + enable_sme_validation: true, + sme_group_name: "Automation Suite SMEs", + ...overrides, + } as unknown as Parameters[0]; +} + +describe("slack bot config request body", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const sentBody = () => JSON.parse(fetchMock.mock.calls[0][1].body as string); + + it("createSlackBotConfig sends the SME fields", async () => { + await createSlackBotConfig(makeRequest()); + const body = sentBody(); + expect(body.enable_sme_validation).toBe(true); + expect(body.sme_group_name).toBe("Automation Suite SMEs"); + }); + + it("updateSlackBotConfig PATCHes the right url and sends the SME fields", async () => { + await updateSlackBotConfig(41, makeRequest()); + expect(fetchMock).toHaveBeenCalledWith( + "/api/manage/admin/slack-bot/config/41", + expect.objectContaining({ method: "PATCH" }) + ); + const body = sentBody(); + expect(body.enable_sme_validation).toBe(true); + expect(body.sme_group_name).toBe("Automation Suite SMEs"); + }); + + it("regression: existing fields (opsgenie_schedule) still pass through", async () => { + await createSlackBotConfig(makeRequest()); + expect(sentBody().opsgenie_schedule).toBe("as-oncall"); + }); + + it("SME off => body carries the falsey values (feature stays disabled)", async () => { + await createSlackBotConfig( + makeRequest({ enable_sme_validation: false, sme_group_name: undefined }) + ); + const body = sentBody(); + expect(body.enable_sme_validation).toBe(false); + expect(body.sme_group_name).toBeUndefined(); + }); +}); diff --git a/web/src/app/admin/bot/lib.ts b/web/src/app/admin/bot/lib.ts index a6bee4d815b..a2b862c3d59 100644 --- a/web/src/app/admin/bot/lib.ts +++ b/web/src/app/admin/bot/lib.ts @@ -18,6 +18,8 @@ interface SlackBotConfigCreationRequest { follow_up_tags?: string[]; prioritized_sources?: string[]; opsgenie_schedule?: string; + enable_sme_validation?: boolean; + sme_group_name?: string; jira_config?: { enable_jira_integration: boolean; project_key: string; @@ -62,6 +64,8 @@ const buildRequestBodyFromCreationRequest = ( follow_up_tags: creationRequest.follow_up_tags?.filter((tag) => tag !== ""), prioritized_sources: creationRequest.prioritized_sources, opsgenie_schedule: creationRequest.opsgenie_schedule, + enable_sme_validation: creationRequest.enable_sme_validation, + sme_group_name: creationRequest.sme_group_name, jira_config: creationRequest.jira_config, curated_response_config: creationRequest.curated_response_config, jira_title_filter: creationRequest.jira_title_filter, diff --git a/web/src/app/admin/settings/SettingsForm.tsx b/web/src/app/admin/settings/SettingsForm.tsx index ee6983c9252..ef892e31c76 100644 --- a/web/src/app/admin/settings/SettingsForm.tsx +++ b/web/src/app/admin/settings/SettingsForm.tsx @@ -237,6 +237,77 @@ export function SettingsForm() { ]); }} /> + +
- - Create new - + {enableAssistantCreation && ( + + Create new + + )}
diff --git a/web/src/app/assistants/mine/AssistantsList.tsx b/web/src/app/assistants/mine/AssistantsList.tsx index 7c2d467490d..1f6f7e59f4b 100644 --- a/web/src/app/assistants/mine/AssistantsList.tsx +++ b/web/src/app/assistants/mine/AssistantsList.tsx @@ -34,7 +34,8 @@ * callers but no longer used here. */ -import { useMemo, useRef, useState } from "react"; +import { useContext, useMemo, useRef, useState } from "react"; +import { SettingsContext } from "@/components/settings/SettingsProvider"; import { MinimalUserSnapshot, User } from "@/lib/types"; import { Persona } from "@/app/admin/assistants/interfaces"; import { Text } from "@tremor/react"; @@ -170,8 +171,7 @@ function RowContent({ dragHandleProps, }: RowProps & { dragHandleProps: - | (React.HTMLAttributes & { ref?: any }) - | null; + (React.HTMLAttributes & { ref?: any }) | null; }) { const isOwnedByUser = checkUserOwnsAssistant(user, assistant); const canEdit = isOwnedByUser; @@ -539,6 +539,8 @@ interface AssistantsListProps { export function AssistantsList({ user, assistants }: AssistantsListProps) { const router = useRouter(); + const enableAssistantCreation = + useContext(SettingsContext)?.settings?.enable_assistant_creation; const { popup, setPopup } = usePopup(); // Opt-out model: `chosenOrder` controls ORDER only (and default = position @@ -826,8 +828,7 @@ export function AssistantsList({ user, assistants }: AssistantsListProps) { )}
- {/* Header: title + 1-line subtitle + create button + browse link. - Cut the two-tile nav block and the explanatory paragraph. */} + {/* Header: title + 1-line subtitle + create button (flag-gated) + browse link. */}
My Assistants @@ -836,17 +837,19 @@ export function AssistantsList({ user, assistants }: AssistantsListProps) { default, and reorder by dragging.
- - Create - + {enableAssistantCreation && ( + + Create + + )}
diff --git a/web/src/app/auth/oauth/callback/route.ts b/web/src/app/auth/oauth/callback/route.ts index 0b4157731a1..36aa591d1f2 100644 --- a/web/src/app/auth/oauth/callback/route.ts +++ b/web/src/app/auth/oauth/callback/route.ts @@ -1,4 +1,5 @@ import { getDomain } from "@/lib/redirectSS"; +import { getSafeNextPath, LOGIN_NEXT_COOKIE } from "@/lib/safeRedirect"; import { buildUrl } from "@/lib/utilsSS"; import { NextRequest, NextResponse } from "next/server"; @@ -15,9 +16,19 @@ export const GET = async (request: NextRequest) => { return NextResponse.redirect(new URL("/auth/error", getDomain(request))); } + // Return the user to the deep link they started from (stashed on /auth/login), + // re-validated open-redirect-safe. Falls back to the app home if absent/unsafe. + const nextPath = + getSafeNextPath(request.cookies.get(LOGIN_NEXT_COOKIE)?.value) ?? "/"; + const redirectResponse = NextResponse.redirect( - new URL("/", getDomain(request)) + new URL(nextPath, getDomain(request)) ); redirectResponse.headers.set("set-cookie", setCookieHeader); + // Clear the one-shot next cookie (append so the session Set-Cookie above stays). + redirectResponse.headers.append( + "set-cookie", + `${LOGIN_NEXT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax` + ); return redirectResponse; }; diff --git a/web/src/app/auth/oidc/callback/route.ts b/web/src/app/auth/oidc/callback/route.ts index 353119409b9..267786ee815 100644 --- a/web/src/app/auth/oidc/callback/route.ts +++ b/web/src/app/auth/oidc/callback/route.ts @@ -1,4 +1,5 @@ import { getDomain } from "@/lib/redirectSS"; +import { getSafeNextPath, LOGIN_NEXT_COOKIE } from "@/lib/safeRedirect"; import { buildUrl } from "@/lib/utilsSS"; import { NextRequest, NextResponse } from "next/server"; @@ -15,9 +16,19 @@ export const GET = async (request: NextRequest) => { return NextResponse.redirect(new URL("/auth/error", getDomain(request))); } + // Return the user to the deep link they started from (stashed on /auth/login), + // re-validated open-redirect-safe. Falls back to the app home if absent/unsafe. + const nextPath = + getSafeNextPath(request.cookies.get(LOGIN_NEXT_COOKIE)?.value) ?? "/"; + const redirectResponse = NextResponse.redirect( - new URL("/", getDomain(request)) + new URL(nextPath, getDomain(request)) ); redirectResponse.headers.set("set-cookie", setCookieHeader); + // Clear the one-shot next cookie (append so the session Set-Cookie above stays). + redirectResponse.headers.append( + "set-cookie", + `${LOGIN_NEXT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax` + ); return redirectResponse; }; diff --git a/web/src/app/auth/saml/callback/route.ts b/web/src/app/auth/saml/callback/route.ts index fe9db7be126..f1d00048366 100644 --- a/web/src/app/auth/saml/callback/route.ts +++ b/web/src/app/auth/saml/callback/route.ts @@ -1,4 +1,5 @@ import { getDomain } from "@/lib/redirectSS"; +import { getSafeNextPath, LOGIN_NEXT_COOKIE } from "@/lib/safeRedirect"; import { buildUrl } from "@/lib/utilsSS"; import { NextRequest, NextResponse } from "next/server"; @@ -24,10 +25,20 @@ export const POST = async (request: NextRequest) => { ); } + // Return the user to the deep link they started from (stashed on /auth/login), + // re-validated open-redirect-safe. Falls back to the app home if absent/unsafe. + const nextPath = + getSafeNextPath(request.cookies.get(LOGIN_NEXT_COOKIE)?.value) ?? "/"; + const redirectResponse = NextResponse.redirect( - new URL("/", getDomain(request)), + new URL(nextPath, getDomain(request)), SEE_OTHER_REDIRECT_STATUS ); redirectResponse.headers.set("set-cookie", setCookieHeader); + // Clear the one-shot next cookie (append so the session Set-Cookie above stays). + redirectResponse.headers.append( + "set-cookie", + `${LOGIN_NEXT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax` + ); return redirectResponse; }; diff --git a/web/src/app/auto-search/AutoSearch.tsx b/web/src/app/auto-search/AutoSearch.tsx new file mode 100644 index 00000000000..dff67d16fd7 --- /dev/null +++ b/web/src/app/auto-search/AutoSearch.tsx @@ -0,0 +1,919 @@ +"use client"; + +import { useContext, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { FiSend, FiThumbsUp, FiThumbsDown } from "react-icons/fi"; +import Link from "next/link"; +import { SettingsContext } from "@/components/settings/SettingsProvider"; +import { useChatContext } from "@/components/context/ChatContext"; +import { Logo } from "@/components/Logo"; +import { HeaderTitle } from "@/components/header/Header"; +import { UserDropdown } from "@/components/UserDropdown"; +import { Persona } from "@/app/admin/assistants/interfaces"; +import { assistantDisplayName } from "@/lib/assistants/displayName"; +import { orderAssistantsForUser } from "@/lib/assistants/orderAssistants"; +import { + getMentionQuery, + filterAssistantsByMention, + applyMentionLabel, + stripMentionLabel, + hasMentionLabel, +} from "@/lib/assistants/mentions"; + +// Matches ChatInputBar's auto-grow cap so the Search box feels like the chat box. +const MAX_INPUT_HEIGHT = 200; +const RECENTS_KEY = "autoSearchRecents"; +const RECENTS_LIMIT = 8; +// Playful rotating status words shown while a search runs (Claude-style). +const LOADING_PHRASES = [ + "Searching", + "Brewing", + "Routing", + "Digging", + "Pondering", + "Assembling", + "Connecting the dots", + "Almost there", +]; +const LOADING_PHRASE_INTERVAL_MS = 1800; +// Typewriter timings for the empty search box's example placeholder. +const TYPE_MS = 45; // per character while typing +const HOLD_MS = 1800; // pause once a full example is typed, before the next +// Example prompts that teach the "@assistant" convention, cycled through the empty +// search box's placeholder. Each is bound to a real assistant by a name fragment +// (case/space-insensitive); unmatched ones are dropped so we never show an +// assistant the user doesn't have. +const EXAMPLE_PROMPTS: { match: string; question: string }[] = [ + { + match: "integration", + question: "why is my Salesforce connection failing?", + }, + { match: "ownership", question: "who is the TAM or CSM of the ABC account?" }, + { + match: "action center", + question: "how do I reassign a task to someone else?", + }, + { + match: "orchestrator", + question: "how do I schedule a process to run hourly?", + }, + { match: "automation suite", question: "how do I back up my cluster?" }, +]; + +// 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 SearchedAssistant { + persona_id: number; + name: string; + display_name: string | null; +} +interface AutoSearchResponse { + answer: string | null; + docs: { top_documents: AutoSearchDoc[] } | null; + chat_message_id: number | null; + answered_by: AnsweredBy; + // Next-best assistants (router ranks 2..N) shown as clickable "Recommended + // assistants" chips: click one to chat further with it if #1 wasn't right. + other_recommended: SearchedAssistant[]; + error_msg: string | null; + // Compare: when compare_enabled, the UI shows two tabs — the top-1 answer (here) + // and a second answer over the union of union_assistants' document sets, which is + // lazy-fetched from /query/auto-search/union so it never blocks this answer. + compare_enabled: boolean; + union_assistants: SearchedAssistant[]; + // Third tab (rides along with compare): an answer scoped to HighSpot + the docs + // sites, lazy-fetched from /query/auto-search/sources. + source_tab_enabled: boolean; +} +// The lazily-fetched second/third answer (union of doc sets, or the source-scoped +// answer). Same shape for both. +interface AutoSearchUnionResponse { + answer: string | null; + docs: { top_documents: AutoSearchDoc[] } | null; + error_msg: string | null; +} + +export function autoSearchVisible( + rollout: string | undefined, + userRole: string | null +): boolean { + // Mirrors the backend gate (_auto_search_allowed); the endpoint enforces it + // for real. OFF hides for everyone; EVERYONE shows to all; ADMIN_ONLY shows to + // admins AND the no-auth/superuser context (userRole === null), matching the + // backend's "user is None => treat as admin" convention so local dev + // (AUTH_TYPE=disabled) isn't blocked. + if (rollout === "off") return false; + if (rollout === "everyone") return true; + // admin_only + if (userRole === null) return true; + return userRole === "admin"; +} + +export function AutoSearch({ userRole }: { userRole: string | null }) { + const settings = useContext(SettingsContext)?.settings; + const rollout = settings?.auto_search_rollout ?? "admin_only"; + const { user, availablePersonas } = useChatContext(); + // Same accessible/ordered assistant set the chat picker uses. + const assistants = orderAssistantsForUser(availablePersonas, user); + + const [question, setQuestion] = useState(""); + const textAreaRef = useRef(null); + // Explicit @mention pick: when set (and its "@