Compare commits

...

23 Commits

Author SHA1 Message Date
Yuhong Sun
4293543a6a k 2024-07-20 16:48:05 -07:00
Yuhong Sun
e95bfa0e0b Suffix Test (#1880) 2024-07-20 15:54:55 -07:00
Yuhong Sun
4848b5f1de Suffix Edits (#1878) 2024-07-20 13:59:14 -07:00
Yuhong Sun
7ba5c434fa Missing Comma (#1877) 2024-07-19 22:15:45 -07:00
Yuhong Sun
59bf5ba848 File Connector Metadata (#1876) 2024-07-19 20:45:18 -07:00
Weves
f66c33380c Improve widget README 2024-07-19 20:21:07 -07:00
Weves
115650ce9f Add example widget code 2024-07-19 20:14:52 -07:00
Weves
7aa3602fca Fix black 2024-07-19 18:55:09 -07:00
Weves
864c552a17 Fix UT 2024-07-19 18:55:09 -07:00
Brent Kwok
07b2ed3d8f Fix HTTP 422 error for api_inference_sample.py (#1868) 2024-07-19 18:54:43 -07:00
Yuhong Sun
38290057f2 Search Eval (#1873) 2024-07-19 16:48:58 -07:00
Weves
2344edf158 Change default login time to 7 days 2024-07-19 13:58:50 -07:00
versecafe
86d1804eb0 Add GPT-4o-Mini & fix a missing gpt-4o 2024-07-19 12:10:27 -07:00
pablodanswer
1ebae50d0c minor udpate 2024-07-19 10:53:28 -07:00
Weves
a9fbaa396c Stop building on every PR 2024-07-19 10:21:19 -07:00
pablodanswer
27d5f69427 udpate to headers (#1864) 2024-07-19 08:38:54 -07:00
pablodanswer
5d98421ae8 show "analysis" (#1863) 2024-07-18 18:18:36 -07:00
Kevin Shi
6b561b8ca9 Add config to skip zendesk article labels 2024-07-18 18:00:51 -07:00
pablodanswer
2dc7e64dd7 fix internet search icons / text + assistants tab (#1862) 2024-07-18 16:15:19 -07:00
Yuhong Sun
5230f7e22f Enforce Disable GenAI if set (#1860) 2024-07-18 13:25:55 -07:00
hagen-danswer
a595d43ae3 Fixed deleting toolcall by message 2024-07-18 12:52:28 -07:00
Yuhong Sun
ee561f42ff Cleaner Layout (#1857) 2024-07-18 11:13:16 -07:00
Yuhong Sun
f00b3d76b3 Touchup NoOp (#1856) 2024-07-18 08:44:27 -07:00
59 changed files with 6740 additions and 171 deletions

View File

@@ -1,8 +1,6 @@
name: Build Backend Image on Merge Group
on:
pull_request:
branches: [ "main" ]
merge_group:
types: [checks_requested]

View File

@@ -1,8 +1,6 @@
name: Build Web Image on Merge Group
on:
pull_request:
branches: [ "main" ]
merge_group:
types: [checks_requested]

View File

@@ -1,7 +1,7 @@
"""add search doc relevance details
Revision ID: 05c07bf07c00
Revises: 3a7802814195
Revises: b896bbd0d5a7
Create Date: 2024-07-10 17:48:15.886653
"""

View File

@@ -11,8 +11,8 @@ from alembic import op
# revision identifiers, used by Alembic.
revision = "b896bbd0d5a7"
down_revision = "44f856ae2a4a"
branch_labels = None
depends_on = None
branch_labels: None = None
depends_on: None = None
def upgrade() -> None:

View File

@@ -223,6 +223,11 @@ MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE = int(
os.environ.get("MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE", 0)
)
# comma delimited list of zendesk article labels to skip indexing for
ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS = os.environ.get(
"ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS", ""
).split(",")
#####
# Indexing Configs

View File

@@ -38,7 +38,7 @@ def make_confluence_call_handle_rate_limit(confluence_call: F) -> F:
retry_after = None
try:
retry_after = int(e.response.headers.get("Retry-After"))
except ValueError:
except (ValueError, TypeError):
pass
if retry_after:

View File

@@ -88,7 +88,7 @@ def _process_file(
# add a prefix to avoid conflicts with other connectors
doc_id = f"FILE_CONNECTOR__{file_name}"
if metadata:
doc_id = metadata.get("id") or doc_id
doc_id = metadata.get("document_id") or doc_id
# If this is set, we will show this in the UI as the "name" of the file
file_display_name = all_metadata.get("file_display_name") or os.path.basename(
@@ -111,6 +111,7 @@ def _process_file(
for k, v in all_metadata.items()
if k
not in [
"document_id",
"time_updated",
"doc_updated_at",
"link",

View File

@@ -4,6 +4,7 @@ from zenpy import Zenpy # type: ignore
from zenpy.lib.api_objects.help_centre_objects import Article # type: ignore
from danswer.configs.app_configs import INDEX_BATCH_SIZE
from danswer.configs.app_configs import ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS
from danswer.configs.constants import DocumentSource
from danswer.connectors.cross_connector_utils.miscellaneous_utils import (
time_str_to_utc,
@@ -81,7 +82,14 @@ class ZendeskConnector(LoadConnector, PollConnector):
)
doc_batch = []
for article in articles:
if article.body is None or article.draft:
if (
article.body is None
or article.draft
or any(
label in ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS
for label in article.label_names
)
):
continue
doc_batch.append(_article_to_document(article))

View File

@@ -146,6 +146,12 @@ def delete_search_doc_message_relationship(
db_session.commit()
def delete_tool_call_for_message_id(message_id: int, db_session: Session) -> None:
stmt = delete(ToolCall).where(ToolCall.message_id == message_id)
db_session.execute(stmt)
db_session.commit()
def delete_orphaned_search_docs(db_session: Session) -> None:
orphaned_docs = (
db_session.query(SearchDoc)
@@ -169,6 +175,7 @@ def delete_messages_and_files_from_chat_session(
).fetchall()
for id, files in messages_with_files:
delete_tool_call_for_message_id(message_id=id, db_session=db_session)
delete_search_doc_message_relationship(message_id=id, db_session=db_session)
for file_info in files or {}:
lobj_name = file_info.get("id")

View File

@@ -153,43 +153,41 @@ schema DANSWER_CHUNK_NAME {
query(query_embedding) tensor<float>(x[VARIABLE_DIM])
}
# This must be separate function for normalize_linear to work
function vector_score() {
function title_vector_score() {
expression {
# If no title, the full vector score comes from the content embedding
(query(title_content_ratio) * if(attribute(skip_title), closeness(field, embeddings), closeness(field, title_embedding))) +
((1 - query(title_content_ratio)) * closeness(field, embeddings))
}
}
# This must be separate function for normalize_linear to work
function keyword_score() {
expression {
(query(title_content_ratio) * bm25(title)) +
((1 - query(title_content_ratio)) * bm25(content))
#query(title_content_ratio) * if(attribute(skip_title), closeness(field, embeddings), closeness(field, title_embedding))
if(attribute(skip_title), closeness(field, embeddings), closeness(field, title_embedding))
}
}
first-phase {
expression: vector_score
expression: closeness(field, embeddings)
}
# Weighted average between Vector Search and BM-25
# Each is a weighted average between the Title and Content fields
# Finally each doc is boosted by it's user feedback based boost and recency
# If any embedding or index field is missing, it just receives a score of 0
# Assumptions:
# - For a given query + corpus, the BM-25 scores will be relatively similar in distribution
# therefore not normalizing before combining.
# - For documents without title, it gets a score of 0 for that and this is ok as documents
# without any title match should be penalized.
global-phase {
expression {
(
# Weighted Vector Similarity Score
(query(alpha) * normalize_linear(vector_score)) +
(
query(alpha) * (
(query(title_content_ratio) * normalize_linear(title_vector_score))
+
((1 - query(title_content_ratio)) * normalize_linear(closeness(field, embeddings)))
)
)
+
# Weighted Keyword Similarity Score
((1 - query(alpha)) * normalize_linear(keyword_score))
(
(1 - query(alpha)) * (
(query(title_content_ratio) * normalize_linear(bm25(title)))
+
((1 - query(title_content_ratio)) * normalize_linear(bm25(content)))
)
)
)
# Boost based on user feedback
* document_boost
@@ -204,8 +202,6 @@ schema DANSWER_CHUNK_NAME {
bm25(content)
closeness(field, title_embedding)
closeness(field, embeddings)
keyword_score
vector_score
document_boost
recency_bias
closest(embeddings)

View File

@@ -232,32 +232,6 @@ class DefaultMultiLLM(LLM):
self._model_kwargs = model_kwargs
@staticmethod
def _log_prompt(prompt: LanguageModelInput) -> None:
if isinstance(prompt, list):
for ind, msg in enumerate(prompt):
if isinstance(msg, AIMessageChunk):
if msg.content:
log_msg = msg.content
elif msg.tool_call_chunks:
log_msg = "Tool Calls: " + str(
[
{
key: value
for key, value in tool_call.items()
if key != "index"
}
for tool_call in msg.tool_call_chunks
]
)
else:
log_msg = ""
logger.debug(f"Message {ind}:\n{log_msg}")
else:
logger.debug(f"Message {ind}:\n{msg.content}")
if isinstance(prompt, str):
logger.debug(f"Prompt:\n{prompt}")
def log_model_configs(self) -> None:
logger.info(f"Config: {self.config}")
@@ -311,7 +285,7 @@ class DefaultMultiLLM(LLM):
api_version=self._api_version,
)
def invoke(
def _invoke_implementation(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
@@ -319,7 +293,6 @@ class DefaultMultiLLM(LLM):
) -> BaseMessage:
if LOG_DANSWER_MODEL_INTERACTIONS:
self.log_model_configs()
self._log_prompt(prompt)
response = cast(
litellm.ModelResponse, self._completion(prompt, tools, tool_choice, False)
@@ -328,7 +301,7 @@ class DefaultMultiLLM(LLM):
response.choices[0].message
)
def stream(
def _stream_implementation(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
@@ -336,7 +309,6 @@ class DefaultMultiLLM(LLM):
) -> Iterator[BaseMessage]:
if LOG_DANSWER_MODEL_INTERACTIONS:
self.log_model_configs()
self._log_prompt(prompt)
if DISABLE_LITELLM_STREAMING:
yield self.invoke(prompt)

View File

@@ -76,7 +76,7 @@ class CustomModelServer(LLM):
def log_model_configs(self) -> None:
logger.debug(f"Custom model at: {self._endpoint}")
def invoke(
def _invoke_implementation(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
@@ -84,7 +84,7 @@ class CustomModelServer(LLM):
) -> BaseMessage:
return self._execute(prompt)
def stream(
def _stream_implementation(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,

View File

@@ -3,9 +3,12 @@ from collections.abc import Iterator
from typing import Literal
from langchain.schema.language_model import LanguageModelInput
from langchain_core.messages import AIMessageChunk
from langchain_core.messages import BaseMessage
from pydantic import BaseModel
from danswer.configs.app_configs import DISABLE_GENERATIVE_AI
from danswer.configs.app_configs import LOG_DANSWER_MODEL_INTERACTIONS
from danswer.utils.logger import setup_logger
@@ -23,6 +26,32 @@ class LLMConfig(BaseModel):
api_version: str | None
def log_prompt(prompt: LanguageModelInput) -> None:
if isinstance(prompt, list):
for ind, msg in enumerate(prompt):
if isinstance(msg, AIMessageChunk):
if msg.content:
log_msg = msg.content
elif msg.tool_call_chunks:
log_msg = "Tool Calls: " + str(
[
{
key: value
for key, value in tool_call.items()
if key != "index"
}
for tool_call in msg.tool_call_chunks
]
)
else:
log_msg = ""
logger.debug(f"Message {ind}:\n{log_msg}")
else:
logger.debug(f"Message {ind}:\n{msg.content}")
if isinstance(prompt, str):
logger.debug(f"Prompt:\n{prompt}")
class LLM(abc.ABC):
"""Mimics the LangChain LLM / BaseChatModel interfaces to make it easy
to use these implementations to connect to a variety of LLM providers."""
@@ -45,20 +74,48 @@ class LLM(abc.ABC):
def log_model_configs(self) -> None:
raise NotImplementedError
@abc.abstractmethod
def _precall(self, prompt: LanguageModelInput) -> None:
if DISABLE_GENERATIVE_AI:
raise Exception("Generative AI is disabled")
if LOG_DANSWER_MODEL_INTERACTIONS:
log_prompt(prompt)
def invoke(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
tool_choice: ToolChoiceOptions | None = None,
) -> BaseMessage:
self._precall(prompt)
# TODO add a postcall to log model outputs independent of concrete class
# implementation
return self._invoke_implementation(prompt, tools, tool_choice)
@abc.abstractmethod
def _invoke_implementation(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
tool_choice: ToolChoiceOptions | None = None,
) -> BaseMessage:
raise NotImplementedError
@abc.abstractmethod
def stream(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
tool_choice: ToolChoiceOptions | None = None,
) -> Iterator[BaseMessage]:
self._precall(prompt)
# TODO add a postcall to log model outputs independent of concrete class
# implementation
return self._stream_implementation(prompt, tools, tool_choice)
@abc.abstractmethod
def _stream_implementation(
self,
prompt: LanguageModelInput,
tools: list[dict] | None = None,
tool_choice: ToolChoiceOptions | None = None,
) -> Iterator[BaseMessage]:
raise NotImplementedError

View File

@@ -26,6 +26,7 @@ OPENAI_PROVIDER_NAME = "openai"
OPEN_AI_MODEL_NAMES = [
"gpt-4",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4-turbo-preview",
"gpt-4-1106-preview",

View File

@@ -189,7 +189,7 @@ def stream_answer_objects(
chunks_below=query_req.chunks_below,
full_doc=query_req.full_doc,
bypass_acl=bypass_acl,
evaluate_response=query_req.evaluate_response,
llm_doc_eval=query_req.llm_doc_eval,
)
answer_config = AnswerStyleConfig(

View File

@@ -37,7 +37,7 @@ class DirectQARequest(ChunkContext):
# This is to toggle agentic evaluation:
# 1. Evaluates whether each response is relevant or not
# 2. Provides a summary of the document's relevance in the resulsts
evaluate_response: bool = False
llm_doc_eval: bool = False
# If True, skips generative an AI response to the search query
skip_gen_ai_answer_generation: bool = False

View File

@@ -39,7 +39,7 @@ SEARCH_RESPONSE_SUMMARY_ID = "search_response_summary"
SEARCH_DOC_CONTENT_ID = "search_doc_content"
SECTION_RELEVANCE_LIST_ID = "section_relevance_list"
FINAL_CONTEXT_DOCUMENTS = "final_context_documents"
SEARCH_EVALUATION_ID = "evaluate_response"
SEARCH_EVALUATION_ID = "llm_doc_eval"
class SearchResponseSummary(BaseModel):
@@ -85,7 +85,7 @@ class SearchTool(Tool):
chunks_below: int = 0,
full_doc: bool = False,
bypass_acl: bool = False,
evaluate_response: bool = False,
llm_doc_eval: bool = False,
) -> None:
self.user = user
self.persona = persona
@@ -102,7 +102,7 @@ class SearchTool(Tool):
self.full_doc = full_doc
self.bypass_acl = bypass_acl
self.db_session = db_session
self.evaluate_response = evaluate_response
self.llm_doc_eval = llm_doc_eval
@property
def name(self) -> str:
@@ -295,7 +295,7 @@ class SearchTool(Tool):
yield ToolResponse(id=FINAL_CONTEXT_DOCUMENTS, response=llm_docs)
if self.evaluate_response and not DISABLE_AGENTIC_SEARCH:
if self.llm_doc_eval and not DISABLE_AGENTIC_SEARCH:
yield ToolResponse(
id=SEARCH_EVALUATION_ID, response=search_pipeline.relevance_summaries
)

View File

@@ -6,7 +6,13 @@ from danswer.llm.utils import get_default_llm_tokenizer
from danswer.tools.tool import Tool
OPEN_AI_TOOL_CALLING_MODELS = {"gpt-3.5-turbo", "gpt-4-turbo", "gpt-4"}
OPEN_AI_TOOL_CALLING_MODELS = {
"gpt-3.5-turbo",
"gpt-4-turbo",
"gpt-4",
"gpt-4o",
"gpt-4o-mini",
}
def explicit_tool_calling_supported(model_provider: str, model_name: str) -> bool:

View File

@@ -33,6 +33,7 @@ def process_question(danswer_url: str, question: str, api_key: str | None) -> No
"message": question,
"chat_session_id": chat_session_id,
"parent_message_id": None,
"file_descriptors": [],
# Default Question Answer prompt
"prompt_id": 0,
# Not specifying any specific docs to chat to, we want to run a search

View File

@@ -2,6 +2,8 @@ from openai import OpenAI
VALID_MODEL_LIST = [
"gpt-4o-mini",
"gpt-4o",
"gpt-4-1106-preview",
"gpt-4-vision-preview",
"gpt-4",

View File

@@ -70,7 +70,7 @@ Edit `search_test_config.yaml` to set:
- model_server_port
- This is the port of the remote model server
- Only need to set this if use_cloud_gpu is true
- existing_test_suffix
- existing_test_suffix (THIS IS NOT A SUFFIX ANYMORE, TODO UPDATE THE DOCS HERE)
- Use this if you would like to relaunch a previous test instance
- Input the suffix of the test you'd like to re-launch
- (E.g. to use the data from folder "test-1234-5678" put "-1234-5678")

View File

@@ -97,7 +97,7 @@ def get_docker_container_env_vars(suffix: str) -> dict:
def manage_data_directories(suffix: str, base_path: str, use_cloud_gpu: bool) -> None:
# Use the user's home directory as the base path
target_path = os.path.join(os.path.expanduser(base_path), f"test{suffix}")
target_path = os.path.join(os.path.expanduser(base_path), suffix)
directories = {
"DANSWER_POSTGRES_DATA_DIR": os.path.join(target_path, "postgres/"),
"DANSWER_VESPA_DATA_DIR": os.path.join(target_path, "vespa/"),
@@ -144,26 +144,30 @@ def _is_port_in_use(port: int) -> bool:
def start_docker_compose(
run_suffix: str, launch_web_ui: bool, use_cloud_gpu: bool
run_suffix: str, launch_web_ui: bool, use_cloud_gpu: bool, only_state: bool = False
) -> None:
print("Starting Docker Compose...")
os.chdir(os.path.dirname(__file__))
os.chdir("../../../../deployment/docker_compose/")
command = f"docker compose -f docker-compose.search-testing.yml -p danswer-stack{run_suffix} up -d"
command = f"docker compose -f docker-compose.search-testing.yml -p danswer-stack-{run_suffix} up -d"
command += " --build"
command += " --force-recreate"
if use_cloud_gpu:
command += " --scale indexing_model_server=0"
command += " --scale inference_model_server=0"
if launch_web_ui:
web_ui_port = 3000
while _is_port_in_use(web_ui_port):
web_ui_port += 1
print(f"UI will be launched at http://localhost:{web_ui_port}")
os.environ["NGINX_PORT"] = str(web_ui_port)
if only_state:
command += " index relational_db"
else:
command += " --scale web_server=0"
command += " --scale nginx=0"
if use_cloud_gpu:
command += " --scale indexing_model_server=0"
command += " --scale inference_model_server=0"
if launch_web_ui:
web_ui_port = 3000
while _is_port_in_use(web_ui_port):
web_ui_port += 1
print(f"UI will be launched at http://localhost:{web_ui_port}")
os.environ["NGINX_PORT"] = str(web_ui_port)
else:
command += " --scale web_server=0"
command += " --scale nginx=0"
print("Docker Command:\n", command)

View File

@@ -39,9 +39,11 @@ def main() -> None:
if config.commit_sha:
switch_to_commit(config.commit_sha)
start_docker_compose(run_suffix, config.launch_web_ui, config.use_cloud_gpu)
start_docker_compose(
run_suffix, config.launch_web_ui, config.use_cloud_gpu, config.only_state
)
if not config.existing_test_suffix:
if not config.existing_test_suffix and not config.only_state:
upload_test_files(config.zipped_documents_file, run_suffix)
run_qa_test_and_save_results(run_suffix)

View File

@@ -46,19 +46,16 @@ def _get_test_output_folder(config: dict) -> str:
base_output_folder = os.path.expanduser(config["output_folder"])
if config["run_suffix"]:
base_output_folder = os.path.join(
base_output_folder, ("test" + config["run_suffix"]), "evaluations_output"
base_output_folder, config["run_suffix"], "evaluations_output"
)
else:
base_output_folder = os.path.join(base_output_folder, "no_defined_suffix")
counter = 1
run_suffix = config["run_suffix"][1:]
output_folder_path = os.path.join(base_output_folder, f"{run_suffix}_run_1")
output_folder_path = os.path.join(base_output_folder, "run_1")
while os.path.exists(output_folder_path):
output_folder_path = os.path.join(
output_folder_path.replace(
f"{run_suffix}_run_{counter-1}", f"{run_suffix}_run_{counter}"
),
output_folder_path.replace(f"run_{counter-1}", f"run_{counter}"),
)
counter += 1
@@ -163,6 +160,9 @@ def _process_and_write_query_results(config: dict) -> None:
if not result.get("answer"):
invalid_answer_count += 1
if not result.get("context_data_list"):
raise RuntimeError("Search failed, this is a critical failure!")
_update_metadata_file(test_output_folder, invalid_answer_count)
if invalid_answer_count:

View File

@@ -19,6 +19,9 @@ clean_up_docker_containers: true
# Whether to launch a web UI for the test
launch_web_ui: false
# Whether to only run Vespa and Postgres
only_state: false
# Whether to use a cloud GPU for processing
use_cloud_gpu: false

View File

@@ -19,7 +19,7 @@ services:
environment:
# Auth Settings
- AUTH_TYPE=${AUTH_TYPE:-disabled}
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-86400}
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-}
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
- VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}

View File

@@ -19,7 +19,7 @@ services:
environment:
# Auth Settings
- AUTH_TYPE=${AUTH_TYPE:-disabled}
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-86400}
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-}
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
- VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}

View File

@@ -57,8 +57,8 @@ SECRET=
#SAML_CONF_DIR=
# How long before user needs to reauthenticate, default to 1 day. (cookie expiration time)
SESSION_EXPIRE_TIME_SECONDS=86400
# How long before user needs to reauthenticate, default to 7 days. (cookie expiration time)
SESSION_EXPIRE_TIME_SECONDS=604800
# Use the below to specify a list of allowed user domains, only checked if user Auth is turned on

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
examples/widget/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

19
examples/widget/README.md Normal file
View File

@@ -0,0 +1,19 @@
This is a code example for how you can use Danswer's APIs to build a chat bot widget for a website! The main code to look at can be found in `src/app/widget/Widget.tsx`.
If you want to get fancier, then take a peek at the Chat implementation within Danswer itself [here](https://github.com/danswer-ai/danswer/blob/main/web/src/app/chat/ChatPage.tsx#L82).
## Getting Started
First, install the requirements:
```bash
npm i
```
Then run the development server:
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

5933
examples/widget/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "widget",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.5",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^8.0.6"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.19",
"eslint": "^8",
"eslint-config-next": "14.2.5",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Example Danswer Widget",
description: "Example Danswer Widget",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,9 @@
import { ChatWidget } from "./widget/Widget";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<ChatWidget />
</main>
);
}

View File

@@ -0,0 +1,344 @@
"use client";
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "";
type NonEmptyObject = { [k: string]: any };
const processSingleChunk = <T extends NonEmptyObject>(
chunk: string,
currPartialChunk: string | null,
): [T | null, string | null] => {
const completeChunk = (currPartialChunk || "") + chunk;
try {
// every complete chunk should be valid JSON
const chunkJson = JSON.parse(completeChunk);
return [chunkJson, null];
} catch (err) {
// if it's not valid JSON, then it's probably an incomplete chunk
return [null, completeChunk];
}
};
const processRawChunkString = <T extends NonEmptyObject>(
rawChunkString: string,
previousPartialChunk: string | null,
): [T[], string | null] => {
/* This is required because, in practice, we see that nginx does not send over
each chunk one at a time even with buffering turned off. Instead,
chunks are sometimes in batches or are sometimes incomplete */
if (!rawChunkString) {
return [[], null];
}
const chunkSections = rawChunkString
.split("\n")
.filter((chunk) => chunk.length > 0);
let parsedChunkSections: T[] = [];
let currPartialChunk = previousPartialChunk;
chunkSections.forEach((chunk) => {
const [processedChunk, partialChunk] = processSingleChunk<T>(
chunk,
currPartialChunk,
);
if (processedChunk) {
parsedChunkSections.push(processedChunk);
currPartialChunk = null;
} else {
currPartialChunk = partialChunk;
}
});
return [parsedChunkSections, currPartialChunk];
};
async function* handleStream<T extends NonEmptyObject>(
streamingResponse: Response,
): AsyncGenerator<T[], void, unknown> {
const reader = streamingResponse.body?.getReader();
const decoder = new TextDecoder("utf-8");
let previousPartialChunk: string | null = null;
while (true) {
const rawChunk = await reader?.read();
if (!rawChunk) {
throw new Error("Unable to process chunk");
}
const { done, value } = rawChunk;
if (done) {
break;
}
const [completedChunks, partialChunk] = processRawChunkString<T>(
decoder.decode(value, { stream: true }),
previousPartialChunk,
);
if (!completedChunks.length && !partialChunk) {
break;
}
previousPartialChunk = partialChunk as string | null;
yield await Promise.resolve(completedChunks);
}
}
async function* sendMessage({
message,
chatSessionId,
parentMessageId,
}: {
message: string;
chatSessionId?: number;
parentMessageId?: number;
}) {
if (!chatSessionId || !parentMessageId) {
// Create a new chat session if one doesn't exist
const createSessionResponse = await fetch(
`${API_URL}/chat/create-chat-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
// or specify an assistant you have defined
persona_id: 0,
}),
},
);
if (!createSessionResponse.ok) {
const errorJson = await createSessionResponse.json();
const errorMsg = errorJson.message || errorJson.detail || "";
throw Error(`Failed to create chat session - ${errorMsg}`);
}
const sessionData = await createSessionResponse.json();
chatSessionId = sessionData.chat_session_id;
}
const sendMessageResponse = await fetch(`${API_URL}/chat/send-message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
chat_session_id: chatSessionId,
parent_message_id: parentMessageId,
message: message,
prompt_id: null,
search_doc_ids: null,
file_descriptors: [],
// checkout https://github.com/danswer-ai/danswer/blob/main/backend/danswer/search/models.py#L105 for
// all available options
retrieval_options: {
run_search: "always",
filters: null,
},
query_override: null,
}),
});
if (!sendMessageResponse.ok) {
const errorJson = await sendMessageResponse.json();
const errorMsg = errorJson.message || errorJson.detail || "";
throw Error(`Failed to send message - ${errorMsg}`);
}
yield* handleStream<NonEmptyObject>(sendMessageResponse);
}
export const ChatWidget = () => {
const [messages, setMessages] = useState<{ text: string; isUser: boolean }[]>(
[],
);
const [inputText, setInputText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (inputText.trim()) {
const initialPrevMessages = messages;
setMessages([...initialPrevMessages, { text: inputText, isUser: true }]);
setInputText("");
setIsLoading(true);
try {
const messageGenerator = sendMessage({
message: inputText,
chatSessionId: undefined,
parentMessageId: undefined,
});
let fullResponse = "";
for await (const chunks of messageGenerator) {
for (const chunk of chunks) {
if ("answer_piece" in chunk) {
fullResponse += chunk.answer_piece;
setMessages([
...initialPrevMessages,
{ text: inputText, isUser: true },
{ text: fullResponse, isUser: false },
]);
}
}
}
} catch (error) {
console.error("Error sending message:", error);
setMessages((prevMessages) => [
...prevMessages,
{ text: "An error occurred. Please try again.", isUser: false },
]);
} finally {
setIsLoading(false);
}
}
};
return (
<div
className="
fixed
bottom-4
right-4
z-50
bg-white
rounded-lg
shadow-xl
w-96
h-[32rem]
flex
flex-col
overflow-hidden
transition-all
duration-300
ease-in-out
"
>
<div
className="
bg-gradient-to-r
from-blue-600
to-blue-800
text-white
p-4
font-bold
flex
justify-between
items-center
"
>
<span>Chat Support</span>
</div>
<div
className="
flex-grow
overflow-y-auto
p-4
space-y-4
bg-gray-50
border-b
border-gray-200
"
>
{messages.map((message, index) => (
<div
key={index}
className={`
flex
${message.isUser ? "justify-end" : "justify-start"}
`}
>
<div
className={`
max-w-[75%]
p-3
rounded-lg
${
message.isUser
? "bg-blue-500 text-white"
: "bg-white text-black"
}
shadow
`}
>
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-center">
<div className="animate-pulse flex space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
</div>
</div>
)}
</div>
<form
onSubmit={handleSubmit}
className="
p-4
bg-white
border-t
border-gray-200
"
>
<div className="relative">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Type a message..."
className="
w-full
p-2
pr-10
border
border-gray-300
rounded-full
focus:outline-none
focus:ring-2
focus:ring-blue-500
focus:border-transparent
"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading}
className="
absolute
right-2
top-1/2
transform
-translate-y-1/2
text-blue-500
hover:text-blue-600
focus:outline-none
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
</div>
</form>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -9,7 +9,7 @@ import { Folder } from "@/app/chat/folders/interfaces";
import { User } from "@/lib/types";
import { Persona } from "@/app/admin/assistants/interfaces";
import Cookies from "js-cookie";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/contants";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
import { ReactNode, useEffect, useRef, useState } from "react";
import { useSidebarVisibility } from "@/components/chat_search/hooks";
import FunctionalHeader from "@/components/chat_search/Header";
@@ -143,6 +143,7 @@ export default function SidebarWrapper<T extends object>({
<div className="mt-4 mx-auto">{content(contentProps)}</div>
</div>
</div>
<FixedLogo />
</div>
);
}

View File

@@ -65,9 +65,6 @@ export default async function GalleryPage({
user={user}
assistants={assistants}
/>
{/* Temporary - fixed logo */}
<FixedLogo />
</ChatProvider>
</>
);

View File

@@ -67,9 +67,6 @@ export default async function GalleryPage({
user={user}
assistants={assistants}
/>
{/* Temporary - fixed logo */}
<FixedLogo />
</ChatProvider>
</>
);

View File

@@ -72,7 +72,7 @@ import { ChatBanner } from "./ChatBanner";
import FunctionalHeader from "@/components/chat_search/Header";
import { useSidebarVisibility } from "@/components/chat_search/hooks";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/contants";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
import FixedLogo from "./shared_chat_search/FixedLogo";
const TEMP_USER_MESSAGE_ID = -1;

View File

@@ -57,6 +57,8 @@ import {
import { ValidSources } from "@/lib/types";
import { Tooltip } from "@/components/tooltip/Tooltip";
import { useMouseTracking } from "./hooks";
import { InternetSearchIcon } from "@/components/InternetSearchIcon";
import { getTitleFromDocument } from "@/lib/sources";
const TOOLS_WITH_CUSTOM_HANDLING = [
SEARCH_TOOL_NAME,
@@ -443,17 +445,17 @@ export const AIMessage = ({
>
<Citation link={doc.link} index={ind + 1} />
<p className="shrink truncate ellipsis break-all ">
{
doc.document_id.split("/")[
doc.document_id.split("/").length - 1
]
}
{getTitleFromDocument(doc)}
</p>
<div className="ml-auto flex-none">
<SourceIcon
sourceType={doc.source_type}
iconSize={18}
/>
{doc.is_internet ? (
<InternetSearchIcon url={doc.link} />
) : (
<SourceIcon
sourceType={doc.source_type}
iconSize={18}
/>
)}
</div>
</a>
<div className="flex overscroll-x-scroll mt-.5">

View File

@@ -54,11 +54,13 @@ export function PagesTab({
}
};
const isHistoryEmpty = !existingChats || existingChats.length === 0;
return (
<div className="mb-1 ml-3 relative miniscroll overflow-y-auto h-full">
{folders && folders.length > 0 && (
<div className="py-2 border-b border-border">
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-2 font-medium">
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-2 font-bold">
Folders
</div>
<FolderList
@@ -80,43 +82,51 @@ export function PagesTab({
} rounded-md`}
>
{(page == "chat" || page == "search") && (
<p className="my-2 text-xs text-subtle flex font-semibold">
<p className="my-2 text-xs text-subtle flex font-bold">
{page == "chat" && "Chat "}
{page == "search" && "Search "}
History
</p>
)}
{Object.entries(groupedChatSessions).map(
([dateRange, chatSessions], ind) => {
if (chatSessions.length > 0) {
return (
<div key={dateRange}>
<div
className={`text-xs text-subtle ${
ind != 0 && "mt-5"
} flex pb-0.5 mb-1.5 font-medium`}
>
{dateRange}
{isHistoryEmpty ? (
<p className="text-sm text-subtle mt-2 w-[250px]">
{page === "search"
? "Try running a search! Your search history will appear here."
: "Try sending a message! Your chat history will appear here."}
</p>
) : (
Object.entries(groupedChatSessions).map(
([dateRange, chatSessions], ind) => {
if (chatSessions.length > 0) {
return (
<div key={dateRange}>
<div
className={`text-xs text-subtle ${
ind != 0 && "mt-5"
} flex pb-0.5 mb-1.5 font-medium`}
>
{dateRange}
</div>
{chatSessions
.filter((chat) => chat.folder_id === null)
.map((chat) => {
const isSelected = currentChatId === chat.id;
return (
<div key={`${chat.id}-${chat.name}`}>
<ChatSessionDisplay
search={page == "search"}
chatSession={chat}
isSelected={isSelected}
skipGradient={isDragOver}
/>
</div>
);
})}
</div>
{chatSessions
.filter((chat) => chat.folder_id === null)
.map((chat) => {
const isSelected = currentChatId === chat.id;
return (
<div key={`${chat.id}-${chat.name}`}>
<ChatSessionDisplay
search={page == "search"}
chatSession={chat}
isSelected={isSelected}
skipGradient={isDragOver}
/>
</div>
);
})}
</div>
);
);
}
}
}
)
)}
</div>
</div>

View File

@@ -1,3 +1,5 @@
"use client";
import { HeaderTitle } from "@/components/header/Header";
import { Logo } from "@/components/Logo";
import { SettingsContext } from "@/components/settings/SettingsProvider";

View File

@@ -30,7 +30,7 @@ import {
} from "@/lib/assistants/fetchAssistantsSS";
import FunctionalWrapper from "../chat/shared_chat_search/FunctionalWrapper";
import { ChatSession } from "../chat/interfaces";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/contants";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
import ToggleSearch from "./WrappedSearch";
import {
AGENTIC_SEARCH_TYPE_COOKIE_NAME,

View File

@@ -329,17 +329,10 @@ export async function Layout({ children }: { children: React.ReactNode }) {
/>
</div>
<div className="pb-8 relative h-full overflow-y-auto w-full">
<div className="fixed bg-background left-0 border-b gap-x-4 mb-8 px-4 py-2 w-full items-center flex justify-end">
<a
href="/chat"
className="transition-all duration-150 cursor-pointer p-1 text-sm items-center flex gap-x-1 px-2 py-1 rounded-lg hover:shadow-sm hover:ring-1 hover:ring-ingio-900/40 hover:bg-opacity-90 text-neutral-100 bg-accent"
>
<BackIcon size={20} className="text-neutral" />
Back to Danswer
</a>
<div className="fixed bg-background left-0 gap-x-4 mb-8 px-4 py-2 w-full items-center flex justify-end">
<UserDropdown user={user} />
</div>
<div className="pt-20 flex overflow-y-auto h-full px-4 md:px-12">
<div className="pt-12 flex overflow-y-auto h-full px-4 md:px-12">
{children}
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { Logo } from "@/components/Logo";
import { NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED } from "@/lib/constants";
import { HeaderTitle } from "@/components/header/Header";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { BackIcon } from "@/components/icons/icons";
interface Item {
name: string | JSX.Element;
@@ -62,6 +63,12 @@ export function AdminSidebar({ collections }: { collections: Collection[] }) {
</Link>
</div>
</div>
<Link href={"/chat"}>
<button className="text-sm block w-48 py-2.5 flex px-2 text-left bg-background-200 hover:bg-background-200/80 cursor-pointer rounded">
<BackIcon size={20} className="text-neutral" />
<p className="ml-1">Back to Danswer</p>
</button>
</Link>
{collections.map((collection, collectionInd) => (
<div key={collectionInd}>
<h2 className="text-xs text-strong font-bold pb-2">
@@ -70,7 +77,7 @@ export function AdminSidebar({ collections }: { collections: Collection[] }) {
{collection.items.map((item) => (
<Link key={item.link} href={item.link}>
<button className="text-sm block w-48 py-2.5 px-2 text-left hover:bg-hover rounded">
<div className="">{item.name}</div>
{item.name}
</button>
</Link>
))}

View File

@@ -2,7 +2,7 @@
import React, { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME } from "./contants";
import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME } from "./constants";
function applyMinAndMax(
width: number,

View File

@@ -191,6 +191,12 @@ export const FullSearchBar = ({
<div className="flex justify-end w-full items-center space-x-3 mr-12 px-4 pb-2">
{searchState == "searching" && (
<div key={"Reading"} className="mr-auto relative inline-block">
<span className="loading-text">Searching...</span>
</div>
)}
{searchState == "reading" && (
<div key={"Reading"} className="mr-auto relative inline-block">
<span className="loading-text">Reading Documents...</span>
</div>

View File

@@ -29,12 +29,17 @@ import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
import { ChatSession, SearchSession } from "@/app/chat/interfaces";
import FunctionalHeader from "../chat_search/Header";
import { useSidebarVisibility } from "../chat_search/hooks";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "../resizable/contants";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "../resizable/constants";
import { AGENTIC_SEARCH_TYPE_COOKIE_NAME } from "@/lib/constants";
import Cookies from "js-cookie";
import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo";
export type searchState = "input" | "searching" | "analyzing" | "summarizing";
export type searchState =
| "input"
| "searching"
| "reading"
| "analyzing"
| "summarizing";
const SEARCH_DEFAULT_OVERRIDES_START: SearchDefaultOverrides = {
forceDisplayQA: false,
@@ -231,10 +236,23 @@ export const SearchSection = ({
const updateDocs = (documents: SearchDanswerDocument[]) => {
setTimeout(() => {
if (searchState != "input") {
setSearchState("analyzing");
}
setSearchState((searchState) => {
if (searchState != "input") {
return "reading";
}
return "input";
});
}, 1500);
setTimeout(() => {
setSearchState((searchState) => {
if (searchState != "input") {
return "analyzing";
}
return "input";
});
}, 4500);
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
documents,

View File

@@ -23,7 +23,7 @@ import { cookies } from "next/headers";
import {
SIDEBAR_TOGGLED_COOKIE_NAME,
DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME,
} from "@/components/resizable/contants";
} from "@/components/resizable/constants";
import { hasCompletedWelcomeFlowSS } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
import { fetchAssistantsSS } from "../assistants/fetchAssistantsSS";

View File

@@ -36,10 +36,12 @@ export function getFinalLLM(
const MODELS_SUPPORTING_IMAGES = [
["openai", "gpt-4o"],
["openai", "gpt-4o-mini"],
["openai", "gpt-4-vision-preview"],
["openai", "gpt-4-turbo"],
["openai", "gpt-4-1106-vision-preview"],
["azure", "gpt-4o"],
["azure", "gpt-4o-mini"],
["azure", "gpt-4-vision-preview"],
["azure", "gpt-4-turbo"],
["azure", "gpt-4-1106-vision-preview"],

View File

@@ -61,7 +61,7 @@ export const searchRequestStreamed = async ({
filters: filters,
enable_auto_detect_filters: false,
},
evaluate_response: true,
llm_doc_eval: true,
skip_gen_ai_answer_generation: true,
}),
headers: {

View File

@@ -38,7 +38,11 @@ import {
ColorSlackIcon,
} from "@/components/icons/icons";
import { ValidSources } from "./types";
import { SourceCategory, SourceMetadata } from "./search/interfaces";
import {
DanswerDocument,
SourceCategory,
SourceMetadata,
} from "./search/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { FaAccessibleIcon, FaSlack } from "react-icons/fa";
@@ -290,3 +294,16 @@ export function getSourcesForPersona(persona: Persona): ValidSources[] {
});
return personaSources;
}
function stripTrailingSlash(str: string) {
if (str.substr(-1) === "/") {
return str.substr(0, str.length - 1);
}
return str;
}
export const getTitleFromDocument = (document: DanswerDocument) => {
return stripTrailingSlash(document.document_id).split("/")[
stripTrailingSlash(document.document_id).split("/").length - 1
];
};