From 02804fecce5d02c1cd8354104d983c381314533a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:41:56 +0000 Subject: [PATCH 01/41] chore(deps): bump h11 Bumps the pip group with 1 update in the /extensions/slack-bot directory: [h11](https://github.com/python-hyper/h11). Updates `h11` from 0.14.0 to 0.16.0 - [Commits](https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0) --- updated-dependencies: - dependency-name: h11 dependency-version: 0.16.0 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] --- extensions/slack-bot/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/slack-bot/requirements.txt b/extensions/slack-bot/requirements.txt index 0c588b43..45139d5d 100644 --- a/extensions/slack-bot/requirements.txt +++ b/extensions/slack-bot/requirements.txt @@ -1,6 +1,6 @@ aiohttp>=3,<4 certifi==2024.7.4 -h11==0.14.0 +h11==0.16.0 httpcore==1.0.5 httpx==0.27.0 idna==3.7 From 6515481624350e8ebf4e200712d120a5f977434b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:29:46 +0000 Subject: [PATCH 02/41] chore(deps): bump flow-bin in /extensions/react-widget Bumps [flow-bin](https://github.com/flowtype/flow-bin) from 0.305.0 to 0.305.1. - [Release notes](https://github.com/flowtype/flow-bin/releases) - [Commits](https://github.com/flowtype/flow-bin/commits) --- updated-dependencies: - dependency-name: flow-bin dependency-version: 0.305.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- extensions/react-widget/package-lock.json | 8 ++++---- extensions/react-widget/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/react-widget/package-lock.json b/extensions/react-widget/package-lock.json index 5275956b..6548b565 100644 --- a/extensions/react-widget/package-lock.json +++ b/extensions/react-widget/package-lock.json @@ -19,7 +19,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "dompurify": "^3.1.5", - "flow-bin": "^0.305.0", + "flow-bin": "^0.306.0", "markdown-it": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -5055,9 +5055,9 @@ } }, "node_modules/flow-bin": { - "version": "0.305.0", - "resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.305.0.tgz", - "integrity": "sha512-Y5eILvtOF3pYdy2eratXYHpcluoruPMUtvwyCaBupefVQDrJgKRGjLrDFq3/n8jHX/ITijk2oQb2ll2VxK3uyg==", + "version": "0.306.0", + "resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.306.0.tgz", + "integrity": "sha512-NaAyPsFWZSY59NLCL+6lUXPS5KCO4td9h7XO0kV9VGzRr19sypImQYc0DD1Skm+SHt0/mOIYKI21KtmhQ4KHBg==", "license": "MIT", "bin": { "flow": "cli.js" diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index 3d329799..761c9d83 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -50,7 +50,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "dompurify": "^3.1.5", - "flow-bin": "^0.305.0", + "flow-bin": "^0.306.0", "markdown-it": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", From eaf39bb15b56785825189fa94295d7a6a7c06e4c Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Mon, 23 Mar 2026 10:52:26 +0800 Subject: [PATCH 03/41] feat: add Novita AI as LLM provider Add Novita AI (https://novita.ai) as a new LLM provider option. Novita offers OpenAI-compatible API endpoints with competitive pricing. --- .env-template | 8 + application/core/model_configs.py | 42 +++++ application/core/model_settings.py | 19 +++ application/core/model_utils.py | 1 + application/core/settings.py | 42 ++--- application/llm/handlers/handler_creator.py | 1 + application/llm/novita.py | 4 +- extensions/slack-bot/requirements.txt | 2 +- setup.ps1 | 4 +- setup.sh | 2 +- tests/llm/test_novita_llm.py | 161 ++++++++++++++++++++ 11 files changed, 251 insertions(+), 35 deletions(-) create mode 100644 tests/llm/test_novita_llm.py diff --git a/.env-template b/.env-template index 13575fc3..4e00b75b 100644 --- a/.env-template +++ b/.env-template @@ -3,6 +3,14 @@ LLM_NAME=docsgpt VITE_API_STREAMING=true INTERNAL_KEY= +# Provider-specific API keys (optional - use these to enable multiple providers) +# OPENAI_API_KEY= +# ANTHROPIC_API_KEY= +# GOOGLE_API_KEY= +# GROQ_API_KEY= +# NOVITA_API_KEY= +# OPEN_ROUTER_API_KEY= + # Remote Embeddings (Optional - for using a remote embeddings API instead of local SentenceTransformer) # When set, the app will use the remote API and won't load SentenceTransformer (saves RAM) EMBEDDINGS_BASE_URL= diff --git a/application/core/model_configs.py b/application/core/model_configs.py index 841be925..f8e70759 100644 --- a/application/core/model_configs.py +++ b/application/core/model_configs.py @@ -27,6 +27,8 @@ ANTHROPIC_ATTACHMENTS = IMAGE_ATTACHMENTS OPENROUTER_ATTACHMENTS = IMAGE_ATTACHMENTS +NOVITA_ATTACHMENTS = IMAGE_ATTACHMENTS + OPENAI_MODELS = [ AvailableModel( @@ -193,6 +195,46 @@ OPENROUTER_MODELS = [ ), ] +NOVITA_MODELS = [ + AvailableModel( + id="moonshotai/kimi-k2.5", + provider=ModelProvider.NOVITA, + display_name="Kimi K2.5", + description="MoE model with function calling, structured output, reasoning, and vision", + capabilities=ModelCapabilities( + supports_tools=True, + supports_structured_output=True, + supported_attachment_types=NOVITA_ATTACHMENTS, + context_window=262144, + ), + ), + AvailableModel( + id="zai-org/glm-5", + provider=ModelProvider.NOVITA, + display_name="GLM-5", + description="MoE model with function calling, structured output, and reasoning", + capabilities=ModelCapabilities( + supports_tools=True, + supports_structured_output=True, + supported_attachment_types=[], + context_window=202800, + ), + ), + AvailableModel( + id="minimax/minimax-m2.5", + provider=ModelProvider.NOVITA, + display_name="MiniMax M2.5", + description="MoE model with function calling, structured output, and reasoning", + capabilities=ModelCapabilities( + supports_tools=True, + supports_structured_output=True, + supported_attachment_types=[], + context_window=204800, + ), + ), +] + + AZURE_OPENAI_MODELS = [ AvailableModel( id="azure-gpt-4", diff --git a/application/core/model_settings.py b/application/core/model_settings.py index 475045d1..044c426b 100644 --- a/application/core/model_settings.py +++ b/application/core/model_settings.py @@ -114,6 +114,10 @@ class ModelRegistry: settings.LLM_PROVIDER == "openrouter" and settings.API_KEY ): self._add_openrouter_models(settings) + if settings.NOVITA_API_KEY or ( + settings.LLM_PROVIDER == "novita" and settings.API_KEY + ): + self._add_novita_models(settings) if settings.HUGGINGFACE_API_KEY or ( settings.LLM_PROVIDER == "huggingface" and settings.API_KEY ): @@ -245,6 +249,21 @@ class ModelRegistry: for model in OPENROUTER_MODELS: self.models[model.id] = model + def _add_novita_models(self, settings): + from application.core.model_configs import NOVITA_MODELS + + if settings.NOVITA_API_KEY: + for model in NOVITA_MODELS: + self.models[model.id] = model + return + if settings.LLM_PROVIDER == "novita" and settings.LLM_NAME: + for model in NOVITA_MODELS: + if model.id == settings.LLM_NAME: + self.models[model.id] = model + return + for model in NOVITA_MODELS: + self.models[model.id] = model + def _add_docsgpt_models(self, settings): model_id = "docsgpt-local" model = AvailableModel( diff --git a/application/core/model_utils.py b/application/core/model_utils.py index 94dc8973..95a6a0d7 100644 --- a/application/core/model_utils.py +++ b/application/core/model_utils.py @@ -10,6 +10,7 @@ def get_api_key_for_provider(provider: str) -> Optional[str]: provider_key_map = { "openai": settings.OPENAI_API_KEY, "openrouter": settings.OPEN_ROUTER_API_KEY, + "novita": settings.NOVITA_API_KEY, "anthropic": settings.ANTHROPIC_API_KEY, "google": settings.GOOGLE_API_KEY, "groq": settings.GROQ_API_KEY, diff --git a/application/core/settings.py b/application/core/settings.py index 6d260f77..9dbc1584 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -5,9 +5,7 @@ from typing import Optional from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -current_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -) +current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) class Settings(BaseSettings): @@ -15,15 +13,11 @@ class Settings(BaseSettings): AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, or None LLM_PROVIDER: str = "docsgpt" - LLM_NAME: Optional[str] = ( - None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo - ) + LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2" EMBEDDINGS_BASE_URL: Optional[str] = None # Remote embeddings API URL (OpenAI-compatible) - EMBEDDINGS_KEY: Optional[str] = ( - None # api key for embeddings (if using openai, just copy API_KEY) - ) - + EMBEDDINGS_KEY: Optional[str] = None # api key for embeddings (if using openai, just copy API_KEY) + CELERY_BROKER_URL: str = "redis://localhost:6379/0" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1" MONGO_URI: str = "mongodb://localhost:27017/docsgpt" @@ -45,9 +39,7 @@ class Settings(BaseSettings): PARSE_IMAGE_REMOTE: bool = False DOCLING_OCR_ENABLED: bool = False # Enable OCR for docling parsers (PDF, images) DOCLING_OCR_ATTACHMENTS_ENABLED: bool = False # Enable OCR for docling when parsing attachments - VECTOR_STORE: str = ( - "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" or "pgvector" - ) + VECTOR_STORE: str = "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" or "pgvector" RETRIEVERS_ENABLED: list = ["classic_rag"] AGENT_NAME: str = "classic" FALLBACK_LLM_PROVIDER: Optional[str] = None # provider for fallback llm @@ -55,12 +47,8 @@ class Settings(BaseSettings): FALLBACK_LLM_API_KEY: Optional[str] = None # api key for fallback llm # Google Drive integration - GOOGLE_CLIENT_ID: Optional[str] = ( - None # Replace with your actual Google OAuth client ID - ) - GOOGLE_CLIENT_SECRET: Optional[str] = ( - None # Replace with your actual Google OAuth client secret - ) + GOOGLE_CLIENT_ID: Optional[str] = None # Replace with your actual Google OAuth client ID + GOOGLE_CLIENT_SECRET: Optional[str] = None # Replace with your actual Google OAuth client secret CONNECTOR_REDIRECT_BASE_URI: Optional[str] = ( "http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp) ) @@ -72,7 +60,7 @@ class Settings(BaseSettings): MICROSOFT_AUTHORITY: Optional[str] = None # e.g., "https://login.microsoftonline.com/{tenant_id}" # GitHub source - GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access + GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access # LLM Cache CACHE_REDIS_URL: str = "redis://localhost:6379/2" @@ -90,16 +78,13 @@ class Settings(BaseSettings): GROQ_API_KEY: Optional[str] = None HUGGINGFACE_API_KEY: Optional[str] = None OPEN_ROUTER_API_KEY: Optional[str] = None + NOVITA_API_KEY: Optional[str] = None OPENAI_API_BASE: Optional[str] = None # azure openai api base url OPENAI_API_VERSION: Optional[str] = None # azure openai api version AZURE_DEPLOYMENT_NAME: Optional[str] = None # azure deployment name for answering - AZURE_EMBEDDINGS_DEPLOYMENT_NAME: Optional[str] = ( - None # azure deployment name for embeddings - ) - OPENAI_BASE_URL: Optional[str] = ( - None # openai base url for open ai compatable models - ) + AZURE_EMBEDDINGS_DEPLOYMENT_NAME: Optional[str] = None # azure deployment name for embeddings + OPENAI_BASE_URL: Optional[str] = None # openai base url for open ai compatable models # elasticsearch ELASTIC_CLOUD_ID: Optional[str] = None # cloud id for elasticsearch @@ -141,9 +126,7 @@ class Settings(BaseSettings): # LanceDB vectorstore config LANCEDB_PATH: str = "./data/lancedb" # Path where LanceDB stores its local data - LANCEDB_TABLE_NAME: Optional[str] = ( - "docsgpts" # Name of the table to use for storing vectors - ) + LANCEDB_TABLE_NAME: Optional[str] = "docsgpts" # Name of the table to use for storing vectors FLASK_DEBUG_MODE: bool = False STORAGE_TYPE: str = "local" # local or s3 @@ -180,6 +163,7 @@ class Settings(BaseSettings): "GOOGLE_API_KEY", "GROQ_API_KEY", "HUGGINGFACE_API_KEY", + "NOVITA_API_KEY", "EMBEDDINGS_KEY", "FALLBACK_LLM_API_KEY", "QDRANT_API_KEY", diff --git a/application/llm/handlers/handler_creator.py b/application/llm/handlers/handler_creator.py index 1560eea1..78409ded 100644 --- a/application/llm/handlers/handler_creator.py +++ b/application/llm/handlers/handler_creator.py @@ -7,6 +7,7 @@ class LLMHandlerCreator: handlers = { "openai": OpenAILLMHandler, "google": GoogleLLMHandler, + "novita": OpenAILLMHandler, # Novita uses OpenAI-compatible API "default": OpenAILLMHandler, } diff --git a/application/llm/novita.py b/application/llm/novita.py index b741c4f3..6a5de742 100644 --- a/application/llm/novita.py +++ b/application/llm/novita.py @@ -1,13 +1,13 @@ from application.core.settings import settings from application.llm.openai import OpenAILLM -NOVITA_BASE_URL = "https://api.novita.ai/v3/openai" +NOVITA_BASE_URL = "https://api.novita.ai/openai" class NovitaLLM(OpenAILLM): def __init__(self, api_key=None, user_api_key=None, base_url=None, *args, **kwargs): super().__init__( - api_key=api_key or settings.API_KEY, + api_key=api_key or settings.NOVITA_API_KEY or settings.API_KEY, user_api_key=user_api_key, base_url=base_url or NOVITA_BASE_URL, *args, diff --git a/extensions/slack-bot/requirements.txt b/extensions/slack-bot/requirements.txt index 45139d5d..0c588b43 100644 --- a/extensions/slack-bot/requirements.txt +++ b/extensions/slack-bot/requirements.txt @@ -1,6 +1,6 @@ aiohttp>=3,<4 certifi==2024.7.4 -h11==0.16.0 +h11==0.14.0 httpcore==1.0.5 httpx==0.27.0 idna==3.7 diff --git a/setup.ps1 b/setup.ps1 index 5da9d233..04bf5a39 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -977,8 +977,8 @@ function Connect-CloudAPIProvider { } "7" { # Novita $script:provider_name = "Novita" - $script:llm_name = "novita" - $script:model_name = "deepseek/deepseek-r1" + $script:llm_provider = "novita" + $script:model_name = "moonshotai/kimi-k2.5" Get-APIKey break } diff --git a/setup.sh b/setup.sh index 807a1b37..a9c2688c 100755 --- a/setup.sh +++ b/setup.sh @@ -704,7 +704,7 @@ connect_cloud_api_provider() { 7) # Novita provider_name="Novita" llm_provider="novita" - model_name="deepseek/deepseek-r1" + model_name="moonshotai/kimi-k2.5" get_api_key break ;; b|B) clear; return 1 ;; # Clear screen and Back to Main Menu diff --git a/tests/llm/test_novita_llm.py b/tests/llm/test_novita_llm.py new file mode 100644 index 00000000..7ff847d2 --- /dev/null +++ b/tests/llm/test_novita_llm.py @@ -0,0 +1,161 @@ +"""Tests for the Novita LLM provider. + +Novita uses an OpenAI-compatible API, so NovitaLLM extends OpenAILLM. +These tests verify the Novita-specific configuration is applied correctly. +""" + +import types +from unittest.mock import MagicMock, patch + +import pytest +from application.llm.novita import NOVITA_BASE_URL, NovitaLLM + + +class FakeChatCompletions: + """Fake OpenAI chat completions for testing.""" + + def __init__(self): + self.last_kwargs = None + + class _Msg: + def __init__(self, content=None): + self.content = content + + class _Delta: + def __init__(self, content=None): + self.content = content + + class _Choice: + def __init__(self, content=None, delta=None): + self.message = FakeChatCompletions._Msg(content=content) + self.delta = FakeChatCompletions._Delta(delta=delta) + + class _Response: + def __init__(self, choices=None, lines=None): + self._choices = choices or [] + self._lines = lines or [] + + @property + def choices(self): + return self._choices + + def __iter__(self): + for line in self._lines: + yield line + + def create(self, **kwargs): + self.last_kwargs = kwargs + if not kwargs.get("stream"): + return FakeChatCompletions._Response(choices=[FakeChatCompletions._Choice(content="novita response")]) + return FakeChatCompletions._Response( + lines=[ + FakeChatCompletions._Choice(delta="part1"), + FakeChatCompletions._Choice(delta="part2"), + ] + ) + + +class FakeClient: + """Fake OpenAI client for testing.""" + + def __init__(self): + self.chat = types.SimpleNamespace(completions=FakeChatCompletions()) + + +@pytest.mark.unit +def test_novita_base_url_constant(): + """Verify the Novita base URL is correctly defined.""" + assert NOVITA_BASE_URL == "https://api.novita.ai/openai" + + +@pytest.mark.unit +def test_novita_llm_uses_novita_base_url(): + """Verify NovitaLLM uses the Novita API endpoint.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + # The client should be configured with Novita's base URL + assert llm.client.base_url == NOVITA_BASE_URL + + +@pytest.mark.unit +def test_novita_llm_uses_novita_api_key(): + """Verify NovitaLLM prioritizes NOVITA_API_KEY from settings.""" + with patch("application.llm.novita.settings") as mock_settings: + mock_settings.NOVITA_API_KEY = "novita-test-key" + mock_settings.API_KEY = "fallback-key" + mock_settings.OPENAI_BASE_URL = None + + llm = NovitaLLM(api_key=None, user_api_key=None) + assert llm.api_key == "novita-test-key" + + +@pytest.mark.unit +def test_novita_llm_falls_back_to_api_key(): + """Verify NovitaLLM falls back to API_KEY when NOVITA_API_KEY is not set.""" + with patch("application.llm.novita.settings") as mock_settings: + mock_settings.NOVITA_API_KEY = None + mock_settings.API_KEY = "fallback-key" + mock_settings.OPENAI_BASE_URL = None + + llm = NovitaLLM(api_key=None, user_api_key=None) + assert llm.api_key == "fallback-key" + + +@pytest.mark.unit +def test_novita_llm_explicit_api_key_takes_precedence(): + """Verify explicitly passed API key takes precedence over settings.""" + with patch("application.llm.novita.settings") as mock_settings: + mock_settings.NOVITA_API_KEY = "settings-key" + mock_settings.API_KEY = "fallback-key" + mock_settings.OPENAI_BASE_URL = None + + llm = NovitaLLM(api_key="explicit-key", user_api_key=None) + assert llm.api_key == "explicit-key" + + +@pytest.mark.unit +def test_novita_llm_custom_base_url(): + """Verify custom base_url can override the default Novita URL.""" + custom_url = "https://custom.novita.endpoint/v1" + llm = NovitaLLM(api_key="test-key", user_api_key=None, base_url=custom_url) + assert llm.client.base_url == custom_url + + +@pytest.mark.unit +def test_novita_llm_supports_tools(): + """Verify NovitaLLM supports function calling/tools.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + assert llm.supports_tools() is True + + +@pytest.mark.unit +def test_novita_llm_supports_structured_output(): + """Verify NovitaLLM supports structured output.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + assert llm.supports_structured_output() is True + + +@pytest.mark.unit +def test_novita_llm_gen_calls_client(monkeypatch): + """Verify NovitaLLM.gen calls the OpenAI-compatible client correctly.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + llm.client = FakeClient() + + msgs = [{"role": "user", "content": "hello"}] + result = llm._raw_gen(llm, model="moonshotai/kimi-k2.5", messages=msgs, stream=False) + + assert result == "novita response" + assert llm.client.chat.completions.last_kwargs["model"] == "moonshotai/kimi-k2.5" + + +@pytest.mark.unit +def test_novita_llm_gen_stream_yields_chunks(monkeypatch): + """Verify NovitaLLM streaming yields chunks correctly.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + llm.client = FakeClient() + + msgs = [{"role": "user", "content": "hi"}] + gen = llm._raw_gen_stream(llm, model="moonshotai/kimi-k2.5", messages=msgs, stream=True) + chunks = list(gen) + + assert "part1" in "".join(chunks) + assert "part2" in "".join(chunks) From 05aa9d7cca9656eb2ad88587609f3a516d753e31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:53:52 +0000 Subject: [PATCH 04/41] chore(deps): bump styled-components in /extensions/react-widget Bumps [styled-components](https://github.com/styled-components/styled-components) from 6.1.11 to 6.3.12. - [Release notes](https://github.com/styled-components/styled-components/releases) - [Commits](https://github.com/styled-components/styled-components/compare/v6.1.11...styled-components@6.3.12) --- updated-dependencies: - dependency-name: styled-components dependency-version: 6.3.12 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- extensions/react-widget/package-lock.json | 109 ++++++++++++---------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/extensions/react-widget/package-lock.json b/extensions/react-widget/package-lock.json index 6548b565..ddc434cd 100644 --- a/extensions/react-widget/package-lock.json +++ b/extensions/react-widget/package-lock.json @@ -1833,22 +1833,25 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", - "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", "dependencies": { - "@emotion/memoize": "^0.8.1" + "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" }, "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", @@ -4324,13 +4327,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@swc/helpers/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, - "license": "0BSD" - }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", @@ -4400,9 +4396,10 @@ } }, "node_modules/@types/stylis": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", - "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz", + "integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==", + "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", @@ -4846,9 +4843,10 @@ "license": "CC0-1.0" }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, "node_modules/debug": { "version": "4.3.5", @@ -5681,15 +5679,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -5954,9 +5953,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "funding": [ { "type": "opencollective", @@ -5971,10 +5970,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6195,27 +6195,29 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/styled-components": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.11.tgz", - "integrity": "sha512-Ui0jXPzbp1phYij90h12ksljKGqF8ncGx+pjrNPsSPhbUUjWT2tD1FwGo2LF6USCnbrsIhNngDfodhxbegfEOA==", + "version": "6.3.12", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz", + "integrity": "sha512-hFR6xsVkVYbsdcUlzPYFvFfoc6o2KlV0VvgRIQwSYMtdThM7SCxnjX9efh/cWce2kTq16I/Kl3xM98xiLptsXA==", + "license": "MIT", "dependencies": { - "@emotion/is-prop-valid": "1.2.2", - "@emotion/unitless": "0.8.1", - "@types/stylis": "4.2.5", + "@emotion/is-prop-valid": "1.4.0", + "@emotion/unitless": "0.10.0", + "@types/stylis": "4.2.7", "css-to-react-native": "3.2.0", - "csstype": "3.1.3", - "postcss": "8.4.38", + "csstype": "3.2.3", + "postcss": "8.4.49", "shallowequal": "1.1.0", - "stylis": "4.3.2", - "tslib": "2.6.2" + "stylis": "4.3.6", + "tslib": "2.8.1" }, "engines": { "node": ">= 16" @@ -6227,12 +6229,18 @@ "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/stylis": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", - "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -6315,9 +6323,10 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-fest": { "version": "0.20.2", From db5c69ca763378395be4545881456d9b1fa140d1 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Wed, 25 Mar 2026 01:37:09 +0530 Subject: [PATCH 05/41] (chore)react widget: build fix --- extensions/react-widget/custom.d.ts | 9 --- .../react-widget/src/assets/dislike.svg | 4 -- extensions/react-widget/src/assets/like.svg | 4 -- .../src/components/DocsGPTWidget.tsx | 68 +++++++++++-------- extensions/react-widget/src/types/index.ts | 27 ++++++++ extensions/react-widget/tsconfig.json | 11 ++- 6 files changed, 72 insertions(+), 51 deletions(-) delete mode 100644 extensions/react-widget/src/assets/dislike.svg delete mode 100644 extensions/react-widget/src/assets/like.svg diff --git a/extensions/react-widget/custom.d.ts b/extensions/react-widget/custom.d.ts index 4914b1ec..e69de29b 100644 --- a/extensions/react-widget/custom.d.ts +++ b/extensions/react-widget/custom.d.ts @@ -1,9 +0,0 @@ -declare module "*.svg" { - import * as React from "react"; - - const ReactComponent: React.FunctionComponent< - React.SVGProps & { title?: string } - >; - - export default ReactComponent; - } \ No newline at end of file diff --git a/extensions/react-widget/src/assets/dislike.svg b/extensions/react-widget/src/assets/dislike.svg deleted file mode 100644 index ec1d24c2..00000000 --- a/extensions/react-widget/src/assets/dislike.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/extensions/react-widget/src/assets/like.svg b/extensions/react-widget/src/assets/like.svg deleted file mode 100644 index c49604ed..00000000 --- a/extensions/react-widget/src/assets/like.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/extensions/react-widget/src/components/DocsGPTWidget.tsx b/extensions/react-widget/src/components/DocsGPTWidget.tsx index e778e5a8..61a2961f 100644 --- a/extensions/react-widget/src/components/DocsGPTWidget.tsx +++ b/extensions/react-widget/src/components/DocsGPTWidget.tsx @@ -6,10 +6,22 @@ import { PaperPlaneIcon, RocketIcon, ExclamationTriangleIcon, Cross2Icon } from import { FEEDBACK, MESSAGE_TYPE, Query, Status, WidgetCoreProps, WidgetProps } from '../types/index'; import { fetchAnswerStreaming, sendFeedback } from '../requests/streamingApi'; import { ThemeProvider } from 'styled-components'; -import Like from '../assets/like.svg'; -import Dislike from '../assets/dislike.svg'; import MarkdownIt from 'markdown-it'; +const LikeIcon = (props: React.SVGProps) => ( + + + + +); + +const DislikeIcon = (props: React.SVGProps) => ( + + + + +); + const themes = { dark: { bg: '#222327', @@ -78,14 +90,14 @@ const openContainer = keyframes` height: 100px; } 100% { - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; border-radius: 12px; }` const closeContainer = keyframes` 0% { - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; border-radius: 12px; } 100% { @@ -96,29 +108,29 @@ const closeContainer = keyframes` const fadeIn = keyframes` from { opacity: 0; - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; transform: scale(0.9); } to { opacity: 1; transform: scale(1); - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; } ` const fadeOut = keyframes` from { opacity: 1; - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; } to { opacity: 0; transform: scale(0.9); - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; } ` const scaleAnimation = keyframes` @@ -164,10 +176,10 @@ const WidgetContainer = styled.div<{ $modal?: boolean }>` const StyledContainer = styled.div<{ $isOpen: boolean }>` all: initial; - max-height: ${(props) => props.theme.dimensions.maxHeight}; - max-width: ${(props) => props.theme.dimensions.maxWidth}; - width: ${(props) => props.theme.dimensions.width}; - height: ${(props) => props.theme.dimensions.height} ; + max-height: ${(props) => props.theme.dimensions!.maxHeight}; + max-width: ${(props) => props.theme.dimensions!.maxWidth}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height} ; position: relative; flex-direction: column; justify-content: space-between; @@ -180,7 +192,7 @@ const StyledContainer = styled.div<{ $isOpen: boolean }>` box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.1); padding: 26px 26px 0px 26px; animation: ${({ $isOpen, theme }) => - theme.dimensions.size === 'large' + theme.dimensions!.size === 'large' ? $isOpen ? css`${fadeIn} 150ms ease-in forwards` : css` ${fadeOut} 150ms ease-in forwards` @@ -452,7 +464,7 @@ const Delay = styled(DotAnimation) <{ $delay: number }>` `; const PromptContainer = styled.form` background-color: transparent; - min-height: ${props => props.theme.dimensions.size == 'large' ? '40px' : '23px'}; + min-height: ${props => props.theme.dimensions!.size == 'large' ? '40px' : '23px'}; max-height:150px; display: flex; align-items: end; @@ -462,7 +474,7 @@ const StyledTextarea = styled.textarea` box-sizing: border-box; width: 100%; border: 1px solid #686877; - padding: ${props => props.theme.dimensions.size === 'large' ? '18px 12px 14px 12px' : '8px 12px 4px 12px'}; + padding: ${props => props.theme.dimensions!.size === 'large' ? '18px 12px 14px 12px' : '8px 12px 4px 12px'}; background-color: transparent; font-size: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -475,7 +487,7 @@ const StyledTextarea = styled.textarea` white-space: pre-wrap; line-height: 1.4; text-align: left; - min-height: ${props => props.theme.dimensions.size === 'large' ? '60px' : '40px'}; + min-height: ${props => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; max-height: 140px; overflow-y: auto; scrollbar-width: thin; @@ -503,8 +515,8 @@ const StyledButton = styled.button` background-image: linear-gradient(to bottom right, #5AF0EC, #E80D9D); background-color: rgba(0, 0, 0, 0.3); border-radius: 6px; - min-width: ${props => props.theme.dimensions.size === 'large' ? '60px' : '40px'}; - height: ${props => props.theme.dimensions.size === 'large' ? '60px' : '40px'}; + min-width: ${props => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; + height: ${props => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; margin-left:8px; padding: 0px; @@ -1013,13 +1025,13 @@ export const WidgetCore = ({ e.stopPropagation() handleFeedback("LIKE", index)} }> - + /> } diff --git a/extensions/react-widget/src/types/index.ts b/extensions/react-widget/src/types/index.ts index 5c6dada3..7b62387e 100644 --- a/extensions/react-widget/src/types/index.ts +++ b/extensions/react-widget/src/types/index.ts @@ -1,3 +1,30 @@ +import 'styled-components'; + +declare module 'styled-components' { + export interface DefaultTheme { + bg: string; + text: string; + primary: { + text: string; + bg: string; + }; + secondary: { + text: string; + bg: string; + }; + /** Present only in SearchBar theme */ + name?: string; + /** Present only in DocsGPTWidget theme (always provided when these styled components render) */ + dimensions?: { + size: string; + width: string; + height: string; + maxWidth?: string; + maxHeight?: string; + }; + } +} + export type MESSAGE_TYPE = 'QUESTION' | 'ANSWER' | 'ERROR'; export type Status = 'idle' | 'loading' | 'failed'; diff --git a/extensions/react-widget/tsconfig.json b/extensions/react-widget/tsconfig.json index 88e86216..20540869 100644 --- a/extensions/react-widget/tsconfig.json +++ b/extensions/react-widget/tsconfig.json @@ -1,9 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["src/*", "@/*"] - }, "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], @@ -23,8 +19,11 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, - /* The "typeRoots" configuration specifies the locations where - TypeScript looks for type definitions (.d.ts files) to + "paths": { + "@/*": ["./src/*"] + }, + /* The "typeRoots" configuration specifies the locations where + TypeScript looks for type definitions (.d.ts files) to include in the compilation process.*/ "typeRoots": ["./dist/index.d.ts", "node_modules/@types"] }, From 06e827573cac6ad3b445ffea0fb08169071c1b50 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 00:03:42 +0000 Subject: [PATCH 06/41] fix: jinja --- application/templates/template_engine.py | 10 +++++++--- tests/api/answer/services/test_prompt_renderer.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/application/templates/template_engine.py b/application/templates/template_engine.py index cd779a83..37a55dd6 100644 --- a/application/templates/template_engine.py +++ b/application/templates/template_engine.py @@ -3,12 +3,12 @@ from typing import Any, Dict, List, Optional, Set from jinja2 import ( ChainableUndefined, - Environment, nodes, select_autoescape, TemplateSyntaxError, ) -from jinja2.exceptions import UndefinedError +from jinja2.exceptions import SecurityError, UndefinedError +from jinja2.sandbox import SandboxedEnvironment logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class TemplateEngine: """Jinja2-based template engine for dynamic prompt rendering""" def __init__(self): - self._env = Environment( + self._env = SandboxedEnvironment( undefined=ChainableUndefined, trim_blocks=True, lstrip_blocks=True, @@ -57,6 +57,10 @@ class TemplateEngine: error_msg = f"Undefined variable in template: {e.message}" logger.error(error_msg) raise TemplateRenderError(error_msg) from e + except SecurityError as e: + error_msg = f"Template security violation: {str(e)}" + logger.error(error_msg) + raise TemplateRenderError(error_msg) from e except Exception as e: error_msg = f"Template rendering failed: {str(e)}" logger.error(error_msg) diff --git a/tests/api/answer/services/test_prompt_renderer.py b/tests/api/answer/services/test_prompt_renderer.py index 30fd7045..9556d1f3 100644 --- a/tests/api/answer/services/test_prompt_renderer.py +++ b/tests/api/answer/services/test_prompt_renderer.py @@ -61,6 +61,18 @@ class TestTemplateEngine: with pytest.raises(TemplateRenderError, match="Template syntax error"): engine.render("Hello {{ name", {"name": "World"}) + def test_render_blocks_unsafe_attribute_access(self): + from application.templates.template_engine import ( + TemplateEngine, + TemplateRenderError, + ) + + engine = TemplateEngine() + payload = "{{ cycler.__init__.__globals__.os.popen('id').read() }}" + + with pytest.raises(TemplateRenderError, match="Template security violation"): + engine.render(payload, {}) + def test_validate_template_valid(self): from application.templates.template_engine import TemplateEngine From ed34c2b929f02d801be9efdf2ae597537b89d902 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 11:29:31 +0000 Subject: [PATCH 07/41] fix: tool name in agent builder --- application/api/user/base.py | 4 +++- frontend/src/agents/NewAgent.tsx | 11 +++++++--- frontend/src/agents/SharedAgentCard.tsx | 5 +++-- .../src/agents/workflow/WorkflowBuilder.tsx | 4 +++- frontend/src/utils/toolUtils.ts | 17 ++++++++++++++++ tests/api/user/test_base.py | 20 +++++++++++++++++++ 6 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 frontend/src/utils/toolUtils.ts diff --git a/application/api/user/base.py b/application/api/user/base.py index d4f70aa6..920af776 100644 --- a/application/api/user/base.py +++ b/application/api/user/base.py @@ -152,7 +152,9 @@ def resolve_tool_details(tool_ids): { "id": str(tool["_id"]), "name": tool.get("name", ""), - "display_name": tool.get("displayName", tool.get("name", "")), + "display_name": tool.get("customName") + or tool.get("displayName") + or tool.get("name", ""), } for tool in tools ] diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index af77f6a9..c9a5e55d 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -28,6 +28,7 @@ import { import PromptsModal from '../preferences/PromptsModal'; import Prompts from '../settings/Prompts'; import { UserToolType } from '../settings/types'; +import { getToolDisplayName } from '../utils/toolUtils'; import AgentPreview from './AgentPreview'; import { Agent, ToolSummary } from './types'; import WorkflowBuilder from './workflow/WorkflowBuilder'; @@ -428,8 +429,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const data = await response.json(); const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({ id: tool.id, - label: tool.customName ? tool.customName : tool.displayName, + label: getToolDisplayName(tool), icon: `/toolIcons/tool_${tool.name}.svg`, + name: tool.name, })); setUserTools(tools); }; @@ -956,7 +958,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { > {selectedTools.length > 0 ? selectedTools - .map((tool) => tool.display_name || tool.name) + .map((tool) => getToolDisplayName(tool)) .filter(Boolean) .join(', ') : t('agents.form.placeholders.selectTools')} @@ -973,7 +975,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { .filter((tool) => newSelectedIds.has(tool.id)) .map((tool) => ({ id: String(tool.id), - name: tool.label, + name: + typeof tool.name === 'string' + ? tool.name + : tool.label, display_name: tool.label, })), ) diff --git a/frontend/src/agents/SharedAgentCard.tsx b/frontend/src/agents/SharedAgentCard.tsx index a2b02c87..412750fa 100644 --- a/frontend/src/agents/SharedAgentCard.tsx +++ b/frontend/src/agents/SharedAgentCard.tsx @@ -1,4 +1,5 @@ import AgentImage from '../components/AgentImage'; +import { getToolDisplayName } from '../utils/toolUtils'; import { Agent } from './types'; export default function SharedAgentCard({ agent }: { agent: Agent }) { @@ -64,10 +65,10 @@ export default function SharedAgentCard({ agent }: { agent: Agent }) { > {`${tool.name}{' '} - {tool.name} + {getToolDisplayName(tool)} ))} diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index b1b5faf6..12c9a070 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -55,6 +55,7 @@ import AgentDetailsModal from '../../modals/AgentDetailsModal'; import ConfirmationModal from '../../modals/ConfirmationModal'; import { ActiveState } from '../../models/misc'; import { selectToken } from '../../preferences/preferenceSlice'; +import { getToolDisplayName } from '../../utils/toolUtils'; import { Agent } from '../types'; import { ConditionCase, WorkflowNode } from '../types/workflow'; import MobileBlocker from './components/MobileBlocker'; @@ -92,6 +93,7 @@ interface UserTool { id: string; name: string; displayName: string; + customName?: string; } function validateJsonSchemaConfig(schema: unknown): string | null { @@ -1887,7 +1889,7 @@ function WorkflowBuilderInner() { ({ value: tool.id, - label: tool.displayName, + label: getToolDisplayName(tool), }))} selected={ selectedNode.data.config?.tools || [] diff --git a/frontend/src/utils/toolUtils.ts b/frontend/src/utils/toolUtils.ts new file mode 100644 index 00000000..e97b3429 --- /dev/null +++ b/frontend/src/utils/toolUtils.ts @@ -0,0 +1,17 @@ +type ToolLabelSource = { + customName?: string | null; + displayName?: string | null; + display_name?: string | null; + name?: string | null; +}; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +export function getToolDisplayName(tool: ToolLabelSource): string { + if (isNonEmptyString(tool.customName)) return tool.customName.trim(); + if (isNonEmptyString(tool.displayName)) return tool.displayName.trim(); + if (isNonEmptyString(tool.display_name)) return tool.display_name.trim(); + if (isNonEmptyString(tool.name)) return tool.name.trim(); + return ''; +} diff --git a/tests/api/user/test_base.py b/tests/api/user/test_base.py index 0190ac46..d0843750 100644 --- a/tests/api/user/test_base.py +++ b/tests/api/user/test_base.py @@ -150,6 +150,26 @@ class TestResolveToolDetails: assert result[0]["display_name"] == "test_tool" + def test_prefers_custom_name_when_present(self, mock_mongo_db): + from application.api.user.base import resolve_tool_details + from application.core.settings import settings + + user_tools = mock_mongo_db[settings.MONGO_DB_NAME]["user_tools"] + tool_id = ObjectId() + + user_tools.insert_one( + { + "_id": tool_id, + "name": "calculator", + "displayName": "Calculator Tool", + "customName": "Math Wizard", + } + ) + + result = resolve_tool_details([str(tool_id)]) + + assert result[0]["display_name"] == "Math Wizard" + def test_empty_tool_ids_list(self, mock_mongo_db): from application.api.user.base import resolve_tool_details From 32c268a21e49f4057bd939fd2ed529443fe2f10e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 12:47:17 +0000 Subject: [PATCH 08/41] refactor: simplify agent architecture and remove ReActAgent --- application/agents/agent_creator.py | 3 +- application/agents/base.py | 395 +++----------- application/agents/classic_agent.py | 6 +- application/agents/react_agent.py | 238 -------- application/agents/tool_executor.py | 307 +++++++++++ application/agents/workflows/node_agent.py | 24 +- application/api/answer/routes/answer.py | 13 +- application/api/answer/routes/stream.py | 9 +- .../api/answer/services/stream_processor.py | 134 ++--- application/app.py | 5 + application/prompts/react_final_prompt.txt | 3 - application/prompts/react_planning_prompt.txt | 13 - application/seed/config/premade_agents.yaml | 2 +- frontend/src/agents/NewAgent.tsx | 1 - frontend/src/agents/WorkflowBuilder.tsx | 3 +- .../src/agents/workflow/WorkflowBuilder.tsx | 5 +- results.txt | 12 + tests/agents/test_agent_creator.py | 8 +- tests/agents/test_react_agent.py | 514 ------------------ tests/conftest.py | 2 +- 20 files changed, 467 insertions(+), 1230 deletions(-) delete mode 100644 application/agents/react_agent.py create mode 100644 application/agents/tool_executor.py delete mode 100644 application/prompts/react_final_prompt.txt delete mode 100644 application/prompts/react_planning_prompt.txt create mode 100644 results.txt delete mode 100644 tests/agents/test_react_agent.py diff --git a/application/agents/agent_creator.py b/application/agents/agent_creator.py index e15165bf..6773babd 100644 --- a/application/agents/agent_creator.py +++ b/application/agents/agent_creator.py @@ -1,7 +1,6 @@ import logging from application.agents.classic_agent import ClassicAgent -from application.agents.react_agent import ReActAgent from application.agents.workflow_agent import WorkflowAgent logger = logging.getLogger(__name__) @@ -10,7 +9,7 @@ logger = logging.getLogger(__name__) class AgentCreator: agents = { "classic": ClassicAgent, - "react": ReActAgent, + "react": ClassicAgent, # backwards compat: react falls back to classic "workflow": WorkflowAgent, } diff --git a/application/agents/base.py b/application/agents/base.py index ee55a449..8e6363fc 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -3,20 +3,15 @@ import uuid from abc import ABC, abstractmethod from typing import Dict, Generator, List, Optional -from bson.objectid import ObjectId - -from application.agents.tools.tool_action_parser import ToolActionParser -from application.agents.tools.tool_manager import ToolManager +from application.agents.tool_executor import ToolExecutor from application.core.json_schema_utils import ( JsonSchemaValidationError, normalize_json_schema_payload, ) -from application.core.mongo_db import MongoDB from application.core.settings import settings from application.llm.handlers.handler_creator import LLMHandlerCreator from application.llm.llm_creator import LLMCreator from application.logging import build_stack_data, log_activity, LogContext -from application.security.encryption import decrypt_credentials logger = logging.getLogger(__name__) @@ -41,6 +36,9 @@ class BaseAgent(ABC): limited_request_mode: Optional[bool] = False, request_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["request_limit"], compressed_summary: Optional[str] = None, + llm=None, + llm_handler=None, + tool_executor: Optional[ToolExecutor] = None, ): self.endpoint = endpoint self.llm_name = llm_name @@ -51,22 +49,41 @@ class BaseAgent(ABC): self.prompt = prompt self.decoded_token = decoded_token or {} self.user: str = self.decoded_token.get("sub") - self.tool_config: Dict = {} self.tools: List[Dict] = [] - self.tool_calls: List[Dict] = [] self.chat_history: List[Dict] = chat_history if chat_history is not None else [] - self.llm = LLMCreator.create_llm( - llm_name, - api_key=api_key, - user_api_key=user_api_key, - decoded_token=decoded_token, - model_id=model_id, - agent_id=agent_id, - ) + + # Dependency injection for LLM — fall back to creating if not provided + if llm is not None: + self.llm = llm + else: + self.llm = LLMCreator.create_llm( + llm_name, + api_key=api_key, + user_api_key=user_api_key, + decoded_token=decoded_token, + model_id=model_id, + agent_id=agent_id, + ) + self.retrieved_docs = retrieved_docs or [] - self.llm_handler = LLMHandlerCreator.create_handler( - llm_name if llm_name else "default" - ) + + if llm_handler is not None: + self.llm_handler = llm_handler + else: + self.llm_handler = LLMHandlerCreator.create_handler( + llm_name if llm_name else "default" + ) + + # Tool executor — injected or created + if tool_executor is not None: + self.tool_executor = tool_executor + else: + self.tool_executor = ToolExecutor( + user_api_key=user_api_key, + user=self.user, + decoded_token=decoded_token, + ) + self.attachments = attachments or [] self.json_schema = None if json_schema is not None: @@ -94,333 +111,72 @@ class BaseAgent(ABC): ) -> Generator[Dict, None, None]: pass + # ---- Tool delegation (thin wrappers around ToolExecutor) ---- + + @property + def tool_calls(self) -> List[Dict]: + return self.tool_executor.tool_calls + + @tool_calls.setter + def tool_calls(self, value: List[Dict]): + self.tool_executor.tool_calls = value + def _get_tools(self, api_key: str = None) -> Dict[str, Dict]: - mongo = MongoDB.get_client() - db = mongo[settings.MONGO_DB_NAME] - agents_collection = db["agents"] - tools_collection = db["user_tools"] - - agent_data = agents_collection.find_one({"key": api_key or self.user_api_key}) - tool_ids = agent_data.get("tools", []) if agent_data else [] - - tools = ( - tools_collection.find( - {"_id": {"$in": [ObjectId(tool_id) for tool_id in tool_ids]}} - ) - if tool_ids - else [] - ) - tools = list(tools) - tools_by_id = {str(tool["_id"]): tool for tool in tools} if tools else {} - - return tools_by_id + return self.tool_executor._get_tools_by_api_key(api_key or self.user_api_key) def _get_user_tools(self, user="local"): - mongo = MongoDB.get_client() - db = mongo[settings.MONGO_DB_NAME] - user_tools_collection = db["user_tools"] - user_tools = user_tools_collection.find({"user": user, "status": True}) - user_tools = list(user_tools) - - return {str(i): tool for i, tool in enumerate(user_tools)} + return self.tool_executor._get_user_tools(user) def _build_tool_parameters(self, action): - params = {"type": "object", "properties": {}, "required": []} - for param_type in ["query_params", "headers", "body", "parameters"]: - if param_type in action and action[param_type].get("properties"): - for k, v in action[param_type]["properties"].items(): - if v.get("filled_by_llm", True): - params["properties"][k] = { - key: value - for key, value in v.items() - if key not in ("filled_by_llm", "value", "required") - } - if v.get("required", False): - params["required"].append(k) - return params + return self.tool_executor._build_tool_parameters(action) def _prepare_tools(self, tools_dict): - self.tools = [ - { - "type": "function", - "function": { - "name": f"{action['name']}_{tool_id}", - "description": action["description"], - "parameters": self._build_tool_parameters(action), - }, - } - for tool_id, tool in tools_dict.items() - if ( - (tool["name"] == "api_tool" and "actions" in tool.get("config", {})) - or (tool["name"] != "api_tool" and "actions" in tool) - ) - for action in ( - tool["config"]["actions"].values() - if tool["name"] == "api_tool" - else tool["actions"] - ) - if action.get("active", True) - ] + self.tools = self.tool_executor.prepare_tools_for_llm(tools_dict) def _execute_tool_action(self, tools_dict, call): - parser = ToolActionParser(self.llm.__class__.__name__) - tool_id, action_name, call_args = parser.parse_args(call) - - call_id = getattr(call, "id", None) or str(uuid.uuid4()) - - # Check if parsing failed - - if tool_id is None or action_name is None: - error_message = f"Error: Failed to parse LLM tool call. Tool name: {getattr(call, 'name', 'unknown')}" - logger.error(error_message) - - tool_call_data = { - "tool_name": "unknown", - "call_id": call_id, - "action_name": getattr(call, "name", "unknown"), - "arguments": call_args or {}, - "result": f"Failed to parse tool call. Invalid tool name format: {getattr(call, 'name', 'unknown')}", - } - yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}} - self.tool_calls.append(tool_call_data) - return "Failed to parse tool call.", call_id - # Check if tool_id exists in available tools - - if tool_id not in tools_dict: - error_message = f"Error: Tool ID '{tool_id}' extracted from LLM call not found in available tools_dict. Available IDs: {list(tools_dict.keys())}" - logger.error(error_message) - - # Return error result - - tool_call_data = { - "tool_name": "unknown", - "call_id": call_id, - "action_name": f"{action_name}_{tool_id}", - "arguments": call_args, - "result": f"Tool with ID {tool_id} not found. Available tools: {list(tools_dict.keys())}", - } - yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}} - self.tool_calls.append(tool_call_data) - return f"Tool with ID {tool_id} not found.", call_id - tool_call_data = { - "tool_name": tools_dict[tool_id]["name"], - "call_id": call_id, - "action_name": f"{action_name}_{tool_id}", - "arguments": call_args, - } - yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}} - - tool_data = tools_dict[tool_id] - action_data = ( - tool_data["config"]["actions"][action_name] - if tool_data["name"] == "api_tool" - else next( - action - for action in tool_data["actions"] - if action["name"] == action_name - ) + return self.tool_executor.execute( + tools_dict, call, self.llm.__class__.__name__ ) - query_params, headers, body, parameters = {}, {}, {}, {} - param_types = { - "query_params": query_params, - "headers": headers, - "body": body, - "parameters": parameters, - } - - for param_type, target_dict in param_types.items(): - if param_type in action_data and action_data[param_type].get("properties"): - for param, details in action_data[param_type]["properties"].items(): - if ( - param not in call_args - and "value" in details - and details["value"] - ): - target_dict[param] = details["value"] - for param, value in call_args.items(): - for param_type, target_dict in param_types.items(): - if param_type in action_data and param in action_data[param_type].get( - "properties", {} - ): - target_dict[param] = value - tm = ToolManager(config={}) - - # Prepare tool_config and add tool_id for memory tools - - if tool_data["name"] == "api_tool": - action_config = tool_data["config"]["actions"][action_name] - tool_config = { - "url": action_config["url"], - "method": action_config["method"], - "headers": headers, - "query_params": query_params, - } - if "body_content_type" in action_config: - tool_config["body_content_type"] = action_config.get( - "body_content_type", "application/json" - ) - tool_config["body_encoding_rules"] = action_config.get( - "body_encoding_rules", {} - ) - else: - tool_config = tool_data["config"].copy() if tool_data["config"] else {} - if tool_config.get("encrypted_credentials") and self.user: - decrypted = decrypt_credentials( - tool_config["encrypted_credentials"], self.user - ) - tool_config.update(decrypted) - tool_config["auth_credentials"] = decrypted - tool_config.pop("encrypted_credentials", None) - tool_config["tool_id"] = str(tool_data.get("_id", tool_id)) - if hasattr(self, "conversation_id") and self.conversation_id: - tool_config["conversation_id"] = self.conversation_id - if tool_data["name"] == "mcp_tool": - tool_config["query_mode"] = True - tool = tm.load_tool( - tool_data["name"], - tool_config=tool_config, - user_id=self.user, - ) - resolved_arguments = ( - {"query_params": query_params, "headers": headers, "body": body} - if tool_data["name"] == "api_tool" - else parameters - ) - if tool_data["name"] == "api_tool": - logger.debug( - f"Executing api: {action_name} with query_params: {query_params}, headers: {headers}, body: {body}" - ) - result = tool.execute_action(action_name, **body) - else: - logger.debug(f"Executing tool: {action_name} with args: {call_args}") - result = tool.execute_action(action_name, **parameters) - - get_artifact_id = ( - getattr(tool, "get_artifact_id", None) - if tool_data["name"] != "api_tool" - else None - ) - - artifact_id = None - if callable(get_artifact_id): - try: - artifact_id = get_artifact_id(action_name, **parameters) - except Exception: - logger.exception( - "Failed to extract artifact_id from tool %s for action %s", - tool_data["name"], - action_name, - ) - - artifact_id = str(artifact_id).strip() if artifact_id is not None else "" - if artifact_id: - tool_call_data["artifact_id"] = artifact_id - result_full = str(result) - tool_call_data["resolved_arguments"] = resolved_arguments - tool_call_data["result_full"] = result_full - tool_call_data["result"] = ( - f"{result_full[:50]}..." if len(result_full) > 50 else result_full - ) - - stream_tool_call_data = { - key: value - for key, value in tool_call_data.items() - if key not in {"result_full", "resolved_arguments"} - } - yield {"type": "tool_call", "data": {**stream_tool_call_data, "status": "completed"}} - self.tool_calls.append(tool_call_data) - - return result, call_id - def _get_truncated_tool_calls(self): - return [ - { - "tool_name": tool_call.get("tool_name"), - "call_id": tool_call.get("call_id"), - "action_name": tool_call.get("action_name"), - "arguments": tool_call.get("arguments"), - "artifact_id": tool_call.get("artifact_id"), - "result": ( - f"{str(tool_call['result'])[:50]}..." - if len(str(tool_call["result"])) > 50 - else tool_call["result"] - ), - "status": "completed", - } - for tool_call in self.tool_calls - ] + return self.tool_executor.get_truncated_tool_calls() + + # ---- Context / token management ---- def _calculate_current_context_tokens(self, messages: List[Dict]) -> int: - """ - Calculate total tokens in current context (messages). - - Args: - messages: List of message dicts - - Returns: - Total token count - """ from application.api.answer.services.compression.token_counter import ( TokenCounter, ) - return TokenCounter.count_message_tokens(messages) def _check_context_limit(self, messages: List[Dict]) -> bool: - """ - Check if we're approaching context limit (80%). - - Args: - messages: Current message list - - Returns: - True if at or above 80% of context limit - """ from application.core.model_utils import get_token_limit - from application.core.settings import settings try: - # Calculate current tokens current_tokens = self._calculate_current_context_tokens(messages) self.current_token_count = current_tokens - - # Get context limit for model context_limit = get_token_limit(self.model_id) - - # Calculate threshold (80%) threshold = int(context_limit * settings.COMPRESSION_THRESHOLD_PERCENTAGE) - # Check if we've reached the limit if current_tokens >= threshold: logger.warning( f"Context limit approaching: {current_tokens}/{context_limit} tokens " f"({(current_tokens/context_limit)*100:.1f}%)" ) return True - return False - except Exception as e: logger.error(f"Error checking context limit: {str(e)}", exc_info=True) return False def _validate_context_size(self, messages: List[Dict]) -> None: - """ - Pre-flight validation before calling LLM. Logs warnings but never raises errors. - - Args: - messages: Messages to be sent to LLM - """ from application.core.model_utils import get_token_limit current_tokens = self._calculate_current_context_tokens(messages) self.current_token_count = current_tokens context_limit = get_token_limit(self.model_id) - percentage = (current_tokens / context_limit) * 100 - # Log based on usage level if current_tokens >= context_limit: logger.warning( f"Context at limit: {current_tokens:,}/{context_limit:,} tokens " @@ -435,44 +191,32 @@ class BaseAgent(ABC): ) def _truncate_text_middle(self, text: str, max_tokens: int) -> str: - """ - Truncate text by removing content from the middle, preserving start and end. - - Args: - text: Text to truncate - max_tokens: Maximum tokens allowed - - Returns: - Truncated text with middle removed if needed - """ from application.utils import num_tokens_from_string current_tokens = num_tokens_from_string(text) if current_tokens <= max_tokens: return text - # Estimate chars per token (roughly 4 chars per token for English) chars_per_token = len(text) / current_tokens if current_tokens > 0 else 4 - target_chars = int(max_tokens * chars_per_token * 0.95) # 5% safety margin + target_chars = int(max_tokens * chars_per_token * 0.95) if target_chars <= 0: return "" - # Split: keep 40% from start, 40% from end, remove middle start_chars = int(target_chars * 0.4) end_chars = int(target_chars * 0.4) truncation_marker = "\n\n[... content truncated to fit context limit ...]\n\n" - truncated = text[:start_chars] + truncation_marker + text[-end_chars:] logger.info( f"Truncated text from {current_tokens:,} to ~{max_tokens:,} tokens " f"(removed middle section)" ) - return truncated + # ---- Message building ---- + def _build_messages( self, system_prompt: str, @@ -482,7 +226,6 @@ class BaseAgent(ABC): from application.core.model_utils import get_token_limit from application.utils import num_tokens_from_string - # Append compression summary to system prompt if present if self.compressed_summary: compression_context = ( "\n\n---\n\n" @@ -496,23 +239,18 @@ class BaseAgent(ABC): context_limit = get_token_limit(self.model_id) system_tokens = num_tokens_from_string(system_prompt) - # Reserve 10% for response/tools safety_buffer = int(context_limit * 0.1) available_after_system = context_limit - system_tokens - safety_buffer - # Max tokens for query: 80% of available space (leave room for history) max_query_tokens = int(available_after_system * 0.8) query_tokens = num_tokens_from_string(query) - # Truncate query from middle if it exceeds 80% of available context if query_tokens > max_query_tokens: query = self._truncate_text_middle(query, max_query_tokens) query_tokens = num_tokens_from_string(query) - # Calculate remaining budget for chat history available_for_history = max(available_after_system - query_tokens, 0) - # Truncate chat history to fit within available budget working_history = self._truncate_history_to_fit( self.chat_history, available_for_history, @@ -557,16 +295,6 @@ class BaseAgent(ABC): history: List[Dict], max_tokens: int, ) -> List[Dict]: - """ - Truncate chat history to fit within token budget, keeping most recent messages. - - Args: - history: Full chat history - max_tokens: Maximum tokens allowed for history - - Returns: - Truncated history (most recent messages that fit) - """ from application.utils import num_tokens_from_string if not history or max_tokens <= 0: @@ -575,7 +303,6 @@ class BaseAgent(ABC): truncated = [] current_tokens = 0 - # Iterate from newest to oldest for message in reversed(history): message_tokens = 0 @@ -595,7 +322,7 @@ class BaseAgent(ABC): if current_tokens + message_tokens <= max_tokens: current_tokens += message_tokens - truncated.insert(0, message) # Maintain chronological order + truncated.insert(0, message) else: break @@ -607,13 +334,13 @@ class BaseAgent(ABC): return truncated + # ---- LLM generation ---- + def _llm_gen(self, messages: List[Dict], log_context: Optional[LogContext] = None): - # Pre-flight context validation - fail fast if over limit self._validate_context_size(messages) gen_kwargs = {"model": self.model_id, "messages": messages} if self.attachments: - # Usage accounting only; stripped before provider invocation. gen_kwargs["_usage_attachments"] = self.attachments if ( diff --git a/application/agents/classic_agent.py b/application/agents/classic_agent.py index af8e585f..d6dced9e 100644 --- a/application/agents/classic_agent.py +++ b/application/agents/classic_agent.py @@ -15,11 +15,7 @@ class ClassicAgent(BaseAgent): ) -> Generator[Dict, None, None]: """Core generator function for ClassicAgent execution flow""" - tools_dict = ( - self._get_user_tools(self.user) - if not self.user_api_key - else self._get_tools(self.user_api_key) - ) + tools_dict = self.tool_executor.get_tools() self._prepare_tools(tools_dict) messages = self._build_messages(self.prompt, query) diff --git a/application/agents/react_agent.py b/application/agents/react_agent.py deleted file mode 100644 index 92be75f6..00000000 --- a/application/agents/react_agent.py +++ /dev/null @@ -1,238 +0,0 @@ -import logging -import os -from typing import Any, Dict, Generator, List - -from application.agents.base import BaseAgent -from application.logging import build_stack_data, LogContext - -logger = logging.getLogger(__name__) - -MAX_ITERATIONS_REASONING = 10 - -current_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -) -with open( - os.path.join(current_dir, "application/prompts", "react_planning_prompt.txt"), "r" -) as f: - PLANNING_PROMPT_TEMPLATE = f.read() -with open( - os.path.join(current_dir, "application/prompts", "react_final_prompt.txt"), "r" -) as f: - FINAL_PROMPT_TEMPLATE = f.read() - - -class ReActAgent(BaseAgent): - """ - Research and Action (ReAct) Agent - Advanced reasoning agent with iterative planning. - - Implements a think-act-observe loop for complex problem-solving: - 1. Creates a strategic plan based on the query - 2. Executes tools and gathers observations - 3. Iteratively refines approach until satisfied - 4. Synthesizes final answer from all observations - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.plan: str = "" - self.observations: List[str] = [] - - def _gen_inner( - self, query: str, log_context: LogContext - ) -> Generator[Dict, None, None]: - """Execute ReAct reasoning loop with planning, action, and observation cycles""" - - self._reset_state() - - tools_dict = ( - self._get_tools(self.user_api_key) - if self.user_api_key - else self._get_user_tools(self.user) - ) - self._prepare_tools(tools_dict) - - for iteration in range(1, MAX_ITERATIONS_REASONING + 1): - yield {"thought": f"Reasoning... (iteration {iteration})\n\n"} - - yield from self._planning_phase(query, log_context) - - if not self.plan: - logger.warning( - f"ReActAgent: No plan generated in iteration {iteration}" - ) - break - self.observations.append(f"Plan (iteration {iteration}): {self.plan}") - - satisfied = yield from self._execution_phase(query, tools_dict, log_context) - - if satisfied: - logger.info("ReActAgent: Goal satisfied, stopping reasoning loop") - break - yield from self._synthesis_phase(query, log_context) - - def _reset_state(self): - """Reset agent state for new query""" - self.plan = "" - self.observations = [] - - def _planning_phase( - self, query: str, log_context: LogContext - ) -> Generator[Dict, None, None]: - """Generate strategic plan for query""" - logger.info("ReActAgent: Creating plan...") - - plan_prompt = self._build_planning_prompt(query) - messages = [{"role": "user", "content": plan_prompt}] - - plan_stream = self.llm.gen_stream( - model=self.model_id, - messages=messages, - tools=self.tools if self.tools else None, - ) - - if log_context: - log_context.stacks.append( - {"component": "planning_llm", "data": build_stack_data(self.llm)} - ) - plan_parts = [] - for chunk in plan_stream: - content = self._extract_content(chunk) - if content: - plan_parts.append(content) - yield {"thought": content} - self.plan = "".join(plan_parts) - - def _execution_phase( - self, query: str, tools_dict: Dict, log_context: LogContext - ) -> Generator[bool, None, None]: - """Execute plan with tool calls and observations""" - execution_prompt = self._build_execution_prompt(query) - messages = self._build_messages(execution_prompt, query) - - llm_response = self._llm_gen(messages, log_context) - initial_content = self._extract_content(llm_response) - - if initial_content: - self.observations.append(f"Initial response: {initial_content}") - processed_response = self._llm_handler( - llm_response, tools_dict, messages, log_context - ) - - for tool_call in self.tool_calls: - observation = ( - f"Executed: {tool_call.get('tool_name', 'Unknown')} " - f"with args {tool_call.get('arguments', {})}. " - f"Result: {str(tool_call.get('result', ''))[:200]}" - ) - self.observations.append(observation) - final_content = self._extract_content(processed_response) - if final_content: - self.observations.append(f"Response after tools: {final_content}") - if log_context: - log_context.stacks.append( - { - "component": "agent_tool_calls", - "data": {"tool_calls": self.tool_calls.copy()}, - } - ) - yield {"sources": self.retrieved_docs} - yield {"tool_calls": self._get_truncated_tool_calls()} - - return "SATISFIED" in (final_content or "") - - def _synthesis_phase( - self, query: str, log_context: LogContext - ) -> Generator[Dict, None, None]: - """Synthesize final answer from all observations""" - logger.info("ReActAgent: Generating final answer...") - - final_prompt = self._build_final_answer_prompt(query) - messages = [{"role": "user", "content": final_prompt}] - - final_stream = self.llm.gen_stream( - model=self.model_id, messages=messages, tools=None - ) - - if log_context: - log_context.stacks.append( - {"component": "final_answer_llm", "data": build_stack_data(self.llm)} - ) - for chunk in final_stream: - content = self._extract_content(chunk) - if content: - yield {"answer": content} - - def _build_planning_prompt(self, query: str) -> str: - """Build planning phase prompt""" - prompt = PLANNING_PROMPT_TEMPLATE.replace("{query}", query) - prompt = prompt.replace("{prompt}", self.prompt or "") - prompt = prompt.replace("{summaries}", "") - prompt = prompt.replace("{observations}", "\n".join(self.observations)) - return prompt - - def _build_execution_prompt(self, query: str) -> str: - """Build execution phase prompt with plan and observations""" - observations_str = "\n".join(self.observations) - - if len(observations_str) > 20000: - observations_str = observations_str[:20000] + "\n...[truncated]" - return ( - f"{self.prompt or ''}\n\n" - f"Follow this plan:\n{self.plan}\n\n" - f"Observations:\n{observations_str}\n\n" - f"If sufficient data exists to answer '{query}', respond with 'SATISFIED'. " - f"Otherwise, continue executing the plan." - ) - - def _build_final_answer_prompt(self, query: str) -> str: - """Build final synthesis prompt""" - observations_str = "\n".join(self.observations) - - if len(observations_str) > 10000: - observations_str = observations_str[:10000] + "\n...[truncated]" - logger.warning("ReActAgent: Observations truncated for final answer") - return FINAL_PROMPT_TEMPLATE.format(query=query, observations=observations_str) - - def _extract_content(self, response: Any) -> str: - """Extract text content from various LLM response formats""" - if not response: - return "" - collected = [] - - if isinstance(response, str): - return response - if hasattr(response, "message") and hasattr(response.message, "content"): - if response.message.content: - return response.message.content - if hasattr(response, "choices") and response.choices: - if hasattr(response.choices[0], "message"): - content = response.choices[0].message.content - if content: - return content - if hasattr(response, "content") and isinstance(response.content, list): - if response.content and hasattr(response.content[0], "text"): - return response.content[0].text - try: - for chunk in response: - content_piece = "" - - if hasattr(chunk, "choices") and chunk.choices: - if hasattr(chunk.choices[0], "delta"): - delta_content = chunk.choices[0].delta.content - if delta_content: - content_piece = delta_content - elif hasattr(chunk, "type") and chunk.type == "content_block_delta": - if hasattr(chunk, "delta") and hasattr(chunk.delta, "text"): - content_piece = chunk.delta.text - elif isinstance(chunk, str): - content_piece = chunk - if content_piece: - collected.append(content_piece) - except (TypeError, AttributeError): - logger.debug( - f"Response not iterable or unexpected format: {type(response)}" - ) - except Exception as e: - logger.error(f"Error extracting content: {e}") - return "".join(collected) \ No newline at end of file diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py new file mode 100644 index 00000000..0fde060d --- /dev/null +++ b/application/agents/tool_executor.py @@ -0,0 +1,307 @@ +import logging +import uuid +from typing import Dict, List, Optional + +from bson.objectid import ObjectId + +from application.agents.tools.tool_action_parser import ToolActionParser +from application.agents.tools.tool_manager import ToolManager +from application.core.mongo_db import MongoDB +from application.core.settings import settings +from application.security.encryption import decrypt_credentials + +logger = logging.getLogger(__name__) + + +class ToolExecutor: + """Handles tool discovery, preparation, and execution. + + Extracted from BaseAgent to separate concerns and enable tool caching. + """ + + def __init__( + self, + user_api_key: Optional[str] = None, + user: Optional[str] = None, + decoded_token: Optional[Dict] = None, + ): + self.user_api_key = user_api_key + self.user = user + self.decoded_token = decoded_token + self.tool_calls: List[Dict] = [] + self._loaded_tools: Dict[str, object] = {} + self.conversation_id: Optional[str] = None + + def get_tools(self) -> Dict[str, Dict]: + """Load tool configs from DB based on user context.""" + if self.user_api_key: + return self._get_tools_by_api_key(self.user_api_key) + return self._get_user_tools(self.user or "local") + + def _get_tools_by_api_key(self, api_key: str) -> Dict[str, Dict]: + mongo = MongoDB.get_client() + db = mongo[settings.MONGO_DB_NAME] + agents_collection = db["agents"] + tools_collection = db["user_tools"] + + agent_data = agents_collection.find_one({"key": api_key}) + tool_ids = agent_data.get("tools", []) if agent_data else [] + + tools = ( + tools_collection.find( + {"_id": {"$in": [ObjectId(tool_id) for tool_id in tool_ids]}} + ) + if tool_ids + else [] + ) + tools = list(tools) + return {str(tool["_id"]): tool for tool in tools} if tools else {} + + def _get_user_tools(self, user: str = "local") -> Dict[str, Dict]: + mongo = MongoDB.get_client() + db = mongo[settings.MONGO_DB_NAME] + user_tools_collection = db["user_tools"] + user_tools = user_tools_collection.find({"user": user, "status": True}) + user_tools = list(user_tools) + return {str(i): tool for i, tool in enumerate(user_tools)} + + def prepare_tools_for_llm(self, tools_dict: Dict) -> List[Dict]: + """Convert tool configs to LLM function schemas.""" + return [ + { + "type": "function", + "function": { + "name": f"{action['name']}_{tool_id}", + "description": action["description"], + "parameters": self._build_tool_parameters(action), + }, + } + for tool_id, tool in tools_dict.items() + if ( + (tool["name"] == "api_tool" and "actions" in tool.get("config", {})) + or (tool["name"] != "api_tool" and "actions" in tool) + ) + for action in ( + tool["config"]["actions"].values() + if tool["name"] == "api_tool" + else tool["actions"] + ) + if action.get("active", True) + ] + + def _build_tool_parameters(self, action: Dict) -> Dict: + params = {"type": "object", "properties": {}, "required": []} + for param_type in ["query_params", "headers", "body", "parameters"]: + if param_type in action and action[param_type].get("properties"): + for k, v in action[param_type]["properties"].items(): + if v.get("filled_by_llm", True): + params["properties"][k] = { + key: value + for key, value in v.items() + if key not in ("filled_by_llm", "value", "required") + } + if v.get("required", False): + params["required"].append(k) + return params + + def execute(self, tools_dict: Dict, call, llm_class_name: str): + """Execute a tool call. Yields status events, returns (result, call_id).""" + parser = ToolActionParser(llm_class_name) + tool_id, action_name, call_args = parser.parse_args(call) + + call_id = getattr(call, "id", None) or str(uuid.uuid4()) + + if tool_id is None or action_name is None: + error_message = f"Error: Failed to parse LLM tool call. Tool name: {getattr(call, 'name', 'unknown')}" + logger.error(error_message) + + tool_call_data = { + "tool_name": "unknown", + "call_id": call_id, + "action_name": getattr(call, "name", "unknown"), + "arguments": call_args or {}, + "result": f"Failed to parse tool call. Invalid tool name format: {getattr(call, 'name', 'unknown')}", + } + yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}} + self.tool_calls.append(tool_call_data) + return "Failed to parse tool call.", call_id + + if tool_id not in tools_dict: + error_message = f"Error: Tool ID '{tool_id}' extracted from LLM call not found in available tools_dict. Available IDs: {list(tools_dict.keys())}" + logger.error(error_message) + + tool_call_data = { + "tool_name": "unknown", + "call_id": call_id, + "action_name": f"{action_name}_{tool_id}", + "arguments": call_args, + "result": f"Tool with ID {tool_id} not found. Available tools: {list(tools_dict.keys())}", + } + yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}} + self.tool_calls.append(tool_call_data) + return f"Tool with ID {tool_id} not found.", call_id + + tool_call_data = { + "tool_name": tools_dict[tool_id]["name"], + "call_id": call_id, + "action_name": f"{action_name}_{tool_id}", + "arguments": call_args, + } + yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}} + + tool_data = tools_dict[tool_id] + action_data = ( + tool_data["config"]["actions"][action_name] + if tool_data["name"] == "api_tool" + else next( + action + for action in tool_data["actions"] + if action["name"] == action_name + ) + ) + + query_params, headers, body, parameters = {}, {}, {}, {} + param_types = { + "query_params": query_params, + "headers": headers, + "body": body, + "parameters": parameters, + } + + for param_type, target_dict in param_types.items(): + if param_type in action_data and action_data[param_type].get("properties"): + for param, details in action_data[param_type]["properties"].items(): + if ( + param not in call_args + and "value" in details + and details["value"] + ): + target_dict[param] = details["value"] + for param, value in call_args.items(): + for param_type, target_dict in param_types.items(): + if param_type in action_data and param in action_data[param_type].get( + "properties", {} + ): + target_dict[param] = value + + # Load tool (with caching) + tool = self._get_or_load_tool(tool_data, tool_id, action_name) + + resolved_arguments = ( + {"query_params": query_params, "headers": headers, "body": body} + if tool_data["name"] == "api_tool" + else parameters + ) + if tool_data["name"] == "api_tool": + logger.debug( + f"Executing api: {action_name} with query_params: {query_params}, headers: {headers}, body: {body}" + ) + result = tool.execute_action(action_name, **body) + else: + logger.debug(f"Executing tool: {action_name} with args: {call_args}") + result = tool.execute_action(action_name, **parameters) + + get_artifact_id = ( + getattr(tool, "get_artifact_id", None) + if tool_data["name"] != "api_tool" + else None + ) + + artifact_id = None + if callable(get_artifact_id): + try: + artifact_id = get_artifact_id(action_name, **parameters) + except Exception: + logger.exception( + "Failed to extract artifact_id from tool %s for action %s", + tool_data["name"], + action_name, + ) + + artifact_id = str(artifact_id).strip() if artifact_id is not None else "" + if artifact_id: + tool_call_data["artifact_id"] = artifact_id + result_full = str(result) + tool_call_data["resolved_arguments"] = resolved_arguments + tool_call_data["result_full"] = result_full + tool_call_data["result"] = ( + f"{result_full[:50]}..." if len(result_full) > 50 else result_full + ) + + stream_tool_call_data = { + key: value + for key, value in tool_call_data.items() + if key not in {"result_full", "resolved_arguments"} + } + yield {"type": "tool_call", "data": {**stream_tool_call_data, "status": "completed"}} + self.tool_calls.append(tool_call_data) + + return result, call_id + + def _get_or_load_tool(self, tool_data: Dict, tool_id: str, action_name: str): + """Load a tool, using cache when possible.""" + cache_key = f"{tool_data['name']}:{tool_id}:{self.user or ''}" + if cache_key in self._loaded_tools: + return self._loaded_tools[cache_key] + + tm = ToolManager(config={}) + + if tool_data["name"] == "api_tool": + action_config = tool_data["config"]["actions"][action_name] + tool_config = { + "url": action_config["url"], + "method": action_config["method"], + "headers": {}, + "query_params": {}, + } + if "body_content_type" in action_config: + tool_config["body_content_type"] = action_config.get( + "body_content_type", "application/json" + ) + tool_config["body_encoding_rules"] = action_config.get( + "body_encoding_rules", {} + ) + else: + tool_config = tool_data["config"].copy() if tool_data["config"] else {} + if tool_config.get("encrypted_credentials") and self.user: + decrypted = decrypt_credentials( + tool_config["encrypted_credentials"], self.user + ) + tool_config.update(decrypted) + tool_config["auth_credentials"] = decrypted + tool_config.pop("encrypted_credentials", None) + tool_config["tool_id"] = str(tool_data.get("_id", tool_id)) + if self.conversation_id: + tool_config["conversation_id"] = self.conversation_id + if tool_data["name"] == "mcp_tool": + tool_config["query_mode"] = True + + tool = tm.load_tool( + tool_data["name"], + tool_config=tool_config, + user_id=self.user, + ) + + # Don't cache api_tool since config varies by action + if tool_data["name"] != "api_tool": + self._loaded_tools[cache_key] = tool + + return tool + + def get_truncated_tool_calls(self) -> List[Dict]: + return [ + { + "tool_name": tool_call.get("tool_name"), + "call_id": tool_call.get("call_id"), + "action_name": tool_call.get("action_name"), + "arguments": tool_call.get("arguments"), + "artifact_id": tool_call.get("artifact_id"), + "result": ( + f"{str(tool_call['result'])[:50]}..." + if len(str(tool_call["result"])) > 50 + else tool_call["result"] + ), + "status": "completed", + } + for tool_call in self.tool_calls + ] diff --git a/application/agents/workflows/node_agent.py b/application/agents/workflows/node_agent.py index d3c9d607..c26f6464 100644 --- a/application/agents/workflows/node_agent.py +++ b/application/agents/workflows/node_agent.py @@ -4,7 +4,6 @@ from typing import Any, Dict, List, Optional, Type from application.agents.base import BaseAgent from application.agents.classic_agent import ClassicAgent -from application.agents.react_agent import ReActAgent from application.agents.workflows.schemas import AgentType @@ -57,32 +56,11 @@ class WorkflowNodeClassicAgent(ToolFilterMixin, ClassicAgent): self._allowed_tool_ids = tool_ids or [] -class WorkflowNodeReActAgent(ToolFilterMixin, ReActAgent): - - def __init__( - self, - endpoint: str, - llm_name: str, - model_id: str, - api_key: str, - tool_ids: Optional[List[str]] = None, - **kwargs, - ): - super().__init__( - endpoint=endpoint, - llm_name=llm_name, - model_id=model_id, - api_key=api_key, - **kwargs, - ) - self._allowed_tool_ids = tool_ids or [] - - class WorkflowNodeAgentFactory: _agents: Dict[AgentType, Type[BaseAgent]] = { AgentType.CLASSIC: WorkflowNodeClassicAgent, - AgentType.REACT: WorkflowNodeReActAgent, + AgentType.REACT: WorkflowNodeClassicAgent, # backwards compat } @classmethod diff --git a/application/api/answer/routes/answer.py b/application/api/answer/routes/answer.py index b90ffa15..f3111605 100644 --- a/application/api/answer/routes/answer.py +++ b/application/api/answer/routes/answer.py @@ -74,21 +74,10 @@ class AnswerResource(Resource, BaseAnswerResource): decoded_token = getattr(request, "decoded_token", None) processor = StreamProcessor(data, decoded_token) try: - processor.initialize() + agent = processor.build_agent(data.get("question", "")) if not processor.decoded_token: return make_response({"error": "Unauthorized"}, 401) - docs_together, docs_list = processor.pre_fetch_docs( - data.get("question", "") - ) - tools_data = processor.pre_fetch_tools() - - agent = processor.create_agent( - docs_together=docs_together, - docs=docs_list, - tools_data=tools_data, - ) - if error := self.check_usage(processor.agent_config): return error diff --git a/application/api/answer/routes/stream.py b/application/api/answer/routes/stream.py index d1a71b25..c2cc0ec6 100644 --- a/application/api/answer/routes/stream.py +++ b/application/api/answer/routes/stream.py @@ -80,7 +80,7 @@ class StreamResource(Resource, BaseAnswerResource): decoded_token = getattr(request, "decoded_token", None) processor = StreamProcessor(data, decoded_token) try: - processor.initialize() + agent = processor.build_agent(data["question"]) if not processor.decoded_token: return Response( self.error_stream_generate("Unauthorized"), @@ -88,13 +88,6 @@ class StreamResource(Resource, BaseAnswerResource): mimetype="text/event-stream", ) - docs_together, docs_list = processor.pre_fetch_docs(data["question"]) - tools_data = processor.pre_fetch_tools() - - agent = processor.create_agent( - docs_together=docs_together, docs=docs_list, tools_data=tools_data - ) - if error := self.check_usage(processor.agent_config): return error return Response( diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index 0cf47094..61a4d4c1 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -91,6 +91,7 @@ class StreamProcessor: self.is_shared_usage = False self.shared_token = None self.agent_id = self.data.get("agent_id") + self.agent_key = None self.model_id: Optional[str] = None self.conversation_service = ConversationService() self.compression_orchestrator = CompressionOrchestrator( @@ -111,6 +112,21 @@ class StreamProcessor: self._load_conversation_history() self._process_attachments() + def build_agent(self, question: str): + """One call to go from request data to a ready-to-run agent. + + Combines initialize(), pre_fetch_docs(), pre_fetch_tools(), and + create_agent() into a single convenience method. + """ + self.initialize() + docs_together, docs_list = self.pre_fetch_docs(question) + tools_data = self.pre_fetch_tools() + return self.create_agent( + docs_together=docs_together, + docs=docs_list, + tools_data=tools_data, + ) + def _load_conversation_history(self): """Load conversation history either from DB or request""" if self.conversation_id and self.initial_user_id: @@ -135,14 +151,8 @@ class StreamProcessor: ) def _handle_compression(self, conversation: Dict[str, Any]): - """ - Handle conversation compression logic using orchestrator. - - Args: - conversation: Full conversation document - """ + """Handle conversation compression logic using orchestrator.""" try: - # Use orchestrator to handle all compression logic result = self.compression_orchestrator.compress_if_needed( conversation_id=self.conversation_id, user_id=self.initial_user_id, @@ -158,7 +168,6 @@ class StreamProcessor: ] return - # Set compressed summary if compression was performed if result.compression_performed and result.compressed_summary: self.compressed_summary = result.compressed_summary self.compressed_summary_tokens = TokenCounter.count_message_tokens( @@ -169,7 +178,6 @@ class StreamProcessor: f"+ {len(result.recent_queries)} recent messages" ) - # Build history from recent queries self.history = result.as_history() except Exception as e: @@ -177,7 +185,6 @@ class StreamProcessor: f"Error handling compression, falling back to standard history: {str(e)}", exc_info=True, ) - # Fallback to original behavior self.history = [ {"prompt": query["prompt"], "response": query["response"]} for query in conversation.get("queries", []) @@ -191,9 +198,6 @@ class StreamProcessor: ) def _get_attachments_content(self, attachment_ids, user_id): - """ - Retrieve content from attachment documents based on their IDs. - """ if not attachment_ids: return [] attachments = [] @@ -202,7 +206,6 @@ class StreamProcessor: attachment_doc = self.attachments_collection.find_one( {"_id": ObjectId(attachment_id), "user": user_id} ) - if attachment_doc: attachments.append(attachment_doc) except Exception as e: @@ -232,7 +235,6 @@ class StreamProcessor: ) self.model_id = requested_model else: - # Check if agent has a default model configured agent_default_model = self.agent_config.get("default_model_id", "") if agent_default_model and validate_model_id(agent_default_model): self.model_id = agent_default_model @@ -285,7 +287,6 @@ class StreamProcessor: data["source"] = "default" else: data["source"] = None - # Handle multiple sources sources = data.get("sources", []) if sources and isinstance(sources, list): @@ -311,7 +312,6 @@ class StreamProcessor: else: data["sources"] = [] - # Preserve model configuration from agent data["default_model_id"] = data.get("default_model_id", "") return data @@ -376,7 +376,10 @@ class StreamProcessor: return None def _configure_agent(self): - """Configure the agent based on request data""" + """Configure the agent based on request data. + + Unified flow: resolve the effective API key, then extract config once. + """ agent_id = self._resolve_agent_id() self.agent_key, self.is_shared_usage, self.shared_token = self._get_agent_key( @@ -384,55 +387,36 @@ class StreamProcessor: ) self.agent_id = str(agent_id) if agent_id else None - api_key = self.data.get("api_key") - if api_key: - data_key = self._get_data_from_api_key(api_key) + # Determine the effective API key (explicit > agent-derived) + effective_key = self.data.get("api_key") or self.agent_key + + if effective_key: + data_key = self._get_data_from_api_key(effective_key) if data_key.get("_id"): self.agent_id = str(data_key.get("_id")) + self.agent_config.update( { "prompt_id": data_key.get("prompt_id", "default"), "agent_type": data_key.get("agent_type", settings.AGENT_NAME), - "user_api_key": api_key, + "user_api_key": effective_key, "json_schema": data_key.get("json_schema"), "default_model_id": data_key.get("default_model_id", ""), } ) - self.initial_user_id = data_key.get("user") - self.decoded_token = {"sub": data_key.get("user")} - if data_key.get("source"): - self.source = {"active_docs": data_key["source"]} - if data_key.get("workflow"): - self.agent_config["workflow"] = data_key["workflow"] - self.agent_config["workflow_owner"] = data_key.get("user") - if data_key.get("retriever"): - self.retriever_config["retriever_name"] = data_key["retriever"] - if data_key.get("chunks") is not None: - try: - self.retriever_config["chunks"] = int(data_key["chunks"]) - except (ValueError, TypeError): - logger.warning( - f"Invalid chunks value: {data_key['chunks']}, using default value 2" - ) - self.retriever_config["chunks"] = 2 - elif self.agent_key: - data_key = self._get_data_from_api_key(self.agent_key) - if data_key.get("_id"): - self.agent_id = str(data_key.get("_id")) - self.agent_config.update( - { - "prompt_id": data_key.get("prompt_id", "default"), - "agent_type": data_key.get("agent_type", settings.AGENT_NAME), - "user_api_key": self.agent_key, - "json_schema": data_key.get("json_schema"), - "default_model_id": data_key.get("default_model_id", ""), - } - ) - self.decoded_token = ( - self.decoded_token - if self.is_shared_usage - else {"sub": data_key.get("user")} - ) + + # Set identity context + if self.data.get("api_key"): + # External API key: use the key owner's identity + self.initial_user_id = data_key.get("user") + self.decoded_token = {"sub": data_key.get("user")} + elif self.is_shared_usage: + # Shared agent: keep the caller's identity + pass + else: + # Owner using their own agent + self.decoded_token = {"sub": data_key.get("user")} + if data_key.get("source"): self.source = {"active_docs": data_key["source"]} if data_key.get("workflow"): @@ -449,6 +433,7 @@ class StreamProcessor: ) self.retriever_config["chunks"] = 2 else: + # No API key — default/workflow configuration agent_type = settings.AGENT_NAME if self.data.get("workflow") and isinstance( self.data.get("workflow"), dict @@ -531,12 +516,7 @@ class StreamProcessor: return None, None def pre_fetch_tools(self) -> Optional[Dict[str, Any]]: - """Pre-fetch tool data for template rendering before agent creation - - Can be controlled via: - 1. Global setting: ENABLE_TOOL_PREFETCH in .env - 2. Per-request: disable_tool_prefetch in request data - """ + """Pre-fetch tool data for template rendering before agent creation""" if not settings.ENABLE_TOOL_PREFETCH: logger.info( "Tool pre-fetching disabled globally via ENABLE_TOOL_PREFETCH setting" @@ -781,6 +761,31 @@ class StreamProcessor: agent_type = self.agent_config["agent_type"] + # Create LLM and handler (dependency injection) + from application.llm.llm_creator import LLMCreator + from application.llm.handlers.handler_creator import LLMHandlerCreator + from application.agents.tool_executor import ToolExecutor + + llm = LLMCreator.create_llm( + provider or settings.LLM_PROVIDER, + api_key=system_api_key, + user_api_key=self.agent_config["user_api_key"], + decoded_token=self.decoded_token, + model_id=self.model_id, + agent_id=self.agent_id, + ) + llm_handler = LLMHandlerCreator.create_handler( + provider if provider else "default" + ) + + user = self.decoded_token.get("sub") if self.decoded_token else None + tool_executor = ToolExecutor( + user_api_key=self.agent_config["user_api_key"], + user=user, + decoded_token=self.decoded_token, + ) + tool_executor.conversation_id = self.conversation_id + # Base agent kwargs agent_kwargs = { "endpoint": "stream", @@ -796,6 +801,9 @@ class StreamProcessor: "attachments": self.attachments, "json_schema": self.agent_config.get("json_schema"), "compressed_summary": self.compressed_summary, + "llm": llm, + "llm_handler": llm_handler, + "tool_executor": tool_executor, } # Workflow-specific kwargs for workflow agents diff --git a/application/app.py b/application/app.py index bb9b3b1b..aed069e0 100644 --- a/application/app.py +++ b/application/app.py @@ -72,6 +72,11 @@ def home(): return "Welcome to DocsGPT Backend!" +@app.route("/api/health") +def health(): + return jsonify({"status": "ok"}) + + @app.route("/api/config") def get_config(): response = { diff --git a/application/prompts/react_final_prompt.txt b/application/prompts/react_final_prompt.txt deleted file mode 100644 index 50916127..00000000 --- a/application/prompts/react_final_prompt.txt +++ /dev/null @@ -1,3 +0,0 @@ -Query: {query} -Observations: {observations} -Now, using the insights from the observations, formulate a well-structured and precise final answer. \ No newline at end of file diff --git a/application/prompts/react_planning_prompt.txt b/application/prompts/react_planning_prompt.txt deleted file mode 100644 index c3571845..00000000 --- a/application/prompts/react_planning_prompt.txt +++ /dev/null @@ -1,13 +0,0 @@ -You are an AI assistant and talk like you're thinking out loud. Given the following query, outline a concise thought process that includes key steps and considerations necessary for effective analysis and response. Avoid pointwise formatting. The goal is to break down the query into manageable components without excessive detail, focusing on clarity and logical progression. - -Include the following elements in your thought and execution process: -1. Identify the main objective of the query. -2. Determine any relevant context or background information needed to understand the query. -3. List potential approaches or methods to address the query. -4. Highlight any critical factors or constraints that may influence the outcome. -5. Plan with available tools to help you with the analysis but dont execute them. Tools will be executed by another AI. - -Query: {query} -Summaries: {summaries} -Prompt: {prompt} -Observations(potentially previous tool calls): {observations} diff --git a/application/seed/config/premade_agents.yaml b/application/seed/config/premade_agents.yaml index cd895bec..df404156 100644 --- a/application/seed/config/premade_agents.yaml +++ b/application/seed/config/premade_agents.yaml @@ -18,7 +18,7 @@ agents: - name: "Researcher" description: "A specialized research agent that performs deep dives into subjects." image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-researcher.svg" - agent_type: "react" + agent_type: "classic" prompt: name: "Researcher-Agent" content: | diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index c9a5e55d..d451d9ee 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -138,7 +138,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const chunks = ['0', '2', '4', '6', '8', '10']; const agentTypes = [ { label: t('agents.form.agentTypes.classic'), value: 'classic' }, - { label: t('agents.form.agentTypes.react'), value: 'react' }, ]; const isPublishable = () => { diff --git a/frontend/src/agents/WorkflowBuilder.tsx b/frontend/src/agents/WorkflowBuilder.tsx index 96a142f9..38a2ac67 100644 --- a/frontend/src/agents/WorkflowBuilder.tsx +++ b/frontend/src/agents/WorkflowBuilder.tsx @@ -55,7 +55,7 @@ import WorkflowPreview from './workflow/WorkflowPreview'; import type { Model } from '../models/types'; interface AgentNodeConfig { - agent_type: 'classic' | 'react'; + agent_type: 'classic'; llm_name?: string; model_id?: string; system_prompt: string; @@ -884,7 +884,6 @@ function WorkflowBuilderInner() { Classic - ReAct diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 12c9a070..845145be 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -75,7 +75,7 @@ import type { Model } from '../../models/types'; const PRIMARY_ACTION_SPINNER_DELAY_MS = 180; interface AgentNodeConfig { - agent_type: 'classic' | 'react'; + agent_type: 'classic'; llm_name?: string; model_id?: string; system_prompt: string; @@ -1748,9 +1748,6 @@ function WorkflowBuilderInner() { Classic - - ReAct - diff --git a/results.txt b/results.txt new file mode 100644 index 00000000..beff8056 --- /dev/null +++ b/results.txt @@ -0,0 +1,12 @@ +Base URL:http://petstore.swagger.io,https://api.example.com +Path1: /pets +description: None +parameters: [] +methods: +get=A paged array of pets +post=Null response +Path2: /pets/{petId} +description: None +parameters: [] +methods: +get=Expected response to a valid request diff --git a/tests/agents/test_agent_creator.py b/tests/agents/test_agent_creator.py index 33eda73e..97f060b7 100644 --- a/tests/agents/test_agent_creator.py +++ b/tests/agents/test_agent_creator.py @@ -1,7 +1,6 @@ import pytest from application.agents.agent_creator import AgentCreator from application.agents.classic_agent import ClassicAgent -from application.agents.react_agent import ReActAgent @pytest.mark.unit @@ -14,11 +13,9 @@ class TestAgentCreator: assert agent.llm_name == agent_base_params["llm_name"] assert agent.model_id == agent_base_params["model_id"] - def test_create_react_agent(self, agent_base_params): + def test_create_react_falls_back_to_classic(self, agent_base_params): agent = AgentCreator.create_agent("react", **agent_base_params) - assert isinstance(agent, ReActAgent) - assert agent.endpoint == agent_base_params["endpoint"] - assert agent.llm_name == agent_base_params["llm_name"] + assert isinstance(agent, ClassicAgent) def test_create_agent_case_insensitive(self, agent_base_params): agent_upper = AgentCreator.create_agent("CLASSIC", **agent_base_params) @@ -35,7 +32,6 @@ class TestAgentCreator: assert "classic" in AgentCreator.agents assert "react" in AgentCreator.agents assert AgentCreator.agents["classic"] == ClassicAgent - assert AgentCreator.agents["react"] == ReActAgent def test_create_agent_with_optional_params(self, agent_base_params): agent_base_params["user_api_key"] = "user_key_123" diff --git a/tests/agents/test_react_agent.py b/tests/agents/test_react_agent.py deleted file mode 100644 index 0df38c3b..00000000 --- a/tests/agents/test_react_agent.py +++ /dev/null @@ -1,514 +0,0 @@ -from unittest.mock import Mock, mock_open, patch - -import pytest -from application.agents.react_agent import ReActAgent - - -@pytest.mark.unit -class TestReActAgent: - - def test_react_agent_initialization( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - assert isinstance(agent, ReActAgent) - assert agent.plan == "" - assert agent.observations == [] - - def test_react_agent_inherits_base_properties( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - assert agent.endpoint == agent_base_params["endpoint"] - assert agent.llm_name == agent_base_params["llm_name"] - assert agent.model_id == agent_base_params["model_id"] - - -@pytest.mark.unit -class TestReActAgentContentExtraction: - - def test_extract_content_from_string( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - response = "Simple string response" - content = agent._extract_content(response) - - assert content == "Simple string response" - - def test_extract_content_from_message_object( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - response = Mock() - response.message = Mock() - response.message.content = "Message content" - - content = agent._extract_content(response) - - assert content == "Message content" - - def test_extract_content_from_openai_response( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - response = Mock() - response.choices = [Mock()] - response.choices[0].message = Mock() - response.choices[0].message.content = "OpenAI content" - response.message = None - response.content = None - - content = agent._extract_content(response) - - assert content == "OpenAI content" - - def test_extract_content_from_anthropic_response( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - text_block = Mock() - text_block.text = "Anthropic content" - - response = Mock() - response.content = [text_block] - response.message = None - response.choices = None - - content = agent._extract_content(response) - - assert content == "Anthropic content" - - def test_extract_content_from_openai_stream( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - chunk1 = Mock() - chunk1.choices = [Mock()] - chunk1.choices[0].delta = Mock() - chunk1.choices[0].delta.content = "Part 1 " - - chunk2 = Mock() - chunk2.choices = [Mock()] - chunk2.choices[0].delta = Mock() - chunk2.choices[0].delta.content = "Part 2" - - response = iter([chunk1, chunk2]) - content = agent._extract_content(response) - - assert content == "Part 1 Part 2" - - def test_extract_content_from_anthropic_stream( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - chunk1 = Mock() - chunk1.type = "content_block_delta" - chunk1.delta = Mock() - chunk1.delta.text = "Stream 1 " - chunk1.choices = [] - - chunk2 = Mock() - chunk2.type = "content_block_delta" - chunk2.delta = Mock() - chunk2.delta.text = "Stream 2" - chunk2.choices = [] - - response = iter([chunk1, chunk2]) - content = agent._extract_content(response) - - assert content == "Stream 1 Stream 2" - - def test_extract_content_from_string_stream( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - response = iter(["chunk1", "chunk2", "chunk3"]) - content = agent._extract_content(response) - - assert content == "chunk1chunk2chunk3" - - def test_extract_content_handles_none_content( - self, agent_base_params, mock_llm_creator, mock_llm_handler_creator - ): - agent = ReActAgent(**agent_base_params) - - response = Mock() - response.message = Mock() - response.message.content = None - response.choices = None - response.content = None - - content = agent._extract_content(response) - - assert content == "" - - -@pytest.mark.unit -class TestReActAgentPlanning: - - @patch( - "builtins.open", - new_callable=mock_open, - read_data="Test planning prompt: {query} {summaries} {prompt} {observations}", - ) - def test_planning_phase( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_creator, - mock_llm_handler_creator, - log_context, - ): - def mock_gen_stream(*args, **kwargs): - # Return simple strings - _extract_content handles strings directly - - yield "Plan " - yield "content" - - mock_llm.gen_stream = Mock(return_value=mock_gen_stream()) - - agent = ReActAgent(**agent_base_params) - agent.observations = ["Observation 1"] - - plan_chunks = list(agent._planning_phase("Test query", log_context)) - - # Should yield thought dicts - - assert any("thought" in chunk for chunk in plan_chunks) - assert agent.plan == "Plan content" - - mock_llm.gen_stream.assert_called_once() - - @patch("builtins.open", new_callable=mock_open, read_data="Test: {query}") - def test_planning_phase_fills_template( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_creator, - mock_llm_handler_creator, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter([])) - - agent = ReActAgent(**agent_base_params) - list(agent._planning_phase("My query", log_context)) - - call_args = mock_llm.gen_stream.call_args[1] - messages = call_args["messages"] - - assert "My query" in messages[0]["content"] - - -@pytest.mark.unit -class TestReActAgentFinalAnswer: - - @patch( - "builtins.open", - new_callable=mock_open, - read_data="Final answer for: {query} with {observations}", - ) - def test_synthesis_phase( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_creator, - mock_llm_handler_creator, - log_context, - ): - def mock_gen_stream(*args, **kwargs): - yield Mock(choices=[Mock(delta=Mock(content="Final "))]) - yield Mock(choices=[Mock(delta=Mock(content="answer"))]) - - mock_llm.gen_stream = Mock(return_value=mock_gen_stream()) - - agent = ReActAgent(**agent_base_params) - agent.observations = ["Obs 1", "Obs 2"] - - answer_chunks = list(agent._synthesis_phase("Test query", log_context)) - - # Should yield answer dicts - - assert any("answer" in chunk for chunk in answer_chunks) - - @patch("builtins.open", new_callable=mock_open, read_data="Answer: {observations}") - def test_synthesis_phase_truncates_long_observations( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_creator, - mock_llm_handler_creator, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter([])) - - agent = ReActAgent(**agent_base_params) - agent.observations = ["A" * 15000] - - list(agent._synthesis_phase("Query", log_context)) - - call_args = mock_llm.gen_stream.call_args[1] - messages = call_args["messages"] - - assert "truncated" in messages[0]["content"] - - @patch("builtins.open", new_callable=mock_open, read_data="Test: {query}") - def test_synthesis_phase_no_tools( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_creator, - mock_llm_handler_creator, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter([])) - - agent = ReActAgent(**agent_base_params) - agent.observations = ["Obs"] - list(agent._synthesis_phase("Query", log_context)) - - call_args = mock_llm.gen_stream.call_args[1] - - assert call_args["tools"] is None - - -@pytest.mark.unit -class TestReActAgentGenInner: - - @patch( - "builtins.open", new_callable=mock_open, read_data="Prompt template: {query}" - ) - def test_gen_inner_resets_state( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter(["SATISFIED"])) - - def mock_handler(*args, **kwargs): - yield "SATISFIED" - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - agent.plan = "Old plan" - agent.observations = ["Old obs"] - - list(agent._gen_inner("New query", log_context)) - - assert agent.plan != "Old plan" - assert len(agent.observations) > 0 - - @patch("builtins.open", new_callable=mock_open, read_data="Prompt: {query}") - def test_gen_inner_stops_on_satisfied( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - iteration_count = 0 - - def mock_gen_stream(*args, **kwargs): - nonlocal iteration_count - iteration_count += 1 - if iteration_count == 1: - yield "Plan" - else: - yield "SATISFIED - done" - - mock_llm.gen_stream = Mock( - side_effect=lambda *args, **kwargs: mock_gen_stream(*args, **kwargs) - ) - - def mock_handler(*args, **kwargs): - yield "SATISFIED - done" - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - results = list(agent._gen_inner("Test query", log_context)) - - assert any("answer" in r for r in results) - - @patch("builtins.open", new_callable=mock_open, read_data="Prompt: {query}") - def test_gen_inner_max_iterations( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - call_count = 0 - - def mock_gen_stream(*args, **kwargs): - nonlocal call_count - call_count += 1 - yield f"Iteration {call_count}" - - mock_llm.gen_stream = Mock( - side_effect=lambda *args, **kwargs: mock_gen_stream(*args, **kwargs) - ) - - def mock_handler(*args, **kwargs): - yield "Continue..." - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - - results = list(agent._gen_inner("Test query", log_context)) - - thought_results = [r for r in results if "thought" in r] - assert len(thought_results) > 0 - - @patch("builtins.open", new_callable=mock_open, read_data="Prompt: {query}") - def test_gen_inner_yields_sources( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter(["SATISFIED"])) - - def mock_handler(*args, **kwargs): - yield "SATISFIED" - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - results = list(agent._gen_inner("Test query", log_context)) - - sources = [r for r in results if "sources" in r] - assert len(sources) >= 1 - - @patch("builtins.open", new_callable=mock_open, read_data="Prompt: {query}") - def test_gen_inner_yields_tool_calls( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter(["SATISFIED"])) - - def mock_handler(*args, **kwargs): - yield "SATISFIED" - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - agent.tool_calls = [{"tool": "test", "result": "A" * 100}] - - results = list(agent._gen_inner("Test query", log_context)) - - tool_call_results = [r for r in results if "tool_calls" in r] - if tool_call_results: - assert len(tool_call_results[0]["tool_calls"][0]["result"]) <= 53 - - @patch("builtins.open", new_callable=mock_open, read_data="Prompt: {query}") - def test_gen_inner_logs_observations( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - mock_llm.gen_stream = Mock(return_value=iter(["SATISFIED"])) - - def mock_handler(*args, **kwargs): - yield "SATISFIED" - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - list(agent._gen_inner("Test query", log_context)) - - assert len(agent.observations) > 0 - - -@pytest.mark.integration -class TestReActAgentIntegration: - - @patch( - "builtins.open", - new_callable=mock_open, - read_data="Prompt: {query} {summaries} {prompt} {observations}", - ) - def test_full_react_workflow( - self, - mock_file, - agent_base_params, - mock_llm, - mock_llm_handler, - mock_llm_creator, - mock_llm_handler_creator, - mock_mongo_db, - log_context, - ): - call_sequence = [] - - def mock_gen_stream(*args, **kwargs): - call_sequence.append("gen_stream") - if len(call_sequence) <= 2: - yield "Planning..." - else: - yield "SATISFIED final answer" - - mock_llm.gen_stream = Mock( - side_effect=lambda *args, **kwargs: mock_gen_stream(*args, **kwargs) - ) - - def mock_handler(*args, **kwargs): - call_sequence.append("handler") - yield "SATISFIED final answer" - - mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) - - agent = ReActAgent(**agent_base_params) - results = list(agent._gen_inner("Complex query", log_context)) - - assert len(results) > 0 - assert any("thought" in r for r in results) - assert any("answer" in r for r in results) diff --git a/tests/conftest.py b/tests/conftest.py index 2355808e..229f455e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -188,7 +188,7 @@ def mock_tool_manager(mock_tool, monkeypatch): manager = Mock() manager.load_tool = Mock(return_value=mock_tool) monkeypatch.setattr( - "application.agents.base.ToolManager", Mock(return_value=manager) + "application.agents.tool_executor.ToolManager", Mock(return_value=manager) ) return manager From 556b0a1da570e94f0b9a9c58efa6046c38014f8e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 15:16:18 +0000 Subject: [PATCH 09/41] feat: research init --- application/agents/agent_creator.py | 4 + application/agents/agentic_agent.py | 76 +++ application/agents/research_agent.py | 584 ++++++++++++++++++ application/agents/tools/internal_search.py | 155 +++++ application/agents/tools/think.py | 68 ++ .../api/answer/services/stream_processor.py | 60 +- application/api/user/agents/routes.py | 2 + application/api/user/base.py | 10 +- application/prompts/agentic/creative.txt | 16 + application/prompts/agentic/default.txt | 15 + application/prompts/agentic/strict.txt | 16 + application/prompts/research/planning.txt | 21 + application/prompts/research/step.txt | 13 + application/prompts/research/synthesis.txt | 22 + frontend/src/agents/NewAgent.tsx | 2 + frontend/src/assets/research.svg | 18 + .../src/conversation/ConversationBubble.tsx | 66 +- .../src/conversation/ConversationMessages.tsx | 3 +- .../src/conversation/ResearchProgress.tsx | 147 +++++ .../src/conversation/conversationModels.ts | 15 + .../src/conversation/conversationSlice.ts | 103 ++- 21 files changed, 1401 insertions(+), 15 deletions(-) create mode 100644 application/agents/agentic_agent.py create mode 100644 application/agents/research_agent.py create mode 100644 application/agents/tools/internal_search.py create mode 100644 application/agents/tools/think.py create mode 100644 application/prompts/agentic/creative.txt create mode 100644 application/prompts/agentic/default.txt create mode 100644 application/prompts/agentic/strict.txt create mode 100644 application/prompts/research/planning.txt create mode 100644 application/prompts/research/step.txt create mode 100644 application/prompts/research/synthesis.txt create mode 100644 frontend/src/assets/research.svg create mode 100644 frontend/src/conversation/ResearchProgress.tsx diff --git a/application/agents/agent_creator.py b/application/agents/agent_creator.py index 6773babd..d623269f 100644 --- a/application/agents/agent_creator.py +++ b/application/agents/agent_creator.py @@ -1,6 +1,8 @@ import logging +from application.agents.agentic_agent import AgenticAgent from application.agents.classic_agent import ClassicAgent +from application.agents.research_agent import ResearchAgent from application.agents.workflow_agent import WorkflowAgent logger = logging.getLogger(__name__) @@ -10,6 +12,8 @@ class AgentCreator: agents = { "classic": ClassicAgent, "react": ClassicAgent, # backwards compat: react falls back to classic + "agentic": AgenticAgent, + "research": ResearchAgent, "workflow": WorkflowAgent, } diff --git a/application/agents/agentic_agent.py b/application/agents/agentic_agent.py new file mode 100644 index 00000000..f642402a --- /dev/null +++ b/application/agents/agentic_agent.py @@ -0,0 +1,76 @@ +import logging +from typing import Dict, Generator, Optional + +from application.agents.base import BaseAgent +from application.agents.tools.internal_search import ( + INTERNAL_TOOL_ENTRY, + INTERNAL_TOOL_ID, + build_internal_tool_config, +) +from application.logging import LogContext + +logger = logging.getLogger(__name__) + + +class AgenticAgent(BaseAgent): + """Agent where the LLM controls retrieval via tools. + + Unlike ClassicAgent which pre-fetches docs into the prompt, + AgenticAgent gives the LLM an internal_search tool so it can + decide when, what, and whether to search. + """ + + def __init__( + self, + retriever_config: Optional[Dict] = None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.retriever_config = retriever_config or {} + + def _gen_inner( + self, query: str, log_context: LogContext + ) -> Generator[Dict, None, None]: + # 1. Get user tools (same as ClassicAgent) + tools_dict = self.tool_executor.get_tools() + + # 2. Add internal search as a synthetic tool (only if sources are configured) + source = self.retriever_config.get("source", {}) + has_sources = bool(source.get("active_docs")) + if self.retriever_config and has_sources: + internal_entry = dict(INTERNAL_TOOL_ENTRY) + internal_entry["config"] = build_internal_tool_config( + **self.retriever_config + ) + tools_dict[INTERNAL_TOOL_ID] = internal_entry + + # 3. Prepare all tools for the LLM + self._prepare_tools(tools_dict) + + # 4. Build messages (prompt has NO pre-fetched docs) + messages = self._build_messages(self.prompt, query) + + # 5. Call LLM — the handler manages the tool loop + llm_response = self._llm_gen(messages, log_context) + + yield from self._handle_response( + llm_response, tools_dict, messages, log_context + ) + + # 6. Collect sources from internal search tool results + self._collect_internal_sources() + + yield {"sources": self.retrieved_docs} + yield {"tool_calls": self._get_truncated_tool_calls()} + + log_context.stacks.append( + {"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}} + ) + + def _collect_internal_sources(self): + """Collect retrieved docs from the cached InternalSearchTool instance.""" + cache_key = f"internal_search:{INTERNAL_TOOL_ID}:{self.user or ''}" + tool = self.tool_executor._loaded_tools.get(cache_key) + if tool and hasattr(tool, "retrieved_docs") and tool.retrieved_docs: + self.retrieved_docs = tool.retrieved_docs diff --git a/application/agents/research_agent.py b/application/agents/research_agent.py new file mode 100644 index 00000000..1f58b73f --- /dev/null +++ b/application/agents/research_agent.py @@ -0,0 +1,584 @@ +import json +import logging +import os +import time +from typing import Dict, Generator, List, Optional + +from application.agents.base import BaseAgent +from application.agents.tool_executor import ToolExecutor +from application.agents.tools.internal_search import ( + INTERNAL_TOOL_ENTRY, + INTERNAL_TOOL_ID, + build_internal_tool_config, +) +from application.agents.tools.think import THINK_TOOL_ENTRY, THINK_TOOL_ID +from application.logging import LogContext + +logger = logging.getLogger(__name__) + +# Defaults (can be overridden via constructor) +DEFAULT_MAX_STEPS = 6 +DEFAULT_MAX_SUB_ITERATIONS = 5 +DEFAULT_TIMEOUT_SECONDS = 300 # 5 minutes +DEFAULT_TOKEN_BUDGET = 100_000 +DEFAULT_PARALLEL_WORKERS = 3 + +# Adaptive depth caps per complexity level +COMPLEXITY_CAPS = { + "simple": 2, + "moderate": 4, + "complex": 6, +} + +_PROMPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "prompts", + "research", +) + + +def _load_prompt(name: str) -> str: + with open(os.path.join(_PROMPTS_DIR, name), "r") as f: + return f.read() + + +PLANNING_PROMPT = _load_prompt("planning.txt") +STEP_PROMPT = _load_prompt("step.txt") +SYNTHESIS_PROMPT = _load_prompt("synthesis.txt") + + +# --------------------------------------------------------------------------- +# CitationManager +# --------------------------------------------------------------------------- + + +class CitationManager: + """Tracks and deduplicates citations across research steps.""" + + def __init__(self): + self.citations: Dict[int, Dict] = {} + self._counter = 0 + + def add(self, doc: Dict) -> int: + """Register a source, return its citation number. Deduplicates by source.""" + source = doc.get("source", "") + title = doc.get("title", "") + for num, existing in self.citations.items(): + if existing.get("source") == source and existing.get("title") == title: + return num + self._counter += 1 + self.citations[self._counter] = doc + return self._counter + + def add_docs(self, docs: List[Dict]) -> str: + """Register multiple docs, return formatted citation mapping text.""" + mapping_lines = [] + for doc in docs: + num = self.add(doc) + title = doc.get("title", "Untitled") + mapping_lines.append(f"[{num}] {title}") + return "\n".join(mapping_lines) + + def format_references(self) -> str: + """Generate [N] -> source mapping for report footer.""" + if not self.citations: + return "No sources found." + lines = [] + for num, doc in sorted(self.citations.items()): + title = doc.get("title", "Untitled") + source = doc.get("source", "Unknown") + filename = doc.get("filename", "") + display = filename or title + lines.append(f"[{num}] {display} — {source}") + return "\n".join(lines) + + def get_all_docs(self) -> List[Dict]: + return list(self.citations.values()) + + +# --------------------------------------------------------------------------- +# ResearchAgent +# --------------------------------------------------------------------------- + + +class ResearchAgent(BaseAgent): + """Multi-step research agent with parallel execution and budget controls. + + Orchestrates: Plan -> Research (per step, optionally parallel) -> Synthesize. + """ + + def __init__( + self, + retriever_config: Optional[Dict] = None, + max_steps: int = DEFAULT_MAX_STEPS, + max_sub_iterations: int = DEFAULT_MAX_SUB_ITERATIONS, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + token_budget: int = DEFAULT_TOKEN_BUDGET, + parallel_workers: int = DEFAULT_PARALLEL_WORKERS, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.retriever_config = retriever_config or {} + self.max_steps = max_steps + self.max_sub_iterations = max_sub_iterations + self.timeout_seconds = timeout_seconds + self.token_budget = token_budget + self.parallel_workers = parallel_workers + self.citations = CitationManager() + self._start_time: float = 0 + self._tokens_used: int = 0 + + # ------------------------------------------------------------------ + # Budget & timeout helpers + # ------------------------------------------------------------------ + + def _is_timed_out(self) -> bool: + return (time.monotonic() - self._start_time) >= self.timeout_seconds + + def _elapsed(self) -> float: + return round(time.monotonic() - self._start_time, 1) + + def _track_tokens(self, count: int): + self._tokens_used += count + + def _budget_remaining(self) -> int: + return max(self.token_budget - self._tokens_used, 0) + + def _is_over_budget(self) -> bool: + return self._tokens_used >= self.token_budget + + def _snapshot_llm_tokens(self) -> int: + """Read current token usage from LLM and return delta since last snapshot.""" + current = self.llm.token_usage.get("prompt_tokens", 0) + self.llm.token_usage.get("generated_tokens", 0) + return current + + # ------------------------------------------------------------------ + # Main orchestration + # ------------------------------------------------------------------ + + def _gen_inner( + self, query: str, log_context: LogContext + ) -> Generator[Dict, None, None]: + self._start_time = time.monotonic() + tools_dict = self._setup_tools() + + # Phase 1: Planning (with adaptive depth) + yield {"type": "research_progress", "data": {"status": "planning"}} + plan, complexity = self._planning_phase(query) + + if not plan: + logger.warning("ResearchAgent: Planning produced no steps, falling back") + plan = [{"query": query, "rationale": "Direct investigation"}] + complexity = "simple" + + yield { + "type": "research_plan", + "data": {"steps": plan, "complexity": complexity}, + } + + # Phase 2: Research each step (yields progress events in real-time) + intermediate_reports = [] + for i, step in enumerate(plan): + step_num = i + 1 + step_query = step.get("query", query) + + if self._is_timed_out(): + logger.warning( + f"ResearchAgent: Timeout at step {step_num}/{len(plan)} " + f"({self._elapsed()}s)" + ) + break + if self._is_over_budget(): + logger.warning( + f"ResearchAgent: Token budget exhausted at step {step_num}/{len(plan)}" + ) + break + + yield { + "type": "research_progress", + "data": { + "step": step_num, + "total": len(plan), + "query": step_query, + "status": "researching", + }, + } + + report = self._research_step(step_query, tools_dict) + intermediate_reports.append({"step": step, "content": report}) + + yield { + "type": "research_progress", + "data": { + "step": step_num, + "total": len(plan), + "query": step_query, + "status": "complete", + }, + } + + # Phase 3: Synthesis (streaming) + if self._is_timed_out(): + logger.warning( + f"ResearchAgent: Timeout ({self._elapsed()}s) before synthesis, " + f"synthesizing with {len(intermediate_reports)} reports" + ) + yield { + "type": "research_progress", + "data": { + "status": "synthesizing", + "elapsed_seconds": self._elapsed(), + "tokens_used": self._tokens_used, + }, + } + yield from self._synthesis_phase( + query, plan, intermediate_reports, tools_dict, log_context + ) + + # Sources and tool calls + self.retrieved_docs = self.citations.get_all_docs() + yield {"sources": self.retrieved_docs} + yield {"tool_calls": self._get_truncated_tool_calls()} + + logger.info( + f"ResearchAgent completed: {len(intermediate_reports)}/{len(plan)} steps, " + f"{self._elapsed()}s, ~{self._tokens_used} tokens" + ) + log_context.stacks.append( + {"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}} + ) + + # ------------------------------------------------------------------ + # Tool setup + # ------------------------------------------------------------------ + + def _setup_tools(self) -> Dict: + """Build tools_dict with user tools + internal search + think.""" + tools_dict = self.tool_executor.get_tools() + + # Only add internal search if sources are configured + source = self.retriever_config.get("source", {}) + has_sources = bool(source.get("active_docs")) + if self.retriever_config and has_sources: + internal_entry = dict(INTERNAL_TOOL_ENTRY) + internal_entry["config"] = build_internal_tool_config( + **self.retriever_config + ) + tools_dict[INTERNAL_TOOL_ID] = internal_entry + elif self.retriever_config and not has_sources: + logger.info("ResearchAgent: No sources configured, skipping internal_search tool") + + think_entry = dict(THINK_TOOL_ENTRY) + think_entry["config"] = {} + tools_dict[THINK_TOOL_ID] = think_entry + + self._prepare_tools(tools_dict) + return tools_dict + + # ------------------------------------------------------------------ + # Phase 1: Planning (with adaptive depth) + # ------------------------------------------------------------------ + + def _planning_phase(self, question: str) -> tuple[List[Dict], str]: + """Decompose the question into research steps via LLM. + + Returns (steps, complexity) where complexity is simple/moderate/complex. + """ + messages = [ + {"role": "system", "content": PLANNING_PROMPT}, + {"role": "user", "content": question}, + ] + + try: + response = self.llm.gen( + model=self.model_id, messages=messages, tools=None + ) + text = self._extract_text(response) + self._track_tokens(self._snapshot_llm_tokens()) + logger.info(f"ResearchAgent planning LLM response: {text[:500]}") + + plan_data = self._parse_plan_json(text) + if isinstance(plan_data, dict): + complexity = plan_data.get("complexity", "moderate") + steps = plan_data.get("steps", []) + else: + complexity = "moderate" + steps = plan_data + + # Adaptive depth: cap steps based on assessed complexity + cap = COMPLEXITY_CAPS.get(complexity, self.max_steps) + cap = min(cap, self.max_steps) + steps = steps[:cap] + + logger.info( + f"ResearchAgent plan: complexity={complexity}, " + f"steps={len(steps)} (cap={cap})" + ) + return steps, complexity + + except Exception as e: + logger.error(f"Planning phase failed: {e}", exc_info=True) + return ( + [{"query": question, "rationale": "Direct investigation (planning failed)"}], + "simple", + ) + + def _parse_plan_json(self, text: str): + """Extract JSON plan from LLM response. Returns dict or list.""" + # Try direct parse + try: + data = json.loads(text) + if isinstance(data, dict) and "steps" in data: + return data + if isinstance(data, list): + return data + except json.JSONDecodeError: + pass + + # Try extracting from markdown code fences + for marker in ["```json", "```"]: + if marker in text: + start = text.index(marker) + len(marker) + end = text.index("```", start) if "```" in text[start:] else len(text) + try: + data = json.loads(text[start:end].strip()) + if isinstance(data, dict) and "steps" in data: + return data + if isinstance(data, list): + return data + except (json.JSONDecodeError, ValueError): + pass + + # Try finding JSON object in text + for i, ch in enumerate(text): + if ch == "{": + for j in range(len(text) - 1, i, -1): + if text[j] == "}": + try: + data = json.loads(text[i : j + 1]) + if isinstance(data, dict) and "steps" in data: + return data + except json.JSONDecodeError: + continue + break + + logger.warning(f"Could not parse plan JSON from: {text[:200]}") + return [] + + # ------------------------------------------------------------------ + # Phase 2: Research step (core loop) + # ------------------------------------------------------------------ + + def _research_step(self, step_query: str, tools_dict: Dict) -> str: + """Run a focused research loop for one sub-question (sequential path).""" + report = self._research_step_with_executor( + step_query, tools_dict, self.tool_executor + ) + self._collect_step_sources() + return report + + def _research_step_with_executor( + self, step_query: str, tools_dict: Dict, executor: ToolExecutor + ) -> str: + """Core research loop. Works with any ToolExecutor instance.""" + system_prompt = STEP_PROMPT.replace("{step_query}", step_query) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": step_query}, + ] + + last_search_empty = False + + for iteration in range(self.max_sub_iterations): + # Check timeout and budget + if self._is_timed_out(): + logger.info( + f"Research step '{step_query[:50]}' timed out at iteration {iteration}" + ) + break + if self._is_over_budget(): + logger.info( + f"Research step '{step_query[:50]}' hit token budget at iteration {iteration}" + ) + break + + try: + response = self.llm.gen( + model=self.model_id, + messages=messages, + tools=self.tools if self.tools else None, + ) + self._track_tokens(self._snapshot_llm_tokens()) + except Exception as e: + logger.error( + f"Research step LLM call failed (iteration {iteration}): {e}", + exc_info=True, + ) + break + + parsed = self.llm_handler.parse_response(response) + + if not parsed.requires_tool_call: + return parsed.content or "No findings for this step." + + # Execute tool calls + messages, last_search_empty = self._execute_step_tools_with_refinement( + parsed.tool_calls, tools_dict, messages, executor, last_search_empty + ) + + # Max iterations / timeout / budget — ask for summary + messages.append( + { + "role": "user", + "content": "Please summarize your findings so far based on the information gathered.", + } + ) + try: + response = self.llm.gen( + model=self.model_id, messages=messages, tools=None + ) + self._track_tokens(self._snapshot_llm_tokens()) + text = self._extract_text(response) + return text or "Research step completed." + except Exception: + return "Research step completed." + + def _execute_step_tools_with_refinement( + self, + tool_calls, + tools_dict: Dict, + messages: List[Dict], + executor: ToolExecutor, + last_search_empty: bool, + ) -> tuple[List[Dict], bool]: + """Execute tool calls with query refinement on empty results. + + Returns (updated_messages, was_last_search_empty). + """ + search_returned_empty = False + + for call in tool_calls: + gen = executor.execute( + tools_dict, call, self.llm.__class__.__name__ + ) + result = None + call_id = None + while True: + try: + next(gen) + except StopIteration as e: + result, call_id = e.value + break + + # Detect empty search results for refinement + is_search = "search" in (call.name or "").lower() + result_str = str(result) if result else "" + if is_search and "No documents found" in result_str: + search_returned_empty = True + if last_search_empty: + # Two consecutive empty searches — inject refinement hint + result_str += ( + "\n\nHint: Previous search also returned no results. " + "Try a very different query with different keywords, " + "or broaden your search terms." + ) + result = result_str + + function_call_content = { + "function_call": { + "name": call.name, + "args": call.arguments, + "call_id": call_id, + } + } + messages.append( + {"role": "assistant", "content": [function_call_content]} + ) + tool_message = self.llm_handler.create_tool_message(call, result) + messages.append(tool_message) + + return messages, search_returned_empty + + def _collect_step_sources(self): + """Collect sources from InternalSearchTool and register with CitationManager.""" + cache_key = f"internal_search:{INTERNAL_TOOL_ID}:{self.user or ''}" + tool = self.tool_executor._loaded_tools.get(cache_key) + if tool and hasattr(tool, "retrieved_docs"): + for doc in tool.retrieved_docs: + self.citations.add(doc) + + # ------------------------------------------------------------------ + # Phase 3: Synthesis + # ------------------------------------------------------------------ + + def _synthesis_phase( + self, + question: str, + plan: List[Dict], + intermediate_reports: List[Dict], + tools_dict: Dict, + log_context: LogContext, + ) -> Generator[Dict, None, None]: + """Compile all findings into a final cited report (streaming).""" + plan_lines = [] + for i, step in enumerate(plan, 1): + plan_lines.append( + f"{i}. {step.get('query', 'Unknown')} — {step.get('rationale', '')}" + ) + plan_summary = "\n".join(plan_lines) + + findings_parts = [] + for i, report in enumerate(intermediate_reports, 1): + step_query = report["step"].get("query", "Unknown") + content = report["content"] + findings_parts.append( + f"--- Step {i}: {step_query} ---\n{content}" + ) + findings = "\n\n".join(findings_parts) + + references = self.citations.format_references() + + synthesis_prompt = SYNTHESIS_PROMPT.replace("{question}", question) + synthesis_prompt = synthesis_prompt.replace("{plan_summary}", plan_summary) + synthesis_prompt = synthesis_prompt.replace("{findings}", findings) + synthesis_prompt = synthesis_prompt.replace("{references}", references) + + messages = [ + {"role": "system", "content": synthesis_prompt}, + {"role": "user", "content": f"Please write the research report for: {question}"}, + ] + + llm_response = self.llm.gen_stream( + model=self.model_id, messages=messages, tools=None + ) + + if log_context: + from application.logging import build_stack_data + + log_context.stacks.append( + {"component": "synthesis_llm", "data": build_stack_data(self.llm)} + ) + + yield from self._handle_response( + llm_response, tools_dict, messages, log_context + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _extract_text(self, response) -> str: + """Extract text content from a non-streaming LLM response.""" + if isinstance(response, str): + return response + if hasattr(response, "message") and hasattr(response.message, "content"): + return response.message.content or "" + if hasattr(response, "choices") and response.choices: + choice = response.choices[0] + if hasattr(choice, "message") and hasattr(choice.message, "content"): + return choice.message.content or "" + if hasattr(response, "content") and isinstance(response.content, list): + if response.content and hasattr(response.content[0], "text"): + return response.content[0].text or "" + return str(response) if response else "" diff --git a/application/agents/tools/internal_search.py b/application/agents/tools/internal_search.py new file mode 100644 index 00000000..0b8c2c47 --- /dev/null +++ b/application/agents/tools/internal_search.py @@ -0,0 +1,155 @@ +import logging +from typing import Dict, List, Optional + +from application.agents.tools.base import Tool +from application.core.settings import settings +from application.retriever.retriever_creator import RetrieverCreator + +logger = logging.getLogger(__name__) + + +class InternalSearchTool(Tool): + """Wraps the ClassicRAG retriever as an LLM-callable tool. + + Instead of pre-fetching docs into the prompt, the LLM decides + when and what to search. Supports multiple searches per session. + """ + + def __init__(self, config: Dict): + self.config = config + self.retrieved_docs: List[Dict] = [] + self._retriever = None + + def _get_retriever(self): + if self._retriever is None: + self._retriever = RetrieverCreator.create_retriever( + self.config.get("retriever_name", "classic"), + source=self.config.get("source", {}), + chat_history=[], # no rephrasing — LLM controls the query + prompt="", + chunks=int(self.config.get("chunks", 2)), + doc_token_limit=int(self.config.get("doc_token_limit", 50000)), + model_id=self.config.get("model_id", "docsgpt-local"), + user_api_key=self.config.get("user_api_key"), + agent_id=self.config.get("agent_id"), + llm_name=self.config.get("llm_name", settings.LLM_PROVIDER), + api_key=self.config.get("api_key", settings.API_KEY), + decoded_token=self.config.get("decoded_token"), + ) + return self._retriever + + def execute_action(self, action_name: str, **kwargs): + if action_name != "search": + return f"Unknown action: {action_name}" + + query = kwargs.get("query", "") + if not query: + return "Error: 'query' parameter is required." + + try: + retriever = self._get_retriever() + docs = retriever.search(query) + except Exception as e: + logger.error(f"Internal search failed: {e}", exc_info=True) + return f"Search failed: {str(e)}" + + if not docs: + return "No documents found matching your query." + + # Accumulate for source tracking + for doc in docs: + if doc not in self.retrieved_docs: + self.retrieved_docs.append(doc) + + # Format results for the LLM + formatted = [] + for i, doc in enumerate(docs, 1): + title = doc.get("title", "Untitled") + text = doc.get("text", "") + source = doc.get("source", "Unknown") + filename = doc.get("filename", "") + header = filename or title + formatted.append(f"[{i}] {header} (source: {source})\n{text}") + + return "\n\n---\n\n".join(formatted) + + def get_actions_metadata(self): + return [ + { + "name": "search", + "description": ( + "Search the user's uploaded documents and knowledge base. " + "Use this to find relevant information before answering questions. " + "You can call this multiple times with different queries." + ), + "parameters": { + "properties": { + "query": { + "type": "string", + "description": "The search query. Be specific and focused.", + "filled_by_llm": True, + "required": True, + } + } + }, + } + ] + + def get_config_requirements(self): + return {} + + +# Constants for building synthetic tools_dict entries +INTERNAL_TOOL_ID = "internal" + +INTERNAL_TOOL_ENTRY = { + "name": "internal_search", + "actions": [ + { + "name": "search", + "description": ( + "Search the user's uploaded documents and knowledge base. " + "Use this to find relevant information before answering questions. " + "You can call this multiple times with different queries." + ), + "active": True, + "parameters": { + "properties": { + "query": { + "type": "string", + "description": "The search query. Be specific and focused.", + "filled_by_llm": True, + "required": True, + } + } + }, + } + ], +} + + +def build_internal_tool_config( + source: Dict, + retriever_name: str = "classic", + chunks: int = 2, + doc_token_limit: int = 50000, + model_id: str = "docsgpt-local", + user_api_key: Optional[str] = None, + agent_id: Optional[str] = None, + llm_name: str = None, + api_key: str = None, + decoded_token: Optional[Dict] = None, +) -> Dict: + """Build the config dict for InternalSearchTool.""" + return { + "source": source, + "retriever_name": retriever_name, + "chunks": chunks, + "doc_token_limit": doc_token_limit, + "model_id": model_id, + "user_api_key": user_api_key, + "agent_id": agent_id, + "llm_name": llm_name or settings.LLM_PROVIDER, + "api_key": api_key or settings.API_KEY, + "decoded_token": decoded_token, + } diff --git a/application/agents/tools/think.py b/application/agents/tools/think.py new file mode 100644 index 00000000..7c1fc2b6 --- /dev/null +++ b/application/agents/tools/think.py @@ -0,0 +1,68 @@ +from application.agents.tools.base import Tool + + +THINK_TOOL_ID = "think" + +THINK_TOOL_ENTRY = { + "name": "think", + "actions": [ + { + "name": "reason", + "description": ( + "Use this tool to think through your reasoning step by step " + "before deciding on your next action. Always reason before " + "searching or answering." + ), + "active": True, + "parameters": { + "properties": { + "reasoning": { + "type": "string", + "description": "Your step-by-step reasoning and analysis", + "filled_by_llm": True, + "required": True, + } + } + }, + } + ], +} + + +class ThinkTool(Tool): + """Pseudo-tool that captures chain-of-thought reasoning. + + Returns a short acknowledgment so the LLM can continue. + The reasoning content is captured in tool_call data for transparency. + """ + + def __init__(self, config=None): + pass + + def execute_action(self, action_name: str, **kwargs): + return "Continue." + + def get_actions_metadata(self): + return [ + { + "name": "reason", + "description": ( + "Use this tool to think through your reasoning step by step " + "before deciding on your next action. Always reason before " + "searching or answering." + ), + "parameters": { + "properties": { + "reasoning": { + "type": "string", + "description": "Your step-by-step reasoning and analysis", + "filled_by_llm": True, + "required": True, + } + } + }, + } + ] + + def get_config_requirements(self): + return {} diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index 61a4d4c1..904f6d3f 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -38,13 +38,23 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str: current_dir = Path(__file__).resolve().parents[3] prompts_dir = current_dir / "prompts" - preset_mapping = { + # Maps for classic agent types + CLASSIC_PRESETS = { "default": "chat_combine_default.txt", "creative": "chat_combine_creative.txt", "strict": "chat_combine_strict.txt", "reduce": "chat_reduce_prompt.txt", } + # Agentic counterparts — same styles, but with search tool instructions + AGENTIC_PRESETS = { + "default": "agentic/default.txt", + "creative": "agentic/creative.txt", + "strict": "agentic/strict.txt", + } + + preset_mapping = {**CLASSIC_PRESETS, **{f"agentic_{k}": v for k, v in AGENTIC_PRESETS.items()}} + if prompt_id in preset_mapping: file_path = os.path.join(prompts_dir, preset_mapping[prompt_id]) try: @@ -119,6 +129,14 @@ class StreamProcessor: create_agent() into a single convenience method. """ self.initialize() + + agent_type = self.agent_config.get("agent_type", "classic") + + # Agentic/research agents skip pre-fetch — the LLM searches on-demand via tools + if agent_type in ("agentic", "research"): + tools_data = self.pre_fetch_tools() + return self.create_agent(tools_data=tools_data) + docs_together, docs_list = self.pre_fetch_docs(question) tools_data = self.pre_fetch_tools() return self.create_agent( @@ -735,11 +753,21 @@ class StreamProcessor: tools_data: Optional[Dict[str, Any]] = None, ): """Create and return the configured agent with rendered prompt""" + agent_type = self.agent_config["agent_type"] + + # For agentic agents, swap standard presets for their agentic + # counterparts (which include search tool instructions instead of + # {summaries}). Custom / user-provided prompts pass through as-is. raw_prompt = self._get_prompt_content() if raw_prompt is None: - raw_prompt = get_prompt( - self.agent_config["prompt_id"], self.prompts_collection - ) + prompt_id = self.agent_config.get("prompt_id", "default") + agentic_presets = {"default", "creative", "strict"} + if agent_type in ("agentic", "research") and prompt_id in agentic_presets: + raw_prompt = get_prompt( + f"agentic_{prompt_id}", self.prompts_collection + ) + else: + raw_prompt = get_prompt(prompt_id, self.prompts_collection) self._prompt_content = raw_prompt rendered_prompt = self.prompt_renderer.render_prompt( @@ -759,8 +787,6 @@ class StreamProcessor: ) system_api_key = get_api_key_for_provider(provider or settings.LLM_PROVIDER) - agent_type = self.agent_config["agent_type"] - # Create LLM and handler (dependency injection) from application.llm.llm_creator import LLMCreator from application.llm.handlers.handler_creator import LLMHandlerCreator @@ -806,8 +832,26 @@ class StreamProcessor: "tool_executor": tool_executor, } - # Workflow-specific kwargs for workflow agents - if agent_type == "workflow": + # Type-specific kwargs + if agent_type in ("agentic", "research"): + agent_kwargs["retriever_config"] = { + "source": self.source, + "retriever_name": self.retriever_config.get( + "retriever_name", "classic" + ), + "chunks": self.retriever_config.get("chunks", 2), + "doc_token_limit": self.retriever_config.get( + "doc_token_limit", 50000 + ), + "model_id": self.model_id, + "user_api_key": self.agent_config["user_api_key"], + "agent_id": self.agent_id, + "llm_name": provider or settings.LLM_PROVIDER, + "api_key": system_api_key, + "decoded_token": self.decoded_token, + } + + elif agent_type == "workflow": workflow_config = self.agent_config.get("workflow") if isinstance(workflow_config, str): agent_kwargs["workflow_id"] = workflow_config diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py index 02b185c2..c95f225d 100644 --- a/application/api/user/agents/routes.py +++ b/application/api/user/agents/routes.py @@ -104,6 +104,8 @@ AGENT_TYPE_SCHEMAS = { } AGENT_TYPE_SCHEMAS["react"] = AGENT_TYPE_SCHEMAS["classic"] +AGENT_TYPE_SCHEMAS["agentic"] = AGENT_TYPE_SCHEMAS["classic"] +AGENT_TYPE_SCHEMAS["research"] = AGENT_TYPE_SCHEMAS["classic"] AGENT_TYPE_SCHEMAS["openai"] = AGENT_TYPE_SCHEMAS["classic"] diff --git a/application/api/user/base.py b/application/api/user/base.py index 920af776..afc22268 100644 --- a/application/api/user/base.py +++ b/application/api/user/base.py @@ -145,9 +145,15 @@ def resolve_tool_details(tool_ids): Returns: List of tool details with id, name, and display_name """ + valid_ids = [] + for tid in tool_ids: + try: + valid_ids.append(ObjectId(tid)) + except Exception: + continue tools = user_tools_collection.find( - {"_id": {"$in": [ObjectId(tid) for tid in tool_ids]}} - ) + {"_id": {"$in": valid_ids}} + ) if valid_ids else [] return [ { "id": str(tool["_id"]), diff --git a/application/prompts/agentic/creative.txt b/application/prompts/agentic/creative.txt new file mode 100644 index 00000000..da2f8ff2 --- /dev/null +++ b/application/prompts/agentic/creative.txt @@ -0,0 +1,16 @@ +You are a helpful AI assistant, DocsGPT. You are proactive and helpful. Try to use tools, if they are available to you, +be proactive and fill in missing information. +Users can Upload documents for your context as attachments or sources via UI using the Conversation input box. +If appropriate, your answers can include code examples, formatted as follows: +```(language) +(code) +``` +Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses. +Try to respond with mermaid charts if visualization helps with users queries. +You effectively utilize chat history, ensuring relevant and tailored responses. +You have access to a search tool that searches the user's uploaded documents and knowledge base. +Use the search_internal tool to find relevant information before answering questions. +You may search multiple times with different queries if needed. +Do not guess when documents are available — search first, then answer based on what you find. +If no relevant documents are found, use your general knowledge and tool capabilities. +Allow yourself to be very creative and use your imagination. \ No newline at end of file diff --git a/application/prompts/agentic/default.txt b/application/prompts/agentic/default.txt new file mode 100644 index 00000000..41339fe6 --- /dev/null +++ b/application/prompts/agentic/default.txt @@ -0,0 +1,15 @@ +You are a helpful AI assistant, DocsGPT. You are proactive and helpful. Try to use tools, if they are available to you, +be proactive and fill in missing information. +Users can Upload documents for your context as attachments or sources via UI using the Conversation input box. +If appropriate, your answers can include code examples, formatted as follows: +```(language) +(code) +``` +Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses. +Try to respond with mermaid charts if visualization helps with users queries. +You effectively utilize chat history, ensuring relevant and tailored responses. +You have access to a search tool that searches the user's uploaded documents and knowledge base. +Use the search_internal tool to find relevant information before answering questions. +You may search multiple times with different queries if needed. +Do not guess when documents are available — search first, then answer based on what you find. +If no relevant documents are found, use your general knowledge and tool capabilities. \ No newline at end of file diff --git a/application/prompts/agentic/strict.txt b/application/prompts/agentic/strict.txt new file mode 100644 index 00000000..8878b323 --- /dev/null +++ b/application/prompts/agentic/strict.txt @@ -0,0 +1,16 @@ +You are a helpful AI assistant, DocsGPT. You are proactive and helpful. Try to use tools, if they are available to you, +be proactive and fill in missing information. +Users can Upload documents for your context as attachments or sources via UI using the Conversation input box. +If appropriate, your answers can include code examples, formatted as follows: +```(language) +(code) +``` +Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses. +Try to respond with mermaid charts if visualization helps with users queries. +You effectively utilize chat history, ensuring relevant and tailored responses. +You have access to a search tool that searches the user's uploaded documents and knowledge base. +Use the search_internal tool to find relevant information before answering questions. +You may search multiple times with different queries if needed. +You MUST search before answering any factual question. Do not guess or use general knowledge when documents are available. +If you dont have enough information from the search results or tools, answer "I don't know" or "I don't have enough information". +Never make up information or provide false information! \ No newline at end of file diff --git a/application/prompts/research/planning.txt b/application/prompts/research/planning.txt new file mode 100644 index 00000000..65f9b0cb --- /dev/null +++ b/application/prompts/research/planning.txt @@ -0,0 +1,21 @@ +You are a research planner. Your job is to analyze the user's question and create a focused research plan. + +Assess the question's complexity: +- "simple": Can be answered with 1-2 focused searches (e.g., "What is our refund policy?") +- "moderate": Needs 3-4 searches across different topics (e.g., "Compare our pricing across product lines") +- "complex": Requires 5-6 searches with synthesis (e.g., "Audit our compliance docs against regulation X") + +Break the question into sub-questions appropriate to its complexity. Don't over-decompose simple questions. + +Consider: +- What distinct aspects of the question need separate investigation? +- What order should they be investigated in? +- Are there any dependencies between sub-questions? + +You MUST respond with ONLY a valid JSON object in this exact format (no markdown, no code fences): +{ + "complexity": "simple|moderate|complex", + "steps": [ + {"query": "specific sub-question to investigate", "rationale": "why this step is needed"} + ] +} \ No newline at end of file diff --git a/application/prompts/research/step.txt b/application/prompts/research/step.txt new file mode 100644 index 00000000..aecaf2b8 --- /dev/null +++ b/application/prompts/research/step.txt @@ -0,0 +1,13 @@ +You are a research agent investigating a specific topic. Your goal is to find comprehensive, accurate information. + +Your current research task: {step_query} + +Instructions: +1. Use the available tools to search for and gather relevant information. +2. If you have a search_internal tool, use it to search the knowledge base. You may search multiple times with different queries. +3. If you have a reason_think tool, use it to analyze what you've found before drawing conclusions. +4. After gathering sufficient information, provide a detailed summary of your findings. +5. Cite specific documents and passages you found. Reference sources by their titles or filenames. +6. If you cannot find relevant information through tools, use your general knowledge but clearly indicate this. + +Be thorough — prefer completeness over brevity. Include all relevant details you find. \ No newline at end of file diff --git a/application/prompts/research/synthesis.txt b/application/prompts/research/synthesis.txt new file mode 100644 index 00000000..66bb1cad --- /dev/null +++ b/application/prompts/research/synthesis.txt @@ -0,0 +1,22 @@ +You are compiling a comprehensive research report based on multiple research steps. + +Original question: {question} + +Research plan: +{plan_summary} + +Intermediate findings: +{findings} + +Write a well-structured, thorough report that: +1. Directly addresses the user's original question +2. Synthesizes findings from all research steps into a coherent narrative +3. Uses inline citations [N] for every factual claim, where N maps to the source list below +4. Highlights key insights and connections across different research steps +5. Notes any gaps or areas where information was insufficient +6. Includes a "References" section at the end listing all cited sources + +Available sources for citation: +{references} + +Format the report with clear headings and sections. Be comprehensive but well-organized. \ No newline at end of file diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index d451d9ee..c36f51e0 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -138,6 +138,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const chunks = ['0', '2', '4', '6', '8', '10']; const agentTypes = [ { label: t('agents.form.agentTypes.classic'), value: 'classic' }, + { label: 'Agentic', value: 'agentic' }, + { label: 'Research', value: 'research' }, ]; const isPublishable = () => { diff --git a/frontend/src/assets/research.svg b/frontend/src/assets/research.svg new file mode 100644 index 00000000..1e3f9120 --- /dev/null +++ b/frontend/src/assets/research.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index bf058581..a894d3ba 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -37,7 +37,8 @@ import { selectSelectedDocs, } from '../preferences/preferenceSlice'; import classes from './ConversationBubble.module.css'; -import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; +import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels'; +import ResearchProgress from './ResearchProgress'; import { ToolCallsType } from './types'; const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; @@ -53,6 +54,7 @@ const ConversationBubble = forwardRef< thought?: string; sources?: { title: string; text: string; link: string }[]; toolCalls?: ToolCallsType[]; + research?: ResearchState; retryBtn?: React.ReactElement; questionNumber?: number; isStreaming?: boolean; @@ -74,6 +76,7 @@ const ConversationBubble = forwardRef< thought, sources, toolCalls, + research, retryBtn, questionNumber, isStreaming, @@ -261,7 +264,15 @@ const ConversationBubble = forwardRef< return inlineProcessedContent; }; const processMarkdownContent = (content: string) => { - const processedContent = preprocessLaTeX(content); + let processedContent = preprocessLaTeX(content); + + // Convert citation references [N] into markdown links [N](#cite-N) + // so ReactMarkdown renders them as tags we can style. + // Avoid matching inside code blocks or existing links. + processedContent = processedContent.replace( + /(? `[${num}](#cite-${num})`, + ); const contentSegments: Array<{ type: 'text' | 'mermaid'; @@ -290,6 +301,7 @@ const ConversationBubble = forwardRef< return contentSegments; }; + bubble = (
{sources?.slice(0, 3)?.map((source, index) => ( -
+
setActiveTooltip(index)} @@ -393,6 +405,7 @@ const ConversationBubble = forwardRef<
)} + {research && } {toolCalls && toolCalls.length > 0 && ( )} @@ -474,6 +487,30 @@ const ConversationBubble = forwardRef< remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={{ + a({ href, children }) { + if (href?.startsWith('#cite-')) { + const num = href.replace('#cite-', ''); + const sourceIdx = parseInt(num, 10) - 1; + return ( + + ); + } + return
{children}; + }, code(props) { const { children, @@ -649,6 +686,29 @@ const ConversationBubble = forwardRef<
+ {research && message && ( +
+ +
+ )}
diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index 1a666667..1d9fd6b6 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -140,7 +140,7 @@ export default function ConversationMessages({ ? LAST_BUBBLE_MARGIN : DEFAULT_BUBBLE_MARGIN; - if (query.thought || query.response || query.tool_calls) { + if (query.thought || query.response || query.tool_calls || query.research) { const isCurrentlyStreaming = status === 'loading' && index === queries.length - 1; return ( @@ -152,6 +152,7 @@ export default function ConversationMessages({ thought={query.thought} sources={query.sources} toolCalls={query.tool_calls} + research={query.research} onOpenArtifact={onOpenArtifact} feedback={query.feedback} isStreaming={isCurrentlyStreaming} diff --git a/frontend/src/conversation/ResearchProgress.tsx b/frontend/src/conversation/ResearchProgress.tsx new file mode 100644 index 00000000..cce6ca0b --- /dev/null +++ b/frontend/src/conversation/ResearchProgress.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from 'react'; +import ResearchIcon from '../assets/research.svg'; +import Avatar from '../components/Avatar'; +import { ResearchState } from './conversationModels'; + +const SmallCheck = () => ( + + + +); + +const SmallSpinner = () => ( + + + + +); + +const SmallPending = () => ( +
+); + +function StatusText({ status, elapsed }: { status: string; elapsed?: number }) { + const labels: Record = { + planning: 'Planning research...', + researching: 'Researching...', + synthesizing: 'Writing report...', + complete: 'Complete', + }; + const elapsed_str = elapsed ? ` \u00B7 ${Math.round(elapsed)}s` : ''; + return ( + + {status === 'complete' ? ( + <> + {labels.complete} + {elapsed_str} + + ) : ( + <> + {labels[status] || status} + {elapsed_str} + + )} + + ); +} + +export default function ResearchProgress({ + research, +}: { + research: ResearchState; +}) { + const { plan, status, elapsed_seconds } = research; + const [isExpanded, setIsExpanded] = useState(true); + + // Auto-collapse when synthesis starts (report is streaming) + useEffect(() => { + if (status === 'synthesizing' || status === 'complete') { + setIsExpanded(false); + } + }, [status]); + + if (!plan && !status) return null; + + const completedSteps = plan?.filter((s) => s.status === 'complete').length ?? 0; + const totalSteps = plan?.length ?? 0; + + // Collapsed: single-line summary + const summaryText = totalSteps > 0 + ? `Researched ${completedSteps} topic${completedSteps !== 1 ? 's' : ''}` + : 'Research'; + + return ( +
+ {/* Header row — matches Reasoning / Sources pattern */} +
+ + } + /> + + {status && } +
+ + {/* Expanded: vertical timeline of steps */} + {isExpanded && plan && plan.length > 0 && ( +
+
+ {plan.map((step, i) => { + const isLast = i === plan.length - 1; + return ( +
+ {/* Timeline: dot + vertical line */} +
+
+ {step.status === 'complete' ? ( + + ) : step.status === 'researching' ? ( + + ) : ( + + )} +
+ {!isLast && ( +
+ )} +
+ {/* Step content */} +
+

+ {step.query} +

+
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/conversation/conversationModels.ts b/frontend/src/conversation/conversationModels.ts index 904683a7..cfa4ec8e 100644 --- a/frontend/src/conversation/conversationModels.ts +++ b/frontend/src/conversation/conversationModels.ts @@ -18,6 +18,20 @@ export interface Attachment { token_count?: number; } +export interface ResearchStep { + query: string; + rationale?: string; + status: 'pending' | 'researching' | 'complete'; +} + +export interface ResearchState { + plan?: ResearchStep[]; + complexity?: string; + status?: string; + elapsed_seconds?: number; + tokens_used?: number; +} + export interface ConversationState { queries: Query[]; status: Status; @@ -50,6 +64,7 @@ export interface Query { attachments?: { id: string; fileName: string }[]; structured?: boolean; schema?: object; + research?: ResearchState; } export interface RetrievalPayload { diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 936d2de8..fed5e6cf 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -11,7 +11,13 @@ import { handleFetchAnswer, handleFetchAnswerSteaming, } from './conversationHandlers'; -import { Answer, ConversationState, Query, Status } from './conversationModels'; +import { + Answer, + ConversationState, + Query, + ResearchStep, + Status, +} from './conversationModels'; import { ToolCallsType } from './types'; const initialState: ConversationState = { @@ -71,6 +77,12 @@ export const fetchAnswer = createAsyncThunk< if (currentConversationId === state.conversation.conversationId) { if (data.type === 'end') { dispatch(conversationSlice.actions.setStatus('idle')); + dispatch( + updateResearchProgress({ + index: targetIndex, + progress: { status: 'complete' }, + }), + ); getConversations(state.preference.token) .then((fetchedConversations) => { dispatch(setConversations(fetchedConversations)); @@ -122,6 +134,21 @@ export const fetchAnswer = createAsyncThunk< tool_call: data.data as ToolCallsType, }), ); + } else if (data.type === 'research_plan') { + dispatch( + updateResearchPlan({ + index: targetIndex, + plan: data.data.steps, + complexity: data.data.complexity, + }), + ); + } else if (data.type === 'research_progress') { + dispatch( + updateResearchProgress({ + index: targetIndex, + progress: data.data, + }), + ); } else if (data.type === 'error') { // set status to 'failed' dispatch(conversationSlice.actions.setStatus('failed')); @@ -256,6 +283,7 @@ export const conversationSlice = createSlice({ delete state.queries[index].structured; delete state.queries[index].schema; delete state.queries[index].feedback; + delete state.queries[index].research; }, updateStreamingQuery( state, @@ -337,6 +365,77 @@ export const conversationSlice = createSlice({ }; } else state.queries[index].tool_calls.push(tool_call); }, + updateResearchPlan( + state, + action: PayloadAction<{ + index: number; + plan: ResearchStep[]; + complexity?: string; + }>, + ) { + const { index, plan, complexity } = action.payload; + if (!state.queries[index].research) { + state.queries[index].research = {}; + } + state.queries[index].research!.plan = plan.map((step) => ({ + ...step, + status: 'pending', + })); + if (complexity) { + state.queries[index].research!.complexity = complexity; + } + }, + updateResearchProgress( + state, + action: PayloadAction<{ + index: number; + progress: { + status?: string; + step?: number; + total?: number; + query?: string; + elapsed_seconds?: number; + tokens_used?: number; + }; + }>, + ) { + const { index, progress } = action.payload; + if (!state.queries[index].research) { + state.queries[index].research = {}; + } + const research = state.queries[index].research!; + if (progress.elapsed_seconds !== undefined) { + research.elapsed_seconds = progress.elapsed_seconds; + } + if (progress.tokens_used !== undefined) { + research.tokens_used = progress.tokens_used; + } + // Update individual step status when step number is present + if (progress.step !== undefined) { + if (!research.plan) { + research.plan = []; + } + const stepIndex = progress.step - 1; + // Dynamically add step if it doesn't exist yet + while (research.plan.length <= stepIndex) { + research.plan.push({ + query: progress.query || `Step ${research.plan.length + 1}`, + status: 'pending', + }); + } + if (progress.status === 'researching' || progress.status === 'complete') { + research.plan[stepIndex].status = progress.status; + } + if (progress.query) { + research.plan[stepIndex].query = progress.query; + } + // Keep top-level status as "researching" while steps are running + research.status = 'researching'; + } else if (progress.status) { + // Top-level status updates (planning, synthesizing) + research.status = progress.status; + } + }, updateQuery( state, action: PayloadAction<{ index: number; query: Partial }>, @@ -405,6 +504,8 @@ export const { updateThought, updateStreamingSource, updateToolCall, + updateResearchPlan, + updateResearchProgress, setConversation, setStatus, raiseError, From 72393dc369a64af96979aea5ec034830f2f59177 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 17:42:24 +0000 Subject: [PATCH 10/41] feat: improve research --- application/agents/research_agent.py | 111 +++++++++++++++++- application/api/answer/routes/base.py | 7 +- .../answer/services/conversation_service.py | 33 ++++-- .../api/answer/services/stream_processor.py | 32 ++++- .../prompts/research/clarification.txt | 23 ++++ application/prompts/research/planning.txt | 2 + .../src/conversation/conversationSlice.ts | 16 ++- 7 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 application/prompts/research/clarification.txt diff --git a/application/agents/research_agent.py b/application/agents/research_agent.py index 1f58b73f..1e4f9a73 100644 --- a/application/agents/research_agent.py +++ b/application/agents/research_agent.py @@ -42,6 +42,7 @@ def _load_prompt(name: str) -> str: return f.read() +CLARIFICATION_PROMPT = _load_prompt("clarification.txt") PLANNING_PROMPT = _load_prompt("planning.txt") STEP_PROMPT = _load_prompt("step.txt") SYNTHESIS_PROMPT = _load_prompt("synthesis.txt") @@ -163,6 +164,19 @@ class ResearchAgent(BaseAgent): self._start_time = time.monotonic() tools_dict = self._setup_tools() + # Phase 0: Clarification (skip if user is responding to a prior clarification) + if not self._is_follow_up(): + clarification = self._clarification_phase(query) + if clarification: + yield {"metadata": {"is_clarification": True}} + yield {"answer": clarification} + yield {"sources": []} + yield {"tool_calls": []} + log_context.stacks.append( + {"component": "agent", "data": {"clarification": True}} + ) + return + # Phase 1: Planning (with adaptive depth) yield {"type": "research_progress", "data": {"status": "planning"}} plan, complexity = self._planning_phase(query) @@ -276,6 +290,98 @@ class ResearchAgent(BaseAgent): self._prepare_tools(tools_dict) return tools_dict + # ------------------------------------------------------------------ + # Phase 0: Clarification + # ------------------------------------------------------------------ + + def _is_follow_up(self) -> bool: + """Check if the user is responding to a prior clarification. + + Uses the metadata flag stored in the conversation DB — no string matching. + Only skip clarification when the last query was explicitly flagged + as a clarification by this agent. + """ + if not self.chat_history: + return False + last = self.chat_history[-1] + meta = last.get("metadata", {}) + return bool(meta.get("is_clarification")) + + def _clarification_phase(self, question: str) -> Optional[str]: + """Ask the LLM whether the question needs clarification. + + Returns formatted clarification text if needed, or None to proceed. + Uses response_format to force valid JSON output. + """ + messages = [ + {"role": "system", "content": CLARIFICATION_PROMPT}, + {"role": "user", "content": question}, + ] + + try: + response = self.llm.gen( + model=self.model_id, + messages=messages, + tools=None, + response_format={"type": "json_object"}, + ) + text = self._extract_text(response) + self._track_tokens(self._snapshot_llm_tokens()) + logger.info(f"ResearchAgent clarification response: {text[:300]}") + + data = self._parse_clarification_json(text) + if not data or not data.get("needs_clarification"): + return None + + questions = data.get("questions", []) + if not questions: + return None + + # Format as a friendly response + lines = [ + "Before I begin researching, I'd like to clarify a few things:\n" + ] + for i, q in enumerate(questions[:3], 1): + lines.append(f"{i}. {q}") + lines.append( + "\nPlease provide these details and I'll start the research." + ) + return "\n".join(lines) + + except Exception as e: + logger.error(f"Clarification phase failed: {e}", exc_info=True) + return None # proceed with research on failure + + def _parse_clarification_json(self, text: str) -> Optional[Dict]: + """Parse clarification JSON from LLM response.""" + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Try extracting from code fences + for marker in ["```json", "```"]: + if marker in text: + start = text.index(marker) + len(marker) + end = text.index("```", start) if "```" in text[start:] else len(text) + try: + return json.loads(text[start:end].strip()) + except (json.JSONDecodeError, ValueError): + pass + + # Try finding JSON object + for i, ch in enumerate(text): + if ch == "{": + for j in range(len(text) - 1, i, -1): + if text[j] == "}": + try: + return json.loads(text[i : j + 1]) + except json.JSONDecodeError: + continue + break + + return None + # ------------------------------------------------------------------ # Phase 1: Planning (with adaptive depth) # ------------------------------------------------------------------ @@ -292,7 +398,10 @@ class ResearchAgent(BaseAgent): try: response = self.llm.gen( - model=self.model_id, messages=messages, tools=None + model=self.model_id, + messages=messages, + tools=None, + response_format={"type": "json_object"}, ) text = self._extract_text(response) self._track_tokens(self._snapshot_llm_tokens()) diff --git a/application/api/answer/routes/base.py b/application/api/answer/routes/base.py index 2f9c9950..29d3d66c 100644 --- a/application/api/answer/routes/base.py +++ b/application/api/answer/routes/base.py @@ -206,9 +206,12 @@ class BaseAnswerResource: is_structured = False schema_info = None structured_chunks = [] + query_metadata = {} for line in agent.gen(query=question): - if "answer" in line: + if "metadata" in line: + query_metadata.update(line["metadata"]) + elif "answer" in line: response_full += str(line["answer"]) if line.get("structured"): is_structured = True @@ -295,6 +298,7 @@ class BaseAnswerResource: is_shared_usage=is_shared_usage, shared_token=shared_token, attachment_ids=attachment_ids, + metadata=query_metadata if query_metadata else None, ) # Persist compression metadata/summary if it exists and wasn't saved mid-execution compression_meta = getattr(agent, "compression_metadata", None) @@ -384,6 +388,7 @@ class BaseAnswerResource: is_shared_usage=is_shared_usage, shared_token=shared_token, attachment_ids=attachment_ids, + metadata=query_metadata if query_metadata else None, ) compression_meta = getattr(agent, "compression_metadata", None) compression_saved = getattr(agent, "compression_saved", False) diff --git a/application/api/answer/services/conversation_service.py b/application/api/answer/services/conversation_service.py index 09baf3d1..00fc52cc 100644 --- a/application/api/answer/services/conversation_service.py +++ b/application/api/answer/services/conversation_service.py @@ -60,6 +60,7 @@ class ConversationService: is_shared_usage: bool = False, shared_token: Optional[str] = None, attachment_ids: Optional[List[str]] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> str: """Save or update a conversation in the database""" if decoded_token is None: @@ -93,6 +94,11 @@ class ConversationService: f"queries.{index}.timestamp": current_time, f"queries.{index}.attachments": attachment_ids, f"queries.{index}.model_id": model_id, + **( + {f"queries.{index}.metadata": metadata} + if metadata + else {} + ), } }, ) @@ -124,6 +130,7 @@ class ConversationService: "timestamp": current_time, "attachments": attachment_ids, "model_id": model_id, + **({"metadata": metadata} if metadata else {}), } } }, @@ -156,22 +163,24 @@ class ConversationService: if not completion or not completion.strip(): completion = question[:50] if question else "New Conversation" + query_doc = { + "prompt": question, + "response": response, + "thought": thought, + "sources": sources, + "tool_calls": tool_calls, + "timestamp": current_time, + "attachments": attachment_ids, + "model_id": model_id, + } + if metadata: + query_doc["metadata"] = metadata + conversation_data = { "user": user_id, "date": current_time, "name": completion, - "queries": [ - { - "prompt": question, - "response": response, - "thought": thought, - "sources": sources, - "tool_calls": tool_calls, - "timestamp": current_time, - "attachments": attachment_ids, - "model_id": model_id, - } - ], + "queries": [query_doc], } if api_key: diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index 904f6d3f..f7ee03d6 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -158,9 +158,17 @@ class StreamProcessor: if settings.ENABLE_CONVERSATION_COMPRESSION: self._handle_compression(conversation) else: - # Original behavior - load all history + # Original behavior - load all history (include metadata if present) self.history = [ - {"prompt": query["prompt"], "response": query["response"]} + { + "prompt": query["prompt"], + "response": query["response"], + **( + {"metadata": query["metadata"]} + if "metadata" in query + else {} + ), + } for query in conversation.get("queries", []) ] else: @@ -181,7 +189,11 @@ class StreamProcessor: if not result.success: logger.error(f"Compression failed: {result.error}, using full history") self.history = [ - {"prompt": query["prompt"], "response": query["response"]} + { + "prompt": query["prompt"], + "response": query["response"], + **({"metadata": query["metadata"]} if "metadata" in query else {}), + } for query in conversation.get("queries", []) ] return @@ -197,6 +209,14 @@ class StreamProcessor: ) self.history = result.as_history() + # Preserve metadata from recent queries (as_history only has prompt/response) + recent = result.recent_queries if result.recent_queries else conversation.get("queries", []) + for i, entry in enumerate(self.history): + # Match by index from the end of recent queries + offset = len(recent) - len(self.history) + qi = offset + i + if 0 <= qi < len(recent) and "metadata" in recent[qi]: + entry["metadata"] = recent[qi]["metadata"] except Exception as e: logger.error( @@ -204,7 +224,11 @@ class StreamProcessor: exc_info=True, ) self.history = [ - {"prompt": query["prompt"], "response": query["response"]} + { + "prompt": query["prompt"], + "response": query["response"], + **({"metadata": query["metadata"]} if "metadata" in query else {}), + } for query in conversation.get("queries", []) ] diff --git a/application/prompts/research/clarification.txt b/application/prompts/research/clarification.txt new file mode 100644 index 00000000..000c73c2 --- /dev/null +++ b/application/prompts/research/clarification.txt @@ -0,0 +1,23 @@ +You are a research assistant evaluating whether a user's question is clear enough to begin in-depth research. + +Decide whether the question can be researched as-is, or whether you need to ask clarifying questions first. + +Proceed WITHOUT clarification if: +- The question has a clear topic and intent +- There is enough context to form a research plan +- The question is broad but can be broken into sub-topics +- Documents/sources are available to search + +Ask for clarification ONLY if: +- The question is critically ambiguous (e.g., "review the thing" with no context about what "thing" means) +- Key information is missing that would make research impossible (not just imperfect) +- The user seems to reference something specific but hasn't provided it + +Err on the side of proceeding. Most questions are clear enough to start researching. + +You MUST respond with ONLY a valid JSON object (no markdown, no code fences): +{ + "needs_clarification": true or false, + "questions": ["question 1", "question 2"] (only if needs_clarification is true, max 3 questions), + "reason": "brief explanation of why clarification is or isn't needed" +} \ No newline at end of file diff --git a/application/prompts/research/planning.txt b/application/prompts/research/planning.txt index 65f9b0cb..8263bcaf 100644 --- a/application/prompts/research/planning.txt +++ b/application/prompts/research/planning.txt @@ -1,5 +1,7 @@ You are a research planner. Your job is to analyze the user's question and create a focused research plan. +IMPORTANT: Every step must be a concrete research action — something you can search for and find information about. Never generate steps that ask the user for more information or request documents. Work with what you have. + Assess the question's complexity: - "simple": Can be answered with 1-2 focused searches (e.g., "What is our refund policy?") - "moderate": Needs 3-4 searches across different topics (e.g., "Compare our pricing across product lines") diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index fed5e6cf..ca2e2cbc 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -77,12 +77,16 @@ export const fetchAnswer = createAsyncThunk< if (currentConversationId === state.conversation.conversationId) { if (data.type === 'end') { dispatch(conversationSlice.actions.setStatus('idle')); - dispatch( - updateResearchProgress({ - index: targetIndex, - progress: { status: 'complete' }, - }), - ); + // Only update research status if this query has research data + const currentState = getState() as RootState; + if (currentState.conversation.queries[targetIndex]?.research) { + dispatch( + updateResearchProgress({ + index: targetIndex, + progress: { status: 'complete' }, + }), + ); + } getConversations(state.preference.token) .then((fetchedConversations) => { dispatch(setConversations(fetchedConversations)); From e8d11fdfa6038f5f3ebdef08b7ddffcde33ee98f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 19:21:46 +0000 Subject: [PATCH 11/41] feat: list files internal tool --- application/agents/agentic_agent.py | 47 +++- application/agents/research_agent.py | 11 +- application/agents/tools/internal_search.py | 270 ++++++++++++++++++-- 3 files changed, 301 insertions(+), 27 deletions(-) diff --git a/application/agents/agentic_agent.py b/application/agents/agentic_agent.py index f642402a..c8af7a2a 100644 --- a/application/agents/agentic_agent.py +++ b/application/agents/agentic_agent.py @@ -3,9 +3,9 @@ from typing import Dict, Generator, Optional from application.agents.base import BaseAgent from application.agents.tools.internal_search import ( - INTERNAL_TOOL_ENTRY, INTERNAL_TOOL_ID, build_internal_tool_config, + build_internal_tool_entry, ) from application.logging import LogContext @@ -39,9 +39,13 @@ class AgenticAgent(BaseAgent): source = self.retriever_config.get("source", {}) has_sources = bool(source.get("active_docs")) if self.retriever_config and has_sources: - internal_entry = dict(INTERNAL_TOOL_ENTRY) + has_dir = _sources_have_directory_structure(source) + internal_entry = build_internal_tool_entry( + has_directory_structure=has_dir + ) internal_entry["config"] = build_internal_tool_config( - **self.retriever_config + **self.retriever_config, + has_directory_structure=has_dir, ) tools_dict[INTERNAL_TOOL_ID] = internal_entry @@ -74,3 +78,40 @@ class AgenticAgent(BaseAgent): tool = self.tool_executor._loaded_tools.get(cache_key) if tool and hasattr(tool, "retrieved_docs") and tool.retrieved_docs: self.retrieved_docs = tool.retrieved_docs + + +def _sources_have_directory_structure(source: Dict) -> bool: + """Check if any of the active sources have directory_structure in MongoDB.""" + active_docs = source.get("active_docs", []) + if not active_docs: + return False + + try: + from bson.objectid import ObjectId + from application.core.mongo_db import MongoDB + + mongo = MongoDB.get_client() + db = mongo[settings.MONGO_DB_NAME] + sources_collection = db["sources"] + + if isinstance(active_docs, str): + active_docs = [active_docs] + + for doc_id in active_docs: + try: + source_doc = sources_collection.find_one( + {"_id": ObjectId(doc_id)}, + {"directory_structure": 1}, + ) + if source_doc and source_doc.get("directory_structure"): + return True + except Exception: + continue + except Exception as e: + logger.debug(f"Could not check directory structure: {e}") + + return False + + +# Import settings at module level for _sources_have_directory_structure +from application.core.settings import settings # noqa: E402 diff --git a/application/agents/research_agent.py b/application/agents/research_agent.py index 1e4f9a73..b743cb16 100644 --- a/application/agents/research_agent.py +++ b/application/agents/research_agent.py @@ -6,10 +6,11 @@ from typing import Dict, Generator, List, Optional from application.agents.base import BaseAgent from application.agents.tool_executor import ToolExecutor +from application.agents.agentic_agent import _sources_have_directory_structure from application.agents.tools.internal_search import ( - INTERNAL_TOOL_ENTRY, INTERNAL_TOOL_ID, build_internal_tool_config, + build_internal_tool_entry, ) from application.agents.tools.think import THINK_TOOL_ENTRY, THINK_TOOL_ID from application.logging import LogContext @@ -275,9 +276,13 @@ class ResearchAgent(BaseAgent): source = self.retriever_config.get("source", {}) has_sources = bool(source.get("active_docs")) if self.retriever_config and has_sources: - internal_entry = dict(INTERNAL_TOOL_ENTRY) + has_dir = _sources_have_directory_structure(source) + internal_entry = build_internal_tool_entry( + has_directory_structure=has_dir + ) internal_entry["config"] = build_internal_tool_config( - **self.retriever_config + **self.retriever_config, + has_directory_structure=has_dir, ) tools_dict[INTERNAL_TOOL_ID] = internal_entry elif self.retriever_config and not has_sources: diff --git a/application/agents/tools/internal_search.py b/application/agents/tools/internal_search.py index 0b8c2c47..18b9c514 100644 --- a/application/agents/tools/internal_search.py +++ b/application/agents/tools/internal_search.py @@ -1,3 +1,4 @@ +import json import logging from typing import Dict, List, Optional @@ -13,19 +14,25 @@ class InternalSearchTool(Tool): Instead of pre-fetching docs into the prompt, the LLM decides when and what to search. Supports multiple searches per session. + + Optional capabilities (enabled when sources have directory_structure): + - path_filter on search: restrict results to a specific file/folder + - list_files action: browse the file/folder structure """ def __init__(self, config: Dict): self.config = config self.retrieved_docs: List[Dict] = [] self._retriever = None + self._directory_structure: Optional[Dict] = None + self._dir_structure_loaded = False def _get_retriever(self): if self._retriever is None: self._retriever = RetrieverCreator.create_retriever( self.config.get("retriever_name", "classic"), source=self.config.get("source", {}), - chat_history=[], # no rephrasing — LLM controls the query + chat_history=[], prompt="", chunks=int(self.config.get("chunks", 2)), doc_token_limit=int(self.config.get("doc_token_limit", 50000)), @@ -38,11 +45,65 @@ class InternalSearchTool(Tool): ) return self._retriever - def execute_action(self, action_name: str, **kwargs): - if action_name != "search": - return f"Unknown action: {action_name}" + def _get_directory_structure(self) -> Optional[Dict]: + """Load directory structure from MongoDB for the configured sources.""" + if self._dir_structure_loaded: + return self._directory_structure + self._dir_structure_loaded = True + source = self.config.get("source", {}) + active_docs = source.get("active_docs", []) + if not active_docs: + return None + + try: + from bson.objectid import ObjectId + from application.core.mongo_db import MongoDB + + mongo = MongoDB.get_client() + db = mongo[settings.MONGO_DB_NAME] + sources_collection = db["sources"] + + if isinstance(active_docs, str): + active_docs = [active_docs] + + merged_structure = {} + for doc_id in active_docs: + try: + source_doc = sources_collection.find_one( + {"_id": ObjectId(doc_id)} + ) + if not source_doc: + continue + dir_str = source_doc.get("directory_structure") + if dir_str: + if isinstance(dir_str, str): + dir_str = json.loads(dir_str) + source_name = source_doc.get("name", doc_id) + if len(active_docs) > 1: + merged_structure[source_name] = dir_str + else: + merged_structure = dir_str + except Exception as e: + logger.debug(f"Could not load dir structure for {doc_id}: {e}") + + self._directory_structure = merged_structure if merged_structure else None + except Exception as e: + logger.debug(f"Failed to load directory structures: {e}") + + return self._directory_structure + + def execute_action(self, action_name: str, **kwargs): + if action_name == "search": + return self._execute_search(**kwargs) + elif action_name == "list_files": + return self._execute_list_files(**kwargs) + return f"Unknown action: {action_name}" + + def _execute_search(self, **kwargs) -> str: query = kwargs.get("query", "") + path_filter = kwargs.get("path_filter", "") + if not query: return "Error: 'query' parameter is required." @@ -56,6 +117,19 @@ class InternalSearchTool(Tool): if not docs: return "No documents found matching your query." + # Apply path filter if specified + if path_filter: + path_lower = path_filter.lower() + docs = [ + d + for d in docs + if path_lower in d.get("source", "").lower() + or path_lower in d.get("filename", "").lower() + or path_lower in d.get("title", "").lower() + ] + if not docs: + return f"No documents found matching query '{query}' in path '{path_filter}'." + # Accumulate for source tracking for doc in docs: if doc not in self.retrieved_docs: @@ -73,8 +147,81 @@ class InternalSearchTool(Tool): return "\n\n---\n\n".join(formatted) + def _execute_list_files(self, **kwargs) -> str: + path = kwargs.get("path", "") + dir_structure = self._get_directory_structure() + + if not dir_structure: + return "No file structure available for the current sources." + + # Navigate to the requested path + current = dir_structure + if path: + for part in path.strip("/").split("/"): + if not part: + continue + if isinstance(current, dict) and part in current: + current = current[part] + else: + return f"Path '{path}' not found in the file structure." + + # Format the structure for the LLM + return self._format_structure(current, path or "/") + + def _format_structure(self, node: Dict, current_path: str) -> str: + if not isinstance(node, dict): + return f"'{current_path}' is a file, not a directory." + + lines = [f"File structure at '{current_path}':\n"] + folders = [] + files = [] + + for name, value in sorted(node.items()): + if isinstance(value, dict): + # Check if it's a file metadata dict or a folder + if "type" in value or "size_bytes" in value or "token_count" in value: + # It's a file with metadata + size = value.get("token_count", "") + ftype = value.get("type", "") + info_parts = [] + if ftype: + info_parts.append(ftype) + if size: + info_parts.append(f"{size} tokens") + info = f" ({', '.join(info_parts)})" if info_parts else "" + files.append(f" {name}{info}") + else: + # It's a folder + count = self._count_files(value) + folders.append(f" {name}/ ({count} items)") + else: + files.append(f" {name}") + + if folders: + lines.append("Folders:") + lines.extend(folders) + if files: + lines.append("Files:") + lines.extend(files) + if not folders and not files: + lines.append(" (empty)") + + return "\n".join(lines) + + def _count_files(self, node: Dict) -> int: + count = 0 + for value in node.values(): + if isinstance(value, dict): + if "type" in value or "size_bytes" in value or "token_count" in value: + count += 1 + else: + count += self._count_files(value) + else: + count += 1 + return count + def get_actions_metadata(self): - return [ + actions = [ { "name": "search", "description": ( @@ -89,12 +236,47 @@ class InternalSearchTool(Tool): "description": "The search query. Be specific and focused.", "filled_by_llm": True, "required": True, - } + }, } }, } ] + # Add path_filter and list_files only if directory structure exists + has_structure = self.config.get("has_directory_structure", False) + if has_structure: + actions[0]["parameters"]["properties"]["path_filter"] = { + "type": "string", + "description": ( + "Optional: filter results to a specific file or folder path. " + "Use list_files first to see available paths." + ), + "filled_by_llm": True, + "required": False, + } + actions.append( + { + "name": "list_files", + "description": ( + "Browse the file and folder structure of the knowledge base. " + "Use this to see what files are available before searching. " + "Optionally provide a path to browse a specific folder." + ), + "parameters": { + "properties": { + "path": { + "type": "string", + "description": "Optional: folder path to browse. Leave empty for root.", + "filled_by_llm": True, + "required": False, + } + } + }, + } + ) + + return actions + def get_config_requirements(self): return {} @@ -102,9 +284,25 @@ class InternalSearchTool(Tool): # Constants for building synthetic tools_dict entries INTERNAL_TOOL_ID = "internal" -INTERNAL_TOOL_ENTRY = { - "name": "internal_search", - "actions": [ + +def build_internal_tool_entry(has_directory_structure: bool = False) -> Dict: + """Build the tools_dict entry for InternalSearchTool. + + Dynamically includes list_files and path_filter based on + whether the sources have directory structure. + """ + search_params = { + "properties": { + "query": { + "type": "string", + "description": "The search query. Be specific and focused.", + "filled_by_llm": True, + "required": True, + } + } + } + + actions = [ { "name": "search", "description": ( @@ -113,19 +311,47 @@ INTERNAL_TOOL_ENTRY = { "You can call this multiple times with different queries." ), "active": True, - "parameters": { - "properties": { - "query": { - "type": "string", - "description": "The search query. Be specific and focused.", - "filled_by_llm": True, - "required": True, - } - } - }, + "parameters": search_params, } - ], -} + ] + + if has_directory_structure: + search_params["properties"]["path_filter"] = { + "type": "string", + "description": ( + "Optional: filter results to a specific file or folder path. " + "Use list_files first to see available paths." + ), + "filled_by_llm": True, + "required": False, + } + actions.append( + { + "name": "list_files", + "description": ( + "Browse the file and folder structure of the knowledge base. " + "Use this to see what files are available before searching. " + "Optionally provide a path to browse a specific folder." + ), + "active": True, + "parameters": { + "properties": { + "path": { + "type": "string", + "description": "Optional: folder path to browse. Leave empty for root.", + "filled_by_llm": True, + "required": False, + } + } + }, + } + ) + + return {"name": "internal_search", "actions": actions} + + +# Keep backward compat +INTERNAL_TOOL_ENTRY = build_internal_tool_entry(has_directory_structure=False) def build_internal_tool_config( @@ -139,6 +365,7 @@ def build_internal_tool_config( llm_name: str = None, api_key: str = None, decoded_token: Optional[Dict] = None, + has_directory_structure: bool = False, ) -> Dict: """Build the config dict for InternalSearchTool.""" return { @@ -152,4 +379,5 @@ def build_internal_tool_config( "llm_name": llm_name or settings.LLM_PROVIDER, "api_key": api_key or settings.API_KEY, "decoded_token": decoded_token, + "has_directory_structure": has_directory_structure, } From a3e6a5622dbc8eef15500a2836682049f5ac2979 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Thu, 26 Mar 2026 01:03:09 +0530 Subject: [PATCH 12/41] (chore)react-widget: build, publish act --- .github/workflows/npm-publish.yml | 97 ++++++++++++++++++++++++ .github/workflows/react-widget-build.yml | 34 +++++++++ 2 files changed, 131 insertions(+) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 .github/workflows/react-widget-build.yml diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 00000000..a930d9f6 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,97 @@ +name: Publish npm libraries + +on: + workflow_dispatch: + inputs: + version: + description: > + Version bump type (patch | minor | major) or explicit semver (e.g. 1.2.3). + Applies to both docsgpt and docsgpt-react. + required: true + default: patch + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-release + defaults: + run: + working-directory: extensions/react-widget + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: npm ci + + # ── docsgpt (HTML embedding bundle) ────────────────────────────────── + # Uses the `build` script (parcel build src/browser.tsx) and keeps + # the `targets` field so Parcel produces browser-optimised bundles. + + - name: Set package name → docsgpt + run: jq --arg n "docsgpt" '.name=$n' package.json > _tmp.json && mv _tmp.json package.json + + - name: Bump version (docsgpt) + id: version_docsgpt + run: | + VERSION="${{ github.event.inputs.version }}" + NEW_VER=$(npm version "${VERSION:-patch}" --no-git-tag-version) + echo "version=${NEW_VER#v}" >> "$GITHUB_OUTPUT" + + - name: Build docsgpt + run: npm run build + + - name: Publish docsgpt + run: npm publish --verbose + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # ── docsgpt-react (React library bundle) ───────────────────────────── + # Uses `build:react` script (parcel build src/index.ts) and strips + # the `targets` field so Parcel treats the output as a plain library + # without browser-specific target resolution, producing a smaller bundle. + + - name: Reset package.json from source control + run: git checkout -- package.json + + - name: Set package name → docsgpt-react + run: jq --arg n "docsgpt-react" '.name=$n' package.json > _tmp.json && mv _tmp.json package.json + + - name: Remove targets field (react library build) + run: jq 'del(.targets)' package.json > _tmp.json && mv _tmp.json package.json + + - name: Bump version (docsgpt-react) to match docsgpt + run: npm version "${{ steps.version_docsgpt.outputs.version }}" --no-git-tag-version + + - name: Clean dist before react build + run: rm -rf dist + + - name: Build docsgpt-react + run: npm run build:react + + - name: Publish docsgpt-react + run: npm publish --verbose + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # ── Commit the bumped version back to the repository ───────────────── + + - name: Reset package.json and write final version + run: | + git checkout -- package.json + jq --arg v "${{ steps.version_docsgpt.outputs.version }}" '.version=$v' \ + package.json > _tmp.json && mv _tmp.json package.json + npm install --package-lock-only + + - name: Commit version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json + git commit -m "chore: bump npm libraries to v${{ steps.version_docsgpt.outputs.version }}" + git push diff --git a/.github/workflows/react-widget-build.yml b/.github/workflows/react-widget-build.yml new file mode 100644 index 00000000..95389d6b --- /dev/null +++ b/.github/workflows/react-widget-build.yml @@ -0,0 +1,34 @@ +name: React Widget Build + +on: + push: + paths: + - 'extensions/react-widget/**' + pull_request: + paths: + - 'extensions/react-widget/**' + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: extensions/react-widget + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: extensions/react-widget/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build From c6ece177cd8d6ec0b4aad26262ac559bc507c7c3 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 20:04:03 +0000 Subject: [PATCH 13/41] fix: small issues --- .gitignore | 1 + application/agents/tools/internal_search.py | 2 +- results.txt | 12 ------------ 3 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 results.txt diff --git a/.gitignore b/.gitignore index 9b09303d..4d8f8c84 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[cod] *$py.class +results.txt experiments/ experiments diff --git a/application/agents/tools/internal_search.py b/application/agents/tools/internal_search.py index 18b9c514..66f91e83 100644 --- a/application/agents/tools/internal_search.py +++ b/application/agents/tools/internal_search.py @@ -112,7 +112,7 @@ class InternalSearchTool(Tool): docs = retriever.search(query) except Exception as e: logger.error(f"Internal search failed: {e}", exc_info=True) - return f"Search failed: {str(e)}" + return "Search failed: an internal error occurred." if not docs: return "No documents found matching your query." diff --git a/results.txt b/results.txt deleted file mode 100644 index beff8056..00000000 --- a/results.txt +++ /dev/null @@ -1,12 +0,0 @@ -Base URL:http://petstore.swagger.io,https://api.example.com -Path1: /pets -description: None -parameters: [] -methods: -get=A paged array of pets -post=Null response -Path2: /pets/{petId} -description: None -parameters: [] -methods: -get=Expected response to a valid request From c4968a641e1b0c36b5560722c8407c36de39d681 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Thu, 26 Mar 2026 02:06:49 +0530 Subject: [PATCH 14/41] (chore)react-widget: add linter --- extensions/react-widget/eslint.config.mjs | 72 + extensions/react-widget/package-lock.json | 3339 ++++++++++++++++- extensions/react-widget/package.json | 14 +- extensions/react-widget/prettier.config.cjs | 8 + extensions/react-widget/src/App.tsx | 14 +- extensions/react-widget/src/browser.tsx | 10 +- .../src/components/DocsGPTWidget.tsx | 1196 +++--- .../react-widget/src/components/SearchBar.tsx | 953 ++--- extensions/react-widget/src/index.ts | 4 +- extensions/react-widget/src/main.tsx | 11 +- .../react-widget/src/requests/searchAPI.ts | 27 +- .../react-widget/src/requests/streamingApi.ts | 20 +- extensions/react-widget/src/types/index.ts | 48 +- extensions/react-widget/src/utils/helper.ts | 42 +- 14 files changed, 4691 insertions(+), 1067 deletions(-) create mode 100644 extensions/react-widget/eslint.config.mjs create mode 100644 extensions/react-widget/prettier.config.cjs diff --git a/extensions/react-widget/eslint.config.mjs b/extensions/react-widget/eslint.config.mjs new file mode 100644 index 00000000..987d463c --- /dev/null +++ b/extensions/react-widget/eslint.config.mjs @@ -0,0 +1,72 @@ +import js from '@eslint/js' +import tsParser from '@typescript-eslint/parser' +import tsPlugin from '@typescript-eslint/eslint-plugin' +import react from 'eslint-plugin-react' +import unusedImports from 'eslint-plugin-unused-imports' +import prettier from 'eslint-plugin-prettier' +import globals from 'globals' + +export default [ + { + ignores: [ + 'node_modules/', + 'dist/', + 'prettier.config.cjs', + 'custom.d.ts', + 'package-lock.json', + 'package.json', + ], + }, + { + files: ['**/*.{js,jsx,ts,tsx}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parser: tsParser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...globals.browser, + ...globals.es2021, + ...globals.node, + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + react, + 'unused-imports': unusedImports, + prettier, + }, + rules: { + ...js.configs.recommended.rules, + ...tsPlugin.configs.recommended.rules, + ...react.configs.recommended.rules, + ...prettier.configs.recommended.rules, + 'react/prop-types': 'off', + 'unused-imports/no-unused-imports': 'error', + 'react/react-in-jsx-scope': 'off', + 'no-undef': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-unused-expressions': 'warn', + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], + }, + settings: { + react: { + version: 'detect', + }, + }, + }, +] + diff --git a/extensions/react-widget/package-lock.json b/extensions/react-widget/package-lock.json index ddc434cd..027e6a13 100644 --- a/extensions/react-widget/package-lock.json +++ b/extensions/react-widget/package-lock.json @@ -29,14 +29,24 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-react": "^7.23.3", + "@eslint/js": "^9.39.4", "@parcel/packager-ts": "^2.16.4", "@parcel/transformer-typescript-types": "^2.16.4", "@types/dompurify": "^3.0.5", "@types/markdown-it": "^14.1.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", "babel-loader": "^10.1.1", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^15.15.0", "parcel": "^2.16.4", + "prettier": "^3.8.1", "process": "^0.11.10", "svgo": "^3.3.3", "typescript": "^5.3.3" @@ -917,6 +927,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-classes/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.6.tgz", @@ -1812,6 +1832,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { "version": "7.27.3", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", @@ -1853,6 +1882,274 @@ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", "license": "MIT" }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -3891,6 +4188,19 @@ "@parcel/core": "^2.16.4" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", @@ -4345,6 +4655,20 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -4407,6 +4731,292 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "devOptional": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4427,6 +5037,170 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/babel-loader": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.1.tgz", @@ -4492,6 +5266,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base-x": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", @@ -4520,6 +5304,19 @@ "dev": true, "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -4564,6 +5361,56 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4700,6 +5547,13 @@ "node": ">= 10" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4744,6 +5598,21 @@ } } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -4848,12 +5717,67 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4864,6 +5788,49 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -4875,6 +5842,19 @@ "node": ">=0.10" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -4980,6 +5960,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.313", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", @@ -5006,6 +6001,184 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5015,6 +6188,381 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5024,6 +6572,65 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5052,6 +6659,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/flow-bin": { "version": "0.306.0", "resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.306.0.tgz", @@ -5064,6 +6692,22 @@ "node": ">=0.10.0" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5073,6 +6717,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5081,6 +6766,31 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-4.2.0.tgz", @@ -5090,12 +6800,105 @@ "node": ">=6" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=4" + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-flag": { @@ -5107,6 +6910,64 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5119,6 +6980,16 @@ "node": ">= 0.4" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5135,19 +7006,167 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5161,6 +7180,42 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5172,6 +7227,32 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5180,6 +7261,200 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5208,12 +7483,33 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5225,6 +7521,46 @@ "node": ">=6" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5562,6 +7898,13 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5607,6 +7950,16 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -5632,10 +7985,39 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/msgpackr": { "version": "1.11.9", @@ -5696,6 +8078,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -5714,6 +8103,25 @@ "node": "^16 || ^18 || >= 20" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", @@ -5759,11 +8167,155 @@ "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ordered-binary": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.1.tgz", "integrity": "sha512-5VyHfHY3cd0iza71JepYG50My+YUbrFtGoUz2ooEydPyPM7Aai/JW098juLr+RG6+rDJuzNNTsEQu2DZa1A41A==" }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5921,6 +8473,16 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -5942,16 +8504,28 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -5985,6 +8559,45 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -5994,6 +8607,28 @@ "node": ">= 0.6.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -6026,6 +8661,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.16.0.tgz", @@ -6036,6 +8678,29 @@ "node": ">=0.10.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -6070,6 +8735,27 @@ "@babel/runtime": "^7.8.4" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regexpu-core": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", @@ -6134,6 +8820,26 @@ "node": ">=4" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6153,6 +8859,41 @@ } ] }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sax": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", @@ -6179,11 +8920,159 @@ "semver": "bin/semver.js" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -6203,6 +9092,131 @@ "node": ">=0.10.0" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/styled-components": { "version": "6.3.12", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz", @@ -6298,6 +9312,22 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -6311,6 +9341,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6322,12 +9369,38 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -6341,6 +9414,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6360,6 +9511,25 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -6430,6 +9600,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utility-types": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", @@ -6443,6 +9623,121 @@ "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index 761c9d83..072a700f 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -35,7 +35,9 @@ "serve": "parcel serve -p 3000", "dev": "parcel -p 3000", "test": "jest", - "lint": "eslint", + "lint": "eslint ./src --ext .jsx,.js,.ts,.tsx", + "lint-fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --fix", + "format": "prettier ./src --write", "check": "tsc --noEmit", "ci": "yarn build && yarn test && yarn lint && yarn check" }, @@ -60,14 +62,24 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-react": "^7.23.3", + "@eslint/js": "^9.39.4", "@parcel/packager-ts": "^2.16.4", "@parcel/transformer-typescript-types": "^2.16.4", "@types/dompurify": "^3.0.5", "@types/markdown-it": "^14.1.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", "babel-loader": "^10.1.1", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^15.15.0", "parcel": "^2.16.4", + "prettier": "^3.8.1", "process": "^0.11.10", "svgo": "^3.3.3", "typescript": "^5.3.3" diff --git a/extensions/react-widget/prettier.config.cjs b/extensions/react-widget/prettier.config.cjs new file mode 100644 index 00000000..61f01195 --- /dev/null +++ b/extensions/react-widget/prettier.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + trailingComma: 'all', + tabWidth: 2, + semi: true, + singleQuote: true, + printWidth: 80, +}; + diff --git a/extensions/react-widget/src/App.tsx b/extensions/react-widget/src/App.tsx index 4bb24bae..45567031 100644 --- a/extensions/react-widget/src/App.tsx +++ b/extensions/react-widget/src/App.tsx @@ -1,11 +1,11 @@ -import React from "react" -import {DocsGPTWidget} from "./components/DocsGPTWidget" -import {SearchBar} from "./components/SearchBar" +import React from 'react'; +import { DocsGPTWidget } from './components/DocsGPTWidget'; +import { SearchBar } from './components/SearchBar'; export const App = () => { return (
- - + +
- ) -} \ No newline at end of file + ); +}; diff --git a/extensions/react-widget/src/browser.tsx b/extensions/react-widget/src/browser.tsx index 8bee7748..d28afe8f 100644 --- a/extensions/react-widget/src/browser.tsx +++ b/extensions/react-widget/src/browser.tsx @@ -1,10 +1,10 @@ //exports browser ready methods -import { createRoot } from "react-dom/client"; +import { createRoot } from 'react-dom/client'; import { DocsGPTWidget } from './components/DocsGPTWidget'; import { SearchBar } from './components/SearchBar'; -import React from "react"; +import React from 'react'; if (typeof window !== 'undefined') { const renderWidget = (elementId: string, props = {}) => { const root = createRoot(document.getElementById(elementId) as HTMLElement); @@ -14,9 +14,11 @@ if (typeof window !== 'undefined') { const root = createRoot(document.getElementById(elementId) as HTMLElement); root.render(); }; - (window as any).renderDocsGPTWidget = renderWidget; + (window as unknown as Record).renderDocsGPTWidget = + renderWidget; - (window as any).renderSearchBar = renderSearchBar; + (window as unknown as Record).renderSearchBar = + renderSearchBar; } export { DocsGPTWidget, SearchBar }; diff --git a/extensions/react-widget/src/components/DocsGPTWidget.tsx b/extensions/react-widget/src/components/DocsGPTWidget.tsx index 61a2961f..ad7075ce 100644 --- a/extensions/react-widget/src/components/DocsGPTWidget.tsx +++ b/extensions/react-widget/src/components/DocsGPTWidget.tsx @@ -1,24 +1,64 @@ -"use client"; -import React, { useRef, useState, useEffect } from 'react' +'use client'; +import React, { useRef, useState } from 'react'; import DOMPurify from 'dompurify'; import styled, { keyframes, css } from 'styled-components'; -import { PaperPlaneIcon, RocketIcon, ExclamationTriangleIcon, Cross2Icon } from '@radix-ui/react-icons'; -import { FEEDBACK, MESSAGE_TYPE, Query, Status, WidgetCoreProps, WidgetProps } from '../types/index'; +import { + PaperPlaneIcon, + RocketIcon, + ExclamationTriangleIcon, + Cross2Icon, +} from '@radix-ui/react-icons'; +import { + FEEDBACK, + MESSAGE_TYPE, + Query, + Status, + WidgetCoreProps, + WidgetProps, +} from '../types/index'; import { fetchAnswerStreaming, sendFeedback } from '../requests/streamingApi'; import { ThemeProvider } from 'styled-components'; import MarkdownIt from 'markdown-it'; const LikeIcon = (props: React.SVGProps) => ( - - - + + + ); const DislikeIcon = (props: React.SVGProps) => ( - - - + + + ); @@ -27,33 +67,38 @@ const themes = { bg: '#222327', text: '#fff', primary: { - text: "#FAFAFA", - bg: '#222327' + text: '#FAFAFA', + bg: '#222327', }, secondary: { - text: "#A1A1AA", - bg: "#38383b" - } + text: '#A1A1AA', + bg: '#38383b', + }, }, light: { bg: '#fff', text: '#000', primary: { - text: "#222327", - bg: "#fff" + text: '#222327', + bg: '#fff', }, secondary: { - text: "#A1A1AA", - bg: "#F6F6F6" - } - } + text: '#A1A1AA', + bg: '#F6F6F6', + }, + }, }; const sizesConfig = { small: { size: 'small', width: '320px', height: '400px' }, medium: { size: 'medium', width: '400px', height: '80vh' }, large: { size: 'large', width: '666px', height: '75vh' }, - getCustom: (custom: { width: string; height: string; maxWidth?: string; maxHeight?: string }) => ({ + getCustom: (custom: { + width: string; + height: string; + maxWidth?: string; + maxHeight?: string; + }) => ({ size: 'custom', width: custom.width, height: custom.height, @@ -71,7 +116,7 @@ const createBox = keyframes` 100% { transform: scale(1); } -` +`; const closeBox = keyframes` 0% { transform: scale(1); @@ -82,7 +127,7 @@ const closeBox = keyframes` 100% { transform: scale(0.6); } -` +`; const openContainer = keyframes` 0% { @@ -93,7 +138,7 @@ const openContainer = keyframes` width: ${(props) => props.theme.dimensions!.width}; height: ${(props) => props.theme.dimensions!.height}; border-radius: 12px; - }` + }`; const closeContainer = keyframes` 0% { width: ${(props) => props.theme.dimensions!.width}; @@ -104,7 +149,7 @@ const closeContainer = keyframes` width: 200px; height: 100px; } -` +`; const fadeIn = keyframes` from { opacity: 0; @@ -118,7 +163,7 @@ const fadeIn = keyframes` width: ${(props) => props.theme.dimensions!.width}; height: ${(props) => props.theme.dimensions!.height}; } -` +`; const fadeOut = keyframes` from { @@ -132,7 +177,7 @@ const fadeOut = keyframes` width: ${(props) => props.theme.dimensions!.width}; height: ${(props) => props.theme.dimensions!.height}; } -` +`; const scaleAnimation = keyframes` from { transform: scale(1.2); @@ -140,7 +185,7 @@ const scaleAnimation = keyframes` to { transform: scale(1); } -` +`; const Overlay = styled.div` position: fixed; top: 0; @@ -150,134 +195,152 @@ const Overlay = styled.div` background-color: rgba(0, 0, 0, 0.5); z-index: 999; transition: opacity 0.5s; -` - +`; const WidgetContainer = styled.div<{ $modal?: boolean }>` - all: initial; - position: fixed; - right: ${props => props.$modal ? '50%' : '10px'}; - bottom: ${props => props.$modal ? '50%' : '10px'}; - z-index: 1001; - transform-origin:100% 100%; - display: block; - &.modal{ - transform : translate(50%,50%); - } - &.open { - animation: css ${createBox} 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; - } - &.close { - animation: css ${closeBox} 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; - } - align-items: center; - text-align: left; + all: initial; + position: fixed; + right: ${(props) => (props.$modal ? '50%' : '10px')}; + bottom: ${(props) => (props.$modal ? '50%' : '10px')}; + z-index: 1001; + transform-origin: 100% 100%; + display: block; + &.modal { + transform: translate(50%, 50%); + } + &.open { + animation: css ${createBox} 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; + } + &.close { + animation: css ${closeBox} 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; + } + align-items: center; + text-align: left; `; const StyledContainer = styled.div<{ $isOpen: boolean }>` - all: initial; - max-height: ${(props) => props.theme.dimensions!.maxHeight}; - max-width: ${(props) => props.theme.dimensions!.maxWidth}; - width: ${(props) => props.theme.dimensions!.width}; - height: ${(props) => props.theme.dimensions!.height} ; - position: relative; - flex-direction: column; - justify-content: space-between; - bottom: 0; - left: 0; - background-color: ${(props) => props.theme.primary.bg}; - font-family: sans-serif; - display: flex; - border-radius: 12px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.1); - padding: 26px 26px 0px 26px; - animation: ${({ $isOpen, theme }) => + all: initial; + max-height: ${(props) => props.theme.dimensions!.maxHeight}; + max-width: ${(props) => props.theme.dimensions!.maxWidth}; + width: ${(props) => props.theme.dimensions!.width}; + height: ${(props) => props.theme.dimensions!.height}; + position: relative; + flex-direction: column; + justify-content: space-between; + bottom: 0; + left: 0; + background-color: ${(props) => props.theme.primary.bg}; + font-family: sans-serif; + display: flex; + border-radius: 12px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.05), + 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 26px 26px 0px 26px; + animation: ${({ $isOpen, theme }) => theme.dimensions!.size === 'large' ? $isOpen - ? css`${fadeIn} 150ms ease-in forwards` - : css` ${fadeOut} 150ms ease-in forwards` + ? css` + ${fadeIn} 150ms ease-in forwards + ` + : css` + ${fadeOut} 150ms ease-in forwards + ` : $isOpen - ? css`${openContainer} 150ms ease-in forwards` - : css`${closeContainer} 250ms ease-in forwards`}; - @media only screen and (max-width: 768px) { - max-height: 100vh; - max-width: 80vw; - overflow: auto; - } + ? css` + ${openContainer} 150ms ease-in forwards + ` + : css` + ${closeContainer} 250ms ease-in forwards + `}; + @media only screen and (max-width: 768px) { + max-height: 100vh; + max-width: 80vw; + overflow: auto; + } `; -const FloatingButton = styled.div<{ $bgcolor: string, $hidden: boolean, $isAnimatingButton: boolean }>` - position: fixed; - display: ${props => props.$hidden ? "none" : "flex"}; - z-index: 500; - justify-content: center; - gap: 8px; - padding: 14px; - align-items: center; - bottom: 16px; - color: white; - font-family: sans-serif; - right: 16px; - font-weight: 500; - border-radius: 9999px; - background: ${props => props.$bgcolor}; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - cursor: pointer; - animation: ${props => props.$isAnimatingButton ? css`${scaleAnimation} 200ms forwards` : 'none'}; - &:hover { - transform: scale(1.1); - transition: transform 0.2s ease-in-out; - } - &:not(:hover) { - transition: transform 0.2s ease-in-out; - } +const FloatingButton = styled.div<{ + $bgcolor: string; + $hidden: boolean; + $isAnimatingButton: boolean; +}>` + position: fixed; + display: ${(props) => (props.$hidden ? 'none' : 'flex')}; + z-index: 500; + justify-content: center; + gap: 8px; + padding: 14px; + align-items: center; + bottom: 16px; + color: white; + font-family: sans-serif; + right: 16px; + font-weight: 500; + border-radius: 9999px; + background: ${(props) => props.$bgcolor}; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + cursor: pointer; + animation: ${(props) => + props.$isAnimatingButton + ? css` + ${scaleAnimation} 200ms forwards + ` + : 'none'}; + &:hover { + transform: scale(1.1); + transition: transform 0.2s ease-in-out; + } + &:not(:hover) { + transition: transform 0.2s ease-in-out; + } `; const CancelButton = styled.button` - cursor: pointer; - position: absolute; - top: 0; - right: 0; - margin: 8px; - width: 30px; - padding: 0; - background-color: transparent; - border: none; - outline: none; - color: inherit; - transition: opacity 0.3s ease; - opacity: 0.6; - &:hover { - opacity: 1; - } - .white-filter { - filter: invert(100%); - } + cursor: pointer; + position: absolute; + top: 0; + right: 0; + margin: 8px; + width: 30px; + padding: 0; + background-color: transparent; + border: none; + outline: none; + color: inherit; + transition: opacity 0.3s ease; + opacity: 0.6; + &:hover { + opacity: 1; + } + .white-filter { + filter: invert(100%); + } `; const Header = styled.div` - display: flex; - align-items: flex-start; + display: flex; + align-items: flex-start; `; const ContentWrapper = styled.div` - display: flex; - flex-direction: column; - gap:2px; - margin-left: 8px; + display: flex; + flex-direction: column; + gap: 2px; + margin-left: 8px; `; const Title = styled.h3` - font-size: 14px; - font-weight: normal; - color: ${props => props.theme.primary.text}; - margin: 0; + font-size: 14px; + font-weight: normal; + color: ${(props) => props.theme.primary.text}; + margin: 0; `; const Description = styled.p` - font-size: 13.75px; - color: ${props => props.theme.secondary.text}; - margin: 0 ; - padding: 0 ; + font-size: 13.75px; + color: ${(props) => props.theme.secondary.text}; + margin: 0; + padding: 0; `; const Conversation = styled.div` @@ -286,7 +349,7 @@ const Conversation = styled.div` text-align: left; overflow-y: auto; scrollbar-width: thin; - scrollbar-color: ${props => props.theme.secondary.bg} transparent; /* thumb color track color */ + scrollbar-color: ${(props) => props.theme.secondary.bg} transparent; /* thumb color track color */ `; const Feedback = styled.div` background-color: transparent; @@ -297,153 +360,149 @@ const Feedback = styled.div` clear: both; `; const MessageBubble = styled.div<{ $type: MESSAGE_TYPE }>` - display: block; - font-size: 16px; - position: relative; - width: 100%;; - float: right; - margin: 0px; - &:hover ${Feedback} * { - visibility: visible ; - - + display: block; + font-size: 16px; + position: relative; + width: 100%; + float: right; + margin: 0px; + &:hover ${Feedback} * { + visibility: visible; } `; const Message = styled.div<{ $type: MESSAGE_TYPE }>` - background: ${props => props.$type === 'QUESTION' ? - 'linear-gradient(to bottom right, #8860DB, #6D42C5)' : - props.theme.secondary.bg}; - color: ${props => props.$type === 'ANSWER' ? props.theme.primary.text : '#fff'}; - border: none; - float: ${props => props.$type === 'QUESTION' ? 'right' : 'left'}; - max-width: ${props => props.$type === 'ANSWER' ? '90%' : '80%'}; - overflow: auto; - margin: 4px; - display: block; - line-height: 1.5; - padding: 12px; - border-radius: 6px; - overflow-wrap: break-word; + background: ${(props) => + props.$type === 'QUESTION' + ? 'linear-gradient(to bottom right, #8860DB, #6D42C5)' + : props.theme.secondary.bg}; + color: ${(props) => + props.$type === 'ANSWER' ? props.theme.primary.text : '#fff'}; + border: none; + float: ${(props) => (props.$type === 'QUESTION' ? 'right' : 'left')}; + max-width: ${(props) => (props.$type === 'ANSWER' ? '90%' : '80%')}; + overflow: auto; + margin: 4px; + display: block; + line-height: 1.5; + padding: 12px; + border-radius: 6px; + overflow-wrap: break-word; `; const Markdown = styled.div` - pre { - padding: 8px; - width: 90%; - font-size: 12px; - border-radius: 6px; - overflow-x: auto; - background-color: #1B1C1F; - color: #fff ; - } + pre { + padding: 8px; + width: 90%; + font-size: 12px; + border-radius: 6px; + overflow-x: auto; + background-color: #1b1c1f; + color: #fff; + } - h1 { - font-size: clamp(14px,40vw,16px); - } + h1 { + font-size: clamp(14px, 40vw, 16px); + } - h2 { - font-size: 14px; - } + h2 { + font-size: 14px; + } - h3 { - font-size: 14px; - } + h3 { + font-size: 14px; + } - p { - - margin: 0px; - } + p { + margin: 0px; + } - code:not(pre code) { - border-radius: 6px; - padding: 1px 3px; - font-size: 12px; - display: inline-block; - background-color: #646464; - color: #fff ; - } + code:not(pre code) { + border-radius: 6px; + padding: 1px 3px; + font-size: 12px; + display: inline-block; + background-color: #646464; + color: #fff; + } - code { - white-space: pre-wrap ; - overflow-wrap: break-word; - word-break: break-all; - } + code { + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-all; + } - ul{ - padding:0px; - margin: 1rem 0; - list-style-position: outside; - list-style-type: disc; - padding-left: 1rem; - white-space: normal; - } - - ol{ - padding:0px; - margin: 1rem 0; - list-style-position: outside; - list-style-type: decimal; - padding-left: 1rem; - white-space: normal; - } - - li{ - line-height: 1.625; - } - .dgpt-table-container { - margin: 20px 0; - width:100%; - overflow-x: scroll !important; - border: 1px solid #a2a2ab; - border-radius: 6px; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: scrollbar; - scrollbar-width: thin; - scrollbar-color: #a2a2ab #38383b; - } - + ul { + padding: 0px; + margin: 1rem 0; + list-style-position: outside; + list-style-type: disc; + padding-left: 1rem; + white-space: normal; + } - table, .dgpt-table { - width: 100%; - border-collapse: collapse; - text-align: left; - min-width:600px; - - } - thead, .dgpt-thead { - font-size: 12px; - text-transform: uppercase; - - } - - - th, .dgpt-th, td, .dgpt-td { - padding: 10px; - border-bottom: 1px solid #a2a2ab; - font-size:14px; - - } - th{ - font-weight: normal !important; - } - td{ - font-weight: bold; - } - - - -` + ol { + padding: 0px; + margin: 1rem 0; + list-style-position: outside; + list-style-type: decimal; + padding-left: 1rem; + white-space: normal; + } + + li { + line-height: 1.625; + } + .dgpt-table-container { + margin: 20px 0; + width: 100%; + overflow-x: scroll !important; + border: 1px solid #a2a2ab; + border-radius: 6px; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: scrollbar; + scrollbar-width: thin; + scrollbar-color: #a2a2ab #38383b; + } + + table, + .dgpt-table { + width: 100%; + border-collapse: collapse; + text-align: left; + min-width: 600px; + } + thead, + .dgpt-thead { + font-size: 12px; + text-transform: uppercase; + } + + th, + .dgpt-th, + td, + .dgpt-td { + padding: 10px; + border-bottom: 1px solid #a2a2ab; + font-size: 14px; + } + th { + font-weight: normal !important; + } + td { + font-weight: bold; + } +`; const ErrorAlert = styled.div` color: #b91c1c; - border:0.1px solid #b91c1c; + border: 0.1px solid #b91c1c; display: flex; - padding:4px; - margin:11.2px; + padding: 4px; + margin: 11.2px; opacity: 90%; max-width: 70%; font-weight: 400; border-radius: 6px; justify-content: space-evenly; -` +`; //dot loading animation const dotBounce = keyframes` 0%, 80%, 100% { @@ -459,13 +518,14 @@ const DotAnimation = styled.div` animation: ${dotBounce} 1s infinite ease-in-out; `; // delay classes as styled components -const Delay = styled(DotAnimation) <{ $delay: number }>` - animation-delay: ${props => props.$delay + 'ms'}; +const Delay = styled(DotAnimation)<{ $delay: number }>` + animation-delay: ${(props) => props.$delay + 'ms'}; `; const PromptContainer = styled.form` background-color: transparent; - min-height: ${props => props.theme.dimensions!.size == 'large' ? '40px' : '23px'}; - max-height:150px; + min-height: ${(props) => + props.theme.dimensions!.size == 'large' ? '40px' : '23px'}; + max-height: 150px; display: flex; align-items: end; justify-content: space-evenly; @@ -474,12 +534,16 @@ const StyledTextarea = styled.textarea` box-sizing: border-box; width: 100%; border: 1px solid #686877; - padding: ${props => props.theme.dimensions!.size === 'large' ? '18px 12px 14px 12px' : '8px 12px 4px 12px'}; + padding: ${(props) => + props.theme.dimensions!.size === 'large' + ? '18px 12px 14px 12px' + : '8px 12px 4px 12px'}; background-color: transparent; font-size: 16px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; border-radius: 6px; - color: ${props => props.theme.text}; + color: ${(props) => props.theme.text}; outline: none; resize: none; transition: height 0.1s ease; @@ -487,7 +551,8 @@ const StyledTextarea = styled.textarea` white-space: pre-wrap; line-height: 1.4; text-align: left; - min-height: ${props => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; + min-height: ${(props) => + props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; max-height: 140px; overflow-y: auto; scrollbar-width: thin; @@ -505,35 +570,37 @@ const StyledTextarea = styled.textarea` } &::placeholder { text-align: left; - } `; const StyledButton = styled.button` display: flex; justify-content: center; align-items: center; - background-image: linear-gradient(to bottom right, #5AF0EC, #E80D9D); + background-image: linear-gradient(to bottom right, #5af0ec, #e80d9d); background-color: rgba(0, 0, 0, 0.3); border-radius: 6px; - min-width: ${props => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; - height: ${props => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; - margin-left:8px; + min-width: ${(props) => + props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; + height: ${(props) => + props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; + margin-left: 8px; padding: 0px; - + border: none; cursor: pointer; outline: none; - &:hover{ + &:hover { opacity: 90%; } &:disabled { background-image: linear-gradient(to bottom right, #2d938f, #b31877); - }`; + } +`; const HeroContainer = styled.div` position: relative; width: 90%; max-width: 500px; - background-image: linear-gradient(to bottom right, #5AF0EC, #ff1bf4); + background-image: linear-gradient(to bottom right, #5af0ec, #ff1bf4); border-radius: 10px; margin: 16px auto; padding: 2px; @@ -544,48 +611,47 @@ const HeroWrapper = styled.div` justify-content: flex-start; gap: 8px; align-items: middle; - background-color: ${props => props.theme.primary.bg}; - border-radius: 10px; + background-color: ${(props) => props.theme.primary.bg}; + border-radius: 10px; font-weight: normal; padding: 12px; -` +`; const HeroTitle = styled.h3` - color: ${props => props.theme.text}; + color: ${(props) => props.theme.text}; font-size: 16px; - margin:0px ; + margin: 0px; padding: 0px; `; const HeroDescription = styled.p` - color: ${props => props.theme.text}; + color: ${(props) => props.theme.text}; font-size: 12px; line-height: 1.5; margin: 0px; padding: 0px; `; const Hyperlink = styled.a` - color: #9971EC; + color: #9971ec; text-decoration: none; `; const Tagline = styled.div` text-align: center; display: block; - color: ${props => props.theme.secondary.text}; - padding: 12px ; + color: ${(props) => props.theme.secondary.text}; + padding: 12px; font-size: 12px; `; - const SourcesList = styled.div` display: flex; - margin:12px 0px; + margin: 12px 0px; flex-wrap: wrap; gap: 8px; `; const SourceLink = styled.a` - color: ${props => props.theme.primary.text}; + color: ${(props) => props.theme.primary.text}; text-decoration: none; - background: ${props => props.theme.secondary.bg}; + background: ${(props) => props.theme.secondary.bg}; padding: 4px 12px; border-radius: 85px; font-size: 14px; @@ -597,14 +663,14 @@ const SourceLink = styled.a` overflow: hidden; text-overflow: ellipsis; line-height: 1.5; - + &:hover { opacity: 0.8; } `; const ExtraButton = styled.button` - color: #9971EC; + color: #9971ec; background: transparent; border-radius: 85px; padding: 4px 12px; @@ -613,23 +679,27 @@ const ExtraButton = styled.button` cursor: pointer; transition: opacity 0.2s ease; text-align: center; - height:auto; + height: auto; &:hover { opacity: 0.8; } `; -const SourcesComponent = ({ sources }: { sources: Array<{ source: string; title: string }> }) => { +const SourcesComponent = ({ + sources, +}: { + sources: Array<{ source: string; title: string }>; +}) => { const [showAll, setShowAll] = React.useState(false); const visibleSources = showAll ? sources : sources.slice(0, 3); const extraCount = sources.length - 3; - + return ( {visibleSources.map((source, idx) => ( - @@ -638,18 +708,30 @@ const SourcesComponent = ({ sources }: { sources: Array<{ source: string; title: ))} {sources.length > 3 && ( setShowAll(!showAll)}> - {showAll ? "Show less" : `+ ${extraCount} more`} + {showAll ? 'Show less' : `+ ${extraCount} more`} )} ); }; -const Hero = ({ title, description, theme }: { title: string, description: string, theme: string }) => { +const Hero = ({ + title, + description, + theme, +}: { + title: string; + description: string; + theme: string; +}) => { return ( - + {title} {description} @@ -657,26 +739,26 @@ const Hero = ({ title, description, theme }: { title: string, description: strin ); }; export const DocsGPTWidget = (props: WidgetProps) => { - const { buttonIcon = 'https://d3dg1063dc54p9.cloudfront.net/widget/chat.svg', buttonText = 'Ask a question', buttonBg = 'linear-gradient(to bottom right, #5AF0EC, #E80D9D)', defaultOpen = false, ...coreProps - } = props + } = props; const [open, setOpen] = React.useState(defaultOpen); const [isAnimatingButton, setIsAnimatingButton] = React.useState(false); - const [isFloatingButtonVisible, setIsFloatingButtonVisible] = React.useState(true); + const [isFloatingButtonVisible, setIsFloatingButtonVisible] = + React.useState(true); React.useEffect(() => { if (isFloatingButtonVisible) setTimeout(() => setIsAnimatingButton(true), 250); return () => { - setIsAnimatingButton(false) - } - }, [isFloatingButtonVisible]) + setIsAnimatingButton(false); + }; + }, [isFloatingButtonVisible]); const handleClose = () => { setIsFloatingButtonVisible(true); @@ -685,24 +767,29 @@ export const DocsGPTWidget = (props: WidgetProps) => { const handleOpen = () => { setOpen(true); setIsFloatingButtonVisible(false); - } + }; return ( <> - + {buttonText} - ) -} + ); +}; export const WidgetCore = ({ apiHost = 'https://gptcloud.arc53.com', - apiKey = "527686a3-e867-4b4d-9fec-f5f45fdb613a", + apiKey = '527686a3-e867-4b4d-9fec-f5f45fdb613a', avatar = 'https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png', title = 'Get AI assistance', - description = 'DocsGPT\'s AI Chatbot is here to help', + description = "DocsGPT's AI Chatbot is here to help", heroTitle = 'Welcome to DocsGPT !', heroDescription = 'This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources.', size = 'small', @@ -711,15 +798,17 @@ export const WidgetCore = ({ isOpen = false, showSources = true, handleClose, - prefilledQuery = '' + prefilledQuery = '', }: WidgetCoreProps) => { - const [prompt, setPrompt] = React.useState(""); + const [prompt, setPrompt] = React.useState(''); const [mounted, setMounted] = React.useState(false); const [status, setStatus] = React.useState('idle'); const [queries, setQueries] = React.useState([]); - const [conversationId, setConversationId] = React.useState(null); + const [conversationId, setConversationId] = React.useState( + null, + ); const [eventInterrupt, setEventInterrupt] = React.useState(false); //click or scroll by user while autoScrolling - const [hasScrolledToLast, setHasScrolledToLast] = useState(true); + const [, setHasScrolledToLast] = useState(true); const isBubbleHovered = useRef(false); const conversationRef = useRef(null); @@ -727,23 +816,22 @@ export const WidgetCore = ({ const promptRef = React.useRef(null); const md = new MarkdownIt(); //Custom markdown for the table - md.renderer.rules.table_open = () => '
'; + md.renderer.rules.table_open = () => + '
'; md.renderer.rules.table_close = () => '
'; md.renderer.rules.thead_open = () => ''; md.renderer.rules.tr_open = () => ''; md.renderer.rules.td_open = () => ''; md.renderer.rules.th_open = () => ''; - - React.useEffect(() => { if (isOpen) { setMounted(true); // Mount the component - appendQuery(prefilledQuery) + appendQuery(prefilledQuery); } else { // Wait for animations before unmounting const timeout = setTimeout(() => { - setMounted(false) + setMounted(false); }, 250); return () => clearTimeout(timeout); } @@ -751,12 +839,16 @@ export const WidgetCore = ({ const handleUserInterrupt = () => { if (!eventInterrupt && status === 'loading') setEventInterrupt(true); - } + }; const scrollIntoView = () => { if (!conversationRef?.current || eventInterrupt) return; - if (status === 'idle' || !queries.length || !queries[queries.length - 1].response) { + if ( + status === 'idle' || + !queries.length || + !queries[queries.length - 1].response + ) { conversationRef.current.scrollTo({ behavior: 'smooth', top: conversationRef.current.scrollHeight, @@ -775,8 +867,8 @@ export const WidgetCore = ({ }; React.useEffect(() => { - !eventInterrupt && scrollIntoView(); - + if (!eventInterrupt) scrollIntoView(); + conversationRef.current?.addEventListener('scroll', checkScroll); return () => { conversationRef.current?.removeEventListener('scroll', checkScroll); @@ -786,44 +878,52 @@ export const WidgetCore = ({ async function handleFeedback(feedback: FEEDBACK, index: number) { let query = queries[index]; if (!query.response || !conversationId) { - console.log("Cannot submit feedback: missing response or conversation ID"); + console.log( + 'Cannot submit feedback: missing response or conversation ID', + ); return; } // If clicking the same feedback button that's already active, remove the feedback by sending null if (query.feedback === feedback) { try { - const response = await sendFeedback({ - question: query.prompt, - answer: query.response, - feedback: null, - apikey: apiKey, - conversation_id: conversationId, - question_index: index, - }, apiHost); + const response = await sendFeedback( + { + question: query.prompt, + answer: query.response, + feedback: null, + apikey: apiKey, + conversation_id: conversationId, + question_index: index, + }, + apiHost, + ); if (response.status === 200) { const updatedQuery = { ...query }; delete updatedQuery.feedback; setQueries((prev: Query[]) => - prev.map((q, i) => (i === index ? updatedQuery : q)) + prev.map((q, i) => (i === index ? updatedQuery : q)), ); } } catch (err) { - console.error("Failed to submit feedback:", err); + console.error('Failed to submit feedback:', err); } return; } try { - const response = await sendFeedback({ - question: query.prompt, - answer: query.response, - feedback: feedback, - apikey: apiKey, - conversation_id: conversationId, - question_index: index, - }, apiHost); + const response = await sendFeedback( + { + question: query.prompt, + answer: query.response, + feedback: feedback, + apikey: apiKey, + conversation_id: conversationId, + question_index: index, + }, + apiHost, + ); if (response.status === 200) { setQueries((prev: Query[]) => { @@ -836,124 +936,121 @@ export const WidgetCore = ({ }); } } catch (err) { - console.error("Failed to submit feedback:", err); + console.error('Failed to submit feedback:', err); } } async function stream(question: string) { - setStatus('loading') + setStatus('loading'); try { - await fetchAnswerStreaming( - { - question: question, - apiKey: apiKey, - apiHost: apiHost, - history: queries, - conversationId: conversationId, - onEvent: (event: MessageEvent) => { - const data = JSON.parse(event.data); - // check if the 'end' event has been received - if (data.type === 'end') { - setStatus('idle'); - } - else if (data.type === 'id') { - setConversationId(data.id) - } - else if (data.type === 'error') { - const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].error = data.error; - setQueries(updatedQueries); - setStatus('idle') - } - else if (data.type === 'source' && showSources) { - const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].sources = data.source; - setQueries(updatedQueries); - } - else { - const result = data.answer ? data.answer : ''; //Fallback to an empty string if data.answer is undefined - const streamingResponse = queries[queries.length - 1].response ? queries[queries.length - 1].response : ''; - const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].response = streamingResponse + result; - setQueries(updatedQueries); - } + await fetchAnswerStreaming({ + question: question, + apiKey: apiKey, + apiHost: apiHost, + history: queries, + conversationId: conversationId, + onEvent: (event: MessageEvent) => { + const data = JSON.parse(event.data); + // check if the 'end' event has been received + if (data.type === 'end') { + setStatus('idle'); + } else if (data.type === 'id') { + setConversationId(data.id); + } else if (data.type === 'error') { + const updatedQueries = [...queries]; + updatedQueries[updatedQueries.length - 1].error = data.error; + setQueries(updatedQueries); + setStatus('idle'); + } else if (data.type === 'source' && showSources) { + const updatedQueries = [...queries]; + updatedQueries[updatedQueries.length - 1].sources = data.source; + setQueries(updatedQueries); + } else { + const result = data.answer ? data.answer : ''; //Fallback to an empty string if data.answer is undefined + const streamingResponse = queries[queries.length - 1].response + ? queries[queries.length - 1].response + : ''; + const updatedQueries = [...queries]; + updatedQueries[updatedQueries.length - 1].response = + streamingResponse + result; + setQueries(updatedQueries); } - } - ); - } catch (error) { + }, + }); + } catch { const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].error = 'Something went wrong !' + updatedQueries[updatedQueries.length - 1].error = + 'Something went wrong !'; setQueries(updatedQueries); - setStatus('idle') + setStatus('idle'); //setEventInterrupt(false) } - } - const appendQuery = async (userQuery: string) => { - if (!userQuery) - return; + if (!userQuery) return; setEventInterrupt(false); queries.push({ prompt: userQuery }); setPrompt(''); await stream(userQuery); - } + }; // submit handler const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!prompt.trim()) return; if (promptRef.current) { - promptRef.current.style.height = "auto"; + promptRef.current.style.height = 'auto'; } await appendQuery(prompt); - } - const handlePromptKeyDown = async (e: React.KeyboardEvent) => { + }; + const handlePromptKeyDown = async ( + e: React.KeyboardEvent, + ) => { if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); // Prevent sending empty messages - if (promptRef.current && promptRef.current.value.trim() === "") return; + if (promptRef.current && promptRef.current.value.trim() === '') return; //Rest the input to it's original size after submitting - if(promptRef.current){ - promptRef.current.value = ""; - promptRef.current.style.height = "auto"; + if (promptRef.current) { + promptRef.current.value = ''; + promptRef.current.style.height = 'auto'; } await appendQuery(prompt); - } -} + } + }; // Auto-resize the input textarea while typing, clamping to base or max height - const handleUserInput = (e: React.KeyboardEvent) =>{ - const el = promptRef.current; - if (!el) return; - const baseHeight = size === 'large' ? 60 : 40; - const maxHeight = 140; - el.style.height = 'auto'; - const next = Math.min(el.scrollHeight, maxHeight); - el.style.height = Math.max(baseHeight, next) + 'px'; + const handleUserInput = () => { + const el = promptRef.current; + if (!el) return; + const baseHeight = size === 'large' ? 60 : 40; + const maxHeight = 140; + el.style.height = 'auto'; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = Math.max(baseHeight, next) + 'px'; + }; - } - // Update prompt state, auto resize textarea to content, and maintain scroll on new lines - const handlePromptChange = (event: React.ChangeEvent) => { - const value = event.target.value; - setPrompt(value); - const el = event.currentTarget; - const baseHeight = size === 'large' ? 60 : 40; - const maxHeight = 140; - el.style.height = 'auto'; - const next = Math.min(el.scrollHeight, maxHeight); - el.style.height = Math.max(baseHeight, next) + 'px'; - if(value.includes("\n")){ - el.scrollTop = el.scrollHeight; - - } - - - } - const handleImageError = (event: React.SyntheticEvent) => { - event.currentTarget.src = "https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"; + const handlePromptChange = ( + event: React.ChangeEvent, + ) => { + const value = event.target.value; + setPrompt(value); + const el = event.currentTarget; + const baseHeight = size === 'large' ? 60 : 40; + const maxHeight = 140; + el.style.height = 'auto'; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = Math.max(baseHeight, next) + 'px'; + if (value.includes('\n')) { + el.scrollTop = el.scrollHeight; + } + }; + const handleImageError = ( + event: React.SyntheticEvent, + ) => { + event.currentTarget.src = + 'https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png'; }; const dimensions = @@ -962,123 +1059,200 @@ export const WidgetCore = ({ : sizesConfig[size]; if (!mounted) return null; - - return ( - {isOpen && size === 'large' && - - } - {( - + {isOpen && size === 'large' && } + { +
- +
- docs-gpt + docs-gpt {title} {description}
- - { - queries.length > 0 ? queries?.map((query, index) => { + > + {queries.length > 0 ? ( + queries?.map((query, index) => { return ( - { - query.prompt && - + {query.prompt && ( + + $type="QUESTION" + ref={ + !(query.response || query.error) && + index === queries.length - 1 + ? endMessageRef + : null + } + > {query.prompt} - } - { - query.response ? { isBubbleHovered.current = true }} $type='ANSWER'> - {showSources && query.sources && query.sources.length > 0 && query.sources.some(source => source.source !== 'local') && ( - source.source !== 'local')} /> - )} + )} + {query.response ? ( + { + isBubbleHovered.current = true; + }} + $type="ANSWER" + > + {showSources && + query.sources && + query.sources.length > 0 && + query.sources.some( + (source) => source.source !== 'local', + ) && ( + source.source !== 'local', + )} + /> + )} - {collectFeedback && + {collectFeedback && ( - } + + )} - :
- { - query.error ? - - -
-
Network Error
- {query.error} -
-
- : - - . - . - . - - - } -
- } -
) + ) : ( +
+ {query.error ? ( + + +
+
Network Error
+ + {query.error} + +
+
+ ) : ( + + + . + . + . + + + )} +
+ )} + + ); }) - : - } + ) : ( + + )}
- + - + disabled={prompt.trim().length == 0 || status !== 'idle'} + > + Powered by  - DocsGPT + + DocsGPT +
- ) }
- ) -} \ No newline at end of file + ); +}; diff --git a/extensions/react-widget/src/components/SearchBar.tsx b/extensions/react-widget/src/components/SearchBar.tsx index c890e054..a6a8ae18 100644 --- a/extensions/react-widget/src/components/SearchBar.tsx +++ b/extensions/react-widget/src/components/SearchBar.tsx @@ -7,235 +7,239 @@ import { Result } from '@/types'; import { getOS, processMarkdownString } from '../utils/helper'; import DOMPurify from 'dompurify'; import { - CodeIcon, - TextAlignLeftIcon, - HeadingIcon, - ReaderIcon, - ListBulletIcon, - QuoteIcon + CodeIcon, + TextAlignLeftIcon, + HeadingIcon, + ReaderIcon, + ListBulletIcon, + QuoteIcon, } from '@radix-ui/react-icons'; const themes = { - dark: { - name: 'dark', - bg: '#202124', - text: '#EDEDED', - primary: { - text: "#FAFAFA", - bg: '#111111' - }, - secondary: { - text: "#A1A1AA", - bg: "#38383b" - } + dark: { + name: 'dark', + bg: '#202124', + text: '#EDEDED', + primary: { + text: '#FAFAFA', + bg: '#111111', }, - light: { - name: 'light', - bg: '#EAEAEA', - text: '#171717', - primary: { - text: "#222327", - bg: "#fff" - }, - secondary: { - text: "#A1A1AA", - bg: "#F6F6F6" - } - } -} + secondary: { + text: '#A1A1AA', + bg: '#38383b', + }, + }, + light: { + name: 'light', + bg: '#EAEAEA', + text: '#171717', + primary: { + text: '#222327', + bg: '#fff', + }, + secondary: { + text: '#A1A1AA', + bg: '#F6F6F6', + }, + }, +}; const GlobalStyle = createGlobalStyle` .highlight { - color: ${props => props.theme.name === 'dark' ? '#4B9EFF' : '#0066CC'}; + color: ${(props) => (props.theme.name === 'dark' ? '#4B9EFF' : '#0066CC')}; font-weight: 500; } `; const loadGeistFont = () => { - const link = document.createElement('link'); - link.href = 'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap'; - link.rel = 'stylesheet'; - document.head.appendChild(link); + const link = document.createElement('link'); + link.href = + 'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap'; + link.rel = 'stylesheet'; + document.head.appendChild(link); }; const Main = styled.div` - all: initial; - font-family: 'Geist', sans-serif; -` + all: initial; + font-family: 'Geist', sans-serif; +`; const SearchButton = styled.button<{ $inputWidth: string }>` - padding: 6px 6px; - font-family: inherit; - width: ${({ $inputWidth }) => $inputWidth}; - border-radius: 8px; - display: inline; - color: ${props => props.theme.secondary.text}; - outline: none; - border: none; - background-color: ${props => props.theme.secondary.bg}; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - transition: background-color 128ms linear; - text-align: left; - cursor: pointer; -` - -const Container = styled.div` - position: relative; - display: inline-block; -` -const SearchOverlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #0000001A; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - z-index: 99; + padding: 6px 6px; + font-family: inherit; + width: ${({ $inputWidth }) => $inputWidth}; + border-radius: 8px; + display: inline; + color: ${(props) => props.theme.secondary.text}; + outline: none; + border: none; + background-color: ${(props) => props.theme.secondary.bg}; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + transition: background-color 128ms linear; + text-align: left; + cursor: pointer; `; +const Container = styled.div` + position: relative; + display: inline-block; +`; +const SearchOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #0000001a; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 99; +`; const SearchResults = styled.div` - position: fixed; - display: flex; - flex-direction: column; - background-color: ${props => props.theme.name === 'dark' ? - 'rgba(0, 0, 0, 0.15)' : - 'rgba(255, 255, 255, 0.4)'}; - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 15px; - padding: 8px 0px 8px 0px; - width: 792px; - max-width: 90vw; - height: 396px; - z-index: 100; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - color: ${props => props.theme.primary.text}; - - box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); - backdrop-filter: blur(82px); - -webkit-backdrop-filter: blur(82px); - border-radius: 10px; - - box-sizing: border-box; + position: fixed; + display: flex; + flex-direction: column; + background-color: ${(props) => + props.theme.name === 'dark' + ? 'rgba(0, 0, 0, 0.15)' + : 'rgba(255, 255, 255, 0.4)'}; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 15px; + padding: 8px 0px 8px 0px; + width: 792px; + max-width: 90vw; + height: 396px; + z-index: 100; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: ${(props) => props.theme.primary.text}; - @media only screen and (max-width: 768px) { - height: 80vh; - width: 90vw; - } + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); + backdrop-filter: blur(82px); + -webkit-backdrop-filter: blur(82px); + border-radius: 10px; + + box-sizing: border-box; + + @media only screen and (max-width: 768px) { + height: 80vh; + width: 90vw; + } `; const SearchResultsScroll = styled.div` - flex: 1; - overflow-y: auto; - overflow-x: hidden; - scrollbar-gutter: stable; - scrollbar-width: thin; - scrollbar-color: #383838 transparent; - padding: 0 16px; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: #383838 transparent; + padding: 0 16px; `; const IconTitleWrapper = styled.div` - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; - .element-icon{ - margin: 4px; - } + .element-icon { + margin: 4px; + } `; const Title = styled.h3` - font-size: 15px; - font-weight: 400; - color: ${props => props.theme.primary.text}; - margin: 0; - overflow-wrap: break-word; - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; + font-size: 15px; + font-weight: 400; + color: ${(props) => props.theme.primary.text}; + margin: 0; + overflow-wrap: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; `; const ContentWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; `; - - const ResultWrapper = styled.div` - display: flex; - align-items: flex-start; - width: 100%; - box-sizing: border-box; - padding: 8px 16px; - cursor: pointer; - background-color: transparent; - font-family: 'Geist', sans-serif; - border-radius: 8px; + display: flex; + align-items: flex-start; + width: 100%; + box-sizing: border-box; + padding: 8px 16px; + cursor: pointer; + background-color: transparent; + font-family: 'Geist', sans-serif; + border-radius: 8px; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; - &:hover { - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - } + &:hover { + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } `; const Content = styled.div` - display: flex; - margin-left: 8px; - flex-direction: column; - gap: 8px; - padding: 4px 0px 0px 12px; - font-size: 15px; - color: ${props => props.theme.primary.text}; - line-height: 1.6; - border-left: 2px solid ${props => props.theme.primary.text}CC; - overflow: hidden; - + display: flex; + margin-left: 8px; + flex-direction: column; + gap: 8px; + padding: 4px 0px 0px 12px; + font-size: 15px; + color: ${(props) => props.theme.primary.text}; + line-height: 1.6; + border-left: 2px solid ${(props) => props.theme.primary.text}CC; + overflow: hidden; `; const ContentSegment = styled.div` - display: flex; - align-items: flex-start; - gap: 8px; - padding-right: 16px; - overflow-wrap: break-word; - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; -` + display: flex; + align-items: flex-start; + gap: 8px; + padding-right: 16px; + overflow-wrap: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; +`; const Toolkit = styled.kbd` - position: absolute; - right: 4px; - top: 50%; - transform: translateY(-50%); - background-color: ${(props) => props.theme.primary.bg}; - color: ${(props) => props.theme.secondary.text}; - font-weight: 600; - font-size: 10px; - padding: 3px 6px; - border: 1px solid ${(props) => props.theme.secondary.text}; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - z-index: 1; - pointer-events: none; -` + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + background-color: ${(props) => props.theme.primary.bg}; + color: ${(props) => props.theme.secondary.text}; + font-weight: 600; + font-size: 10px; + padding: 3px 6px; + border: 1px solid ${(props) => props.theme.secondary.text}; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + pointer-events: none; +`; const Loader = styled.div` margin: 2rem auto; - border: 4px solid ${props => props.theme.name === 'dark' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; - border-top: 4px solid ${props => props.theme.name === 'dark' ? '#FFFFFF' : props.theme.primary.bg}; + border: 4px solid + ${(props) => + props.theme.name === 'dark' + ? 'rgba(255, 255, 255, 0.2)' + : 'rgba(0, 0, 0, 0.1)'}; + border-top: 4px solid + ${(props) => + props.theme.name === 'dark' ? '#FFFFFF' : props.theme.primary.bg}; border-radius: 50%; width: 12px; height: 12px; @@ -255,320 +259,353 @@ const NoResults = styled.div` margin-top: 2rem; text-align: center; font-size: 14px; - color: ${props => props.theme.name === 'dark' ? '#E0E0E0' : '#505050'}; + color: ${(props) => (props.theme.name === 'dark' ? '#E0E0E0' : '#505050')}; font-weight: 500; `; const AskAIButton = styled.button` - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - width: calc(100% - 32px); - margin: 0 16px 16px 16px; - box-sizing: border-box; - height: 50px; - padding: 8px 24px; - border: none; - border-radius: 8px; - color: ${props => props.theme.text}; - cursor: pointer; - font-size: 16px; - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - background-color: ${props => props.theme.name === 'dark' ? - 'rgba(255, 255, 255, 0.05)' : - 'rgba(0, 0, 0, 0.03)'}; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + width: calc(100% - 32px); + margin: 0 16px 16px 16px; + box-sizing: border-box; + height: 50px; + padding: 8px 24px; + border: none; + border-radius: 8px; + color: ${(props) => props.theme.text}; + cursor: pointer; + font-size: 16px; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + background-color: ${(props) => + props.theme.name === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.03)'}; - &:hover { - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - background-color: ${props => props.theme.name === 'dark' ? - 'rgba(255, 255, 255, 0.1)' : - 'rgba(0, 0, 0, 0.06)'}; - } + &:hover { + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: ${(props) => + props.theme.name === 'dark' + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(0, 0, 0, 0.06)'}; + } `; const SearchHeader = styled.div` - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - padding-bottom: 12px; - border-bottom: 1px solid ${props => props.theme.name === 'dark' ? '#FFFFFF24' : 'rgba(0, 0, 0, 0.14)'}; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid + ${(props) => + props.theme.name === 'dark' ? '#FFFFFF24' : 'rgba(0, 0, 0, 0.14)'}; `; - - const TextField = styled.input` - width: calc(100% - 32px); - margin: 0 16px; - padding: 12px 16px; - border: none; - background-color: transparent; - color: ${props => props.theme.text}; - font-size: 20px; - font-weight: 400; - outline: none; - - &:focus { - border-color: none; - } + width: calc(100% - 32px); + margin: 0 16px; + padding: 12px 16px; + border: none; + background-color: transparent; + color: ${(props) => props.theme.text}; + font-size: 20px; + font-weight: 400; + outline: none; - &::placeholder { - color: ${props => props.theme.name === 'dark' ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.5)'} !important; - opacity: 100%; /* Force opacity to ensure placeholder is visible */ - font-weight: 500; - } -` + &:focus { + border-color: none; + } - - -const EscapeInstruction = styled.kbd` - display: flex; - align-items: center; - justify-content: center; - margin: 12px 16px 0; - padding: 4px 8px; - border-radius: 4px; - background-color: transparent; - border: 1px solid ${props => props.theme.name === 'dark' ? - 'rgba(237, 237, 237, 0.6)' : - 'rgba(23, 23, 23, 0.6)'}; - color: ${props => props.theme.name === 'dark' ? '#EDEDED' : '#171717'}; - font-size: 12px; - font-family: 'Geist', sans-serif; - white-space: nowrap; - cursor: pointer; - width: fit-content; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; + &::placeholder { + color: ${(props) => + props.theme.name === 'dark' + ? 'rgba(255, 255, 255, 0.6)' + : 'rgba(0, 0, 0, 0.5)'} !important; + opacity: 100%; /* Force opacity to ensure placeholder is visible */ + font-weight: 500; + } `; +const EscapeInstruction = styled.kbd` + display: flex; + align-items: center; + justify-content: center; + margin: 12px 16px 0; + padding: 4px 8px; + border-radius: 4px; + background-color: transparent; + border: 1px solid + ${(props) => + props.theme.name === 'dark' + ? 'rgba(237, 237, 237, 0.6)' + : 'rgba(23, 23, 23, 0.6)'}; + color: ${(props) => (props.theme.name === 'dark' ? '#EDEDED' : '#171717')}; + font-size: 12px; + font-family: 'Geist', sans-serif; + white-space: nowrap; + cursor: pointer; + width: fit-content; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +`; export const SearchBar = ({ - apiKey = "74039c6d-bff7-44ce-ae55-2973cbf13837", - apiHost = "https://gptcloud.arc53.com", - theme = "dark", - placeholder = "Search or Ask AI...", - width = "256px", - buttonText = "Search here" + apiKey = '74039c6d-bff7-44ce-ae55-2973cbf13837', + apiHost = 'https://gptcloud.arc53.com', + theme = 'dark', + placeholder = 'Search or Ask AI...', + width = '256px', + buttonText = 'Search here', }: SearchBarProps) => { - const [input, setInput] = React.useState(""); - const [loading, setLoading] = React.useState(false); - const [isWidgetOpen, setIsWidgetOpen] = React.useState(false); - const inputRef = React.useRef(null); - const containerRef = React.useRef(null); - const [isResultVisible, setIsResultVisible] = React.useState(false); - const [results, setResults] = React.useState([]); - const debounceTimeout = React.useRef | null>(null); - const abortControllerRef = React.useRef(null); - const browserOS = getOS(); - const isTouch = 'ontouchstart' in window; + const [input, setInput] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [isWidgetOpen, setIsWidgetOpen] = React.useState(false); + const inputRef = React.useRef(null); + const containerRef = React.useRef(null); + const [isResultVisible, setIsResultVisible] = React.useState(false); + const [results, setResults] = React.useState([]); + const debounceTimeout = React.useRef | null>( + null, + ); + const abortControllerRef = React.useRef(null); + const browserOS = getOS(); + const isTouch = 'ontouchstart' in window; - const getKeyboardInstruction = () => { - if (isResultVisible) return "Enter"; - return browserOS === 'mac' ? '⌘ + K' : 'Ctrl + K'; + const getKeyboardInstruction = () => { + if (isResultVisible) return 'Enter'; + return browserOS === 'mac' ? '⌘ + K' : 'Ctrl + K'; + }; + + React.useEffect(() => { + loadGeistFont(); + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsResultVisible(false); + } }; - React.useEffect(() => { - loadGeistFont() - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsResultVisible(false); - } - }; + const handleKeyDown = (event: KeyboardEvent) => { + if ( + ((browserOS === 'win' || browserOS === 'linux') && + event.ctrlKey && + event.key === 'k') || + (browserOS === 'mac' && event.metaKey && event.key === 'k') + ) { + event.preventDefault(); + inputRef.current?.focus(); + setIsResultVisible(true); + } else if (event.key === 'Escape') { + setIsResultVisible(false); + } + }; - const handleKeyDown = (event: KeyboardEvent) => { - if ( - ((browserOS === 'win' || browserOS === 'linux') && event.ctrlKey && event.key === 'k') || - (browserOS === 'mac' && event.metaKey && event.key === 'k') - ) { - event.preventDefault(); - inputRef.current?.focus(); - setIsResultVisible(true); - } else if (event.key === 'Escape') { - setIsResultVisible(false); - } - }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - React.useEffect(() => { + React.useEffect(() => { if (!input) { - setResults([]); - setLoading(false); - return; + setResults([]); + setLoading(false); + return; } setLoading(true); if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); + clearTimeout(debounceTimeout.current); } if (abortControllerRef.current) { - abortControllerRef.current.abort(); + abortControllerRef.current.abort(); } const abortController = new AbortController(); abortControllerRef.current = abortController; debounceTimeout.current = setTimeout(() => { - getSearchResults(input, apiKey, apiHost, abortController.signal) - .then((data) => setResults(data)) - .catch((err) => !abortController.signal.aborted && console.log(err)) - .finally(() => setLoading(false)); + getSearchResults(input, apiKey, apiHost, abortController.signal) + .then((data) => setResults(data)) + .catch((err) => !abortController.signal.aborted && console.log(err)) + .finally(() => setLoading(false)); }, 500); return () => { - abortController.abort(); - clearTimeout(debounceTimeout.current ?? undefined); + abortController.abort(); + clearTimeout(debounceTimeout.current ?? undefined); }; -}, [input]) + }, [input]); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - openWidget(); - } - }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + openWidget(); + } + }; - const openWidget = () => { - setIsWidgetOpen(true); - setIsResultVisible(false); - }; + const openWidget = () => { + setIsWidgetOpen(true); + setIsResultVisible(false); + }; - const handleClose = () => { - setIsWidgetOpen(false); - setIsResultVisible(true); - }; + const handleClose = () => { + setIsWidgetOpen(false); + setIsResultVisible(true); + }; - return ( - -
- - - setIsResultVisible(true)} - $inputWidth={width} - > - {buttonText} - - { - isResultVisible && ( - <> - setIsResultVisible(false)} /> - - - setInput(e.target.value)} - onKeyDown={(e) => handleKeyDown(e)} - placeholder={placeholder} - autoFocus - /> - setIsResultVisible(false)}> - Esc - - - - DocsGPT - Ask the AI - - - {!loading ? ( - results.length > 0 ? ( - results.map((res, key) => { - const containsSource = res.source !== 'local'; - const processedResults = processMarkdownString(res.text, input); - if (processedResults) - return ( - { - if (!containsSource) return; - window.open(res.source, '_blank', 'noopener, noreferrer'); - }} - > -
- - - - {res.title} - - - {processedResults.map((element, index) => ( - - - {element.tag === 'code' && } - {(element.tag === 'bulletList' || element.tag === 'numberedList') && } - {element.tag === 'text' && } - {element.tag === 'heading' && } - {element.tag === 'blockquote' && } - -
- - ))} - - -
- - ); - return null; - }) - ) : ( - No results found - ) - ) : ( - - )} - - - - ) - } - { - isTouch ? - - { - setIsWidgetOpen(true) - }} - title={"Tap to Ask the AI"}> - Tap - - : - - {getKeyboardInstruction()} - - } - - -
-
- ) -} + return ( + +
+ + + setIsResultVisible(true)} + $inputWidth={width} + > + {buttonText} + + {isResultVisible && ( + <> + setIsResultVisible(false)} /> + + + setInput(e.target.value)} + onKeyDown={(e) => handleKeyDown(e)} + placeholder={placeholder} + autoFocus + /> + setIsResultVisible(false)}> + Esc + + + + DocsGPT + Ask the AI + + + {!loading ? ( + results.length > 0 ? ( + results.map((res, key) => { + const containsSource = res.source !== 'local'; + const processedResults = processMarkdownString( + res.text, + input, + ); + if (processedResults) + return ( + { + if (!containsSource) return; + window.open( + res.source, + '_blank', + 'noopener, noreferrer', + ); + }} + > +
+ + + + {res.title} + + + {processedResults.map((element, index) => ( + + + {element.tag === 'code' && ( + + )} + {(element.tag === 'bulletList' || + element.tag === 'numberedList') && ( + + )} + {element.tag === 'text' && ( + + )} + {element.tag === 'heading' && ( + + )} + {element.tag === 'blockquote' && ( + + )} + +
+ + ))} + + +
+ + ); + return null; + }) + ) : ( + No results found + ) + ) : ( + + )} + + + + )} + {isTouch ? ( + { + setIsWidgetOpen(true); + }} + title={'Tap to Ask the AI'} + > + Tap + + ) : ( + + {getKeyboardInstruction()} + + )} + + +
+
+ ); +}; diff --git a/extensions/react-widget/src/index.ts b/extensions/react-widget/src/index.ts index 5f2e30e8..ecdec8ee 100644 --- a/extensions/react-widget/src/index.ts +++ b/extensions/react-widget/src/index.ts @@ -1,3 +1,3 @@ //exports methods for React -export {SearchBar} from "./components/SearchBar" -export { DocsGPTWidget } from "./components/DocsGPTWidget"; +export { SearchBar } from './components/SearchBar'; +export { DocsGPTWidget } from './components/DocsGPTWidget'; diff --git a/extensions/react-widget/src/main.tsx b/extensions/react-widget/src/main.tsx index f238c51e..0bd57ba2 100644 --- a/extensions/react-widget/src/main.tsx +++ b/extensions/react-widget/src/main.tsx @@ -1,8 +1,7 @@ - //development -import { createRoot } from "react-dom/client"; -import { App } from "./App"; -import React from "react"; -const container = document.getElementById("app") as HTMLElement; -const root = createRoot(container) +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import React from 'react'; +const container = document.getElementById('app') as HTMLElement; +const root = createRoot(container); root.render(); diff --git a/extensions/react-widget/src/requests/searchAPI.ts b/extensions/react-widget/src/requests/searchAPI.ts index 7411a810..3fc43ed6 100644 --- a/extensions/react-widget/src/requests/searchAPI.ts +++ b/extensions/react-widget/src/requests/searchAPI.ts @@ -1,20 +1,24 @@ -import { Result } from "@/types"; - -async function getSearchResults(question: string, apiKey: string, apiHost: string, signal: AbortSignal): Promise { +import { Result } from '@/types'; +async function getSearchResults( + question: string, + apiKey: string, + apiHost: string, + signal: AbortSignal, +): Promise { const payload = { question, - api_key: apiKey + api_key: apiKey, }; try { const response = await fetch(`${apiHost}/api/search`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(payload), - signal: signal + signal: signal, }); if (!response.ok) { @@ -23,15 +27,12 @@ async function getSearchResults(question: string, apiKey: string, apiHost: strin const data: Result[] = await response.json(); return data; - } catch (error) { - if (!(error instanceof DOMException && error.name == "AbortError")) { - console.error("Failed to fetch documents:", error); + if (!(error instanceof DOMException && error.name == 'AbortError')) { + console.error('Failed to fetch documents:', error); } throw error; } } -export { - getSearchResults -} \ No newline at end of file +export { getSearchResults }; diff --git a/extensions/react-widget/src/requests/streamingApi.ts b/extensions/react-widget/src/requests/streamingApi.ts index f55f9d0c..e27eaa3f 100644 --- a/extensions/react-widget/src/requests/streamingApi.ts +++ b/extensions/react-widget/src/requests/streamingApi.ts @@ -1,5 +1,3 @@ -import { FEEDBACK } from "@/types"; - interface HistoryItem { prompt: string; response?: string; @@ -30,7 +28,9 @@ export function fetchAnswerStreaming({ history = [], conversationId = null, apiHost = '', - onEvent = () => { console.log("Event triggered, but no handler provided."); } + onEvent = () => { + console.log('Event triggered, but no handler provided.'); + }, }: FetchAnswerStreamingProps): Promise { return new Promise((resolve, reject) => { const body = { @@ -38,7 +38,7 @@ export function fetchAnswerStreaming({ history: JSON.stringify(history), conversation_id: conversationId, model: 'default', - api_key: apiKey + api_key: apiKey, }; fetch(apiHost + '/stream', { method: 'POST', @@ -52,7 +52,7 @@ export function fetchAnswerStreaming({ const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); - let counter = 0; + let counter = 0; // eslint-disable-line @typescript-eslint/no-unused-vars const processStream = ({ done, value, @@ -95,12 +95,14 @@ export function fetchAnswerStreaming({ }); } - -export const sendFeedback = (payload: FeedbackPayload, apiHost: string): Promise => { +export const sendFeedback = ( + payload: FeedbackPayload, + apiHost: string, +): Promise => { return fetch(`${apiHost}/api/feedback`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify({ question: payload.question, @@ -108,7 +110,7 @@ export const sendFeedback = (payload: FeedbackPayload, apiHost: string): Promis feedback: payload.feedback, api_key: payload.apikey, conversation_id: payload.conversation_id, - question_index: payload.question_index + question_index: payload.question_index, }), }); }; diff --git a/extensions/react-widget/src/types/index.ts b/extensions/react-widget/src/types/index.ts index 7b62387e..9fb60fff 100644 --- a/extensions/react-widget/src/types/index.ts +++ b/extensions/react-widget/src/types/index.ts @@ -38,7 +38,7 @@ export interface Query { response?: string; feedback?: FEEDBACK; error?: string; - sources?: { title: string; text: string, source:string }[]; + sources?: { title: string; text: string; source: string }[]; conversationId?: string | null; title?: string | null; } @@ -51,30 +51,34 @@ export interface WidgetProps { description?: string; heroTitle?: string; heroDescription?: string; - size?: 'small' | 'medium' | 'large' | { - custom: { - width: string; - height: string; - maxWidth?: string; - maxHeight?: string; - }; - }; - theme?:THEME, - buttonIcon?:string; - buttonText?:string; - buttonBg?:string; - collectFeedback?:boolean; + size?: + | 'small' + | 'medium' + | 'large' + | { + custom: { + width: string; + height: string; + maxWidth?: string; + maxHeight?: string; + }; + }; + theme?: THEME; + buttonIcon?: string; + buttonText?: string; + buttonBg?: string; + collectFeedback?: boolean; showSources?: boolean; defaultOpen?: boolean; } -export interface WidgetCoreProps extends WidgetProps { - widgetRef?:React.RefObject | null; - handleClose?:React.MouseEventHandler | undefined; - isOpen:boolean; +export interface WidgetCoreProps extends WidgetProps { + widgetRef?: React.RefObject | null; + handleClose?: React.MouseEventHandler | undefined; + isOpen: boolean; prefilledQuery?: string; } -export interface SearchBarProps { +export interface SearchBarProps { apiHost?: string; apiKey?: string; theme?: THEME; @@ -84,7 +88,7 @@ export interface SearchBarProps { } export interface Result { - text:string; - title:string; - source:string; + text: string; + title: string; + source: string; } diff --git a/extensions/react-widget/src/utils/helper.ts b/extensions/react-widget/src/utils/helper.ts index 9f92fdcb..75c8c2a0 100644 --- a/extensions/react-widget/src/utils/helper.ts +++ b/extensions/react-widget/src/utils/helper.ts @@ -30,11 +30,15 @@ interface ParsedElement { tag: string; } -export const processMarkdownString = (markdown: string, keyword?: string): ParsedElement[] => { +export const processMarkdownString = ( + markdown: string, + keyword?: string, +): ParsedElement[] => { const lines = markdown.trim().split('\n'); const keywordLower = keyword?.toLowerCase(); - const escapeRegExp = (str: string) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + const escapeRegExp = (str: string) => + str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); const escapedKeyword = keyword ? escapeRegExp(keyword) : ''; const keywordRegex = keyword ? new RegExp(`(${escapedKeyword})`, 'gi') : null; @@ -56,7 +60,7 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse const codeContent = codeBlockContent.join('\n'); const parsedElement: ParsedElement = { content: codeContent, - tag: 'code' + tag: 'code', }; if (!firstLine) { @@ -64,7 +68,10 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse } if (keywordLower && codeContent.toLowerCase().includes(keywordLower)) { - parsedElement.content = parsedElement.content.replace(keywordRegex!, '$1'); + parsedElement.content = parsedElement.content.replace( + keywordRegex!, + '$1', + ); matchingLines.push(parsedElement); } } @@ -89,30 +96,30 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse content = headingMatch[2]; parsedElement = { content: content, - tag: 'heading' + tag: 'heading', }; } else if (bulletMatch) { content = bulletMatch[1]; parsedElement = { content: content, - tag: 'bulletList' + tag: 'bulletList', }; } else if (numberedMatch) { content = numberedMatch[1]; parsedElement = { content: content, - tag: 'numberedList' + tag: 'numberedList', }; } else if (blockquoteMatch) { content = blockquoteMatch[1]; parsedElement = { content: content, - tag: 'blockquote' + tag: 'blockquote', }; } else { parsedElement = { content: content, - tag: 'text' + tag: 'text', }; } @@ -120,8 +127,14 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse firstLine = parsedElement; } - if (keywordLower && parsedElement.content.toLowerCase().includes(keywordLower)) { - parsedElement.content = parsedElement.content.replace(keywordRegex!, '$1'); + if ( + keywordLower && + parsedElement.content.toLowerCase().includes(keywordLower) + ) { + parsedElement.content = parsedElement.content.replace( + keywordRegex!, + '$1', + ); matchingLines.push(parsedElement); } } @@ -130,7 +143,7 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse const codeContent = codeBlockContent.join('\n'); const parsedElement: ParsedElement = { content: codeContent, - tag: 'code' + tag: 'code', }; if (!firstLine) { @@ -138,7 +151,10 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse } if (keywordLower && codeContent.toLowerCase().includes(keywordLower)) { - parsedElement.content = parsedElement.content.replace(keywordRegex!, '$1'); + parsedElement.content = parsedElement.content.replace( + keywordRegex!, + '$1', + ); matchingLines.push(parsedElement); } } From 462f2e9494f765957fe5d276dde65dbad8d2a26a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 22:34:25 +0000 Subject: [PATCH 15/41] mini refactors --- application/agents/agentic_agent.py | 58 +- application/agents/research_agent.py | 34 +- application/agents/tools/internal_search.py | 53 ++ application/agents/workflows/node_agent.py | 19 +- application/agents/workflows/schemas.py | 2 + .../agents/workflows/workflow_engine.py | 39 +- application/prompts/agentic/creative.txt | 2 +- application/prompts/agentic/default.txt | 2 +- application/prompts/agentic/strict.txt | 2 +- .../prompts/research/clarification.txt | 2 +- application/prompts/research/planning.txt | 2 +- application/prompts/research/step.txt | 2 +- application/prompts/research/synthesis.txt | 2 +- frontend/src/agents/WorkflowBuilder.tsx | 8 +- .../src/agents/workflow/WorkflowBuilder.tsx | 8 +- tests/agents/test_agentic_agent.py | 139 +++++ tests/agents/test_internal_search_tool.py | 250 +++++++++ tests/agents/test_research_agent.py | 400 ++++++++++++++ tests/agents/test_think_tool.py | 52 ++ tests/agents/test_tool_executor.py | 279 ++++++++++ tests/agents/test_workflow_agent_types.py | 475 +++++++++++++++++ tests/integration/test_chat.py | 501 ++++++++++++++++++ 22 files changed, 2233 insertions(+), 98 deletions(-) create mode 100644 tests/agents/test_agentic_agent.py create mode 100644 tests/agents/test_internal_search_tool.py create mode 100644 tests/agents/test_research_agent.py create mode 100644 tests/agents/test_think_tool.py create mode 100644 tests/agents/test_tool_executor.py create mode 100644 tests/agents/test_workflow_agent_types.py diff --git a/application/agents/agentic_agent.py b/application/agents/agentic_agent.py index c8af7a2a..fe08277a 100644 --- a/application/agents/agentic_agent.py +++ b/application/agents/agentic_agent.py @@ -4,8 +4,7 @@ from typing import Dict, Generator, Optional from application.agents.base import BaseAgent from application.agents.tools.internal_search import ( INTERNAL_TOOL_ID, - build_internal_tool_config, - build_internal_tool_entry, + add_internal_search_tool, ) from application.logging import LogContext @@ -32,24 +31,8 @@ class AgenticAgent(BaseAgent): def _gen_inner( self, query: str, log_context: LogContext ) -> Generator[Dict, None, None]: - # 1. Get user tools (same as ClassicAgent) tools_dict = self.tool_executor.get_tools() - - # 2. Add internal search as a synthetic tool (only if sources are configured) - source = self.retriever_config.get("source", {}) - has_sources = bool(source.get("active_docs")) - if self.retriever_config and has_sources: - has_dir = _sources_have_directory_structure(source) - internal_entry = build_internal_tool_entry( - has_directory_structure=has_dir - ) - internal_entry["config"] = build_internal_tool_config( - **self.retriever_config, - has_directory_structure=has_dir, - ) - tools_dict[INTERNAL_TOOL_ID] = internal_entry - - # 3. Prepare all tools for the LLM + add_internal_search_tool(tools_dict, self.retriever_config) self._prepare_tools(tools_dict) # 4. Build messages (prompt has NO pre-fetched docs) @@ -78,40 +61,3 @@ class AgenticAgent(BaseAgent): tool = self.tool_executor._loaded_tools.get(cache_key) if tool and hasattr(tool, "retrieved_docs") and tool.retrieved_docs: self.retrieved_docs = tool.retrieved_docs - - -def _sources_have_directory_structure(source: Dict) -> bool: - """Check if any of the active sources have directory_structure in MongoDB.""" - active_docs = source.get("active_docs", []) - if not active_docs: - return False - - try: - from bson.objectid import ObjectId - from application.core.mongo_db import MongoDB - - mongo = MongoDB.get_client() - db = mongo[settings.MONGO_DB_NAME] - sources_collection = db["sources"] - - if isinstance(active_docs, str): - active_docs = [active_docs] - - for doc_id in active_docs: - try: - source_doc = sources_collection.find_one( - {"_id": ObjectId(doc_id)}, - {"directory_structure": 1}, - ) - if source_doc and source_doc.get("directory_structure"): - return True - except Exception: - continue - except Exception as e: - logger.debug(f"Could not check directory structure: {e}") - - return False - - -# Import settings at module level for _sources_have_directory_structure -from application.core.settings import settings # noqa: E402 diff --git a/application/agents/research_agent.py b/application/agents/research_agent.py index b743cb16..280fa2cd 100644 --- a/application/agents/research_agent.py +++ b/application/agents/research_agent.py @@ -6,11 +6,9 @@ from typing import Dict, Generator, List, Optional from application.agents.base import BaseAgent from application.agents.tool_executor import ToolExecutor -from application.agents.agentic_agent import _sources_have_directory_structure from application.agents.tools.internal_search import ( INTERNAL_TOOL_ID, - build_internal_tool_config, - build_internal_tool_entry, + add_internal_search_tool, ) from application.agents.tools.think import THINK_TOOL_ENTRY, THINK_TOOL_ID from application.logging import LogContext @@ -130,6 +128,7 @@ class ResearchAgent(BaseAgent): self.citations = CitationManager() self._start_time: float = 0 self._tokens_used: int = 0 + self._last_token_snapshot: int = 0 # ------------------------------------------------------------------ # Budget & timeout helpers @@ -153,7 +152,9 @@ class ResearchAgent(BaseAgent): def _snapshot_llm_tokens(self) -> int: """Read current token usage from LLM and return delta since last snapshot.""" current = self.llm.token_usage.get("prompt_tokens", 0) + self.llm.token_usage.get("generated_tokens", 0) - return current + delta = current - self._last_token_snapshot + self._last_token_snapshot = current + return delta # ------------------------------------------------------------------ # Main orchestration @@ -272,21 +273,7 @@ class ResearchAgent(BaseAgent): """Build tools_dict with user tools + internal search + think.""" tools_dict = self.tool_executor.get_tools() - # Only add internal search if sources are configured - source = self.retriever_config.get("source", {}) - has_sources = bool(source.get("active_docs")) - if self.retriever_config and has_sources: - has_dir = _sources_have_directory_structure(source) - internal_entry = build_internal_tool_entry( - has_directory_structure=has_dir - ) - internal_entry["config"] = build_internal_tool_config( - **self.retriever_config, - has_directory_structure=has_dir, - ) - tools_dict[INTERNAL_TOOL_ID] = internal_entry - elif self.retriever_config and not has_sources: - logger.info("ResearchAgent: No sources configured, skipping internal_search tool") + add_internal_search_tool(tools_dict, self.retriever_config) think_entry = dict(THINK_TOOL_ENTRY) think_entry["config"] = {} @@ -580,7 +567,14 @@ class ResearchAgent(BaseAgent): call_id = None while True: try: - next(gen) + event = next(gen) + # Log tool_call status events instead of discarding them + if isinstance(event, dict) and event.get("type") == "tool_call": + logger.debug( + "Tool %s status: %s", + event.get("data", {}).get("action_name", ""), + event.get("data", {}).get("status", ""), + ) except StopIteration as e: result, call_id = e.value break diff --git a/application/agents/tools/internal_search.py b/application/agents/tools/internal_search.py index 66f91e83..2cd7915b 100644 --- a/application/agents/tools/internal_search.py +++ b/application/agents/tools/internal_search.py @@ -354,6 +354,59 @@ def build_internal_tool_entry(has_directory_structure: bool = False) -> Dict: INTERNAL_TOOL_ENTRY = build_internal_tool_entry(has_directory_structure=False) +def sources_have_directory_structure(source: Dict) -> bool: + """Check if any of the active sources have directory_structure in MongoDB.""" + active_docs = source.get("active_docs", []) + if not active_docs: + return False + + try: + from bson.objectid import ObjectId + from application.core.mongo_db import MongoDB + + mongo = MongoDB.get_client() + db = mongo[settings.MONGO_DB_NAME] + sources_collection = db["sources"] + + if isinstance(active_docs, str): + active_docs = [active_docs] + + for doc_id in active_docs: + try: + source_doc = sources_collection.find_one( + {"_id": ObjectId(doc_id)}, + {"directory_structure": 1}, + ) + if source_doc and source_doc.get("directory_structure"): + return True + except Exception: + continue + except Exception as e: + logger.debug(f"Could not check directory structure: {e}") + + return False + + +def add_internal_search_tool(tools_dict: Dict, retriever_config: Dict) -> None: + """Add the internal search tool to tools_dict if sources are configured. + + Shared by AgenticAgent and ResearchAgent to avoid duplicate setup logic. + Mutates tools_dict in place. + """ + source = retriever_config.get("source", {}) + has_sources = bool(source.get("active_docs")) + if not retriever_config or not has_sources: + return + + has_dir = sources_have_directory_structure(source) + internal_entry = build_internal_tool_entry(has_directory_structure=has_dir) + internal_entry["config"] = build_internal_tool_config( + **retriever_config, + has_directory_structure=has_dir, + ) + tools_dict[INTERNAL_TOOL_ID] = internal_entry + + def build_internal_tool_config( source: Dict, retriever_name: str = "classic", diff --git a/application/agents/workflows/node_agent.py b/application/agents/workflows/node_agent.py index c26f6464..67437e45 100644 --- a/application/agents/workflows/node_agent.py +++ b/application/agents/workflows/node_agent.py @@ -2,8 +2,10 @@ from typing import Any, Dict, List, Optional, Type +from application.agents.agentic_agent import AgenticAgent from application.agents.base import BaseAgent from application.agents.classic_agent import ClassicAgent +from application.agents.research_agent import ResearchAgent from application.agents.workflows.schemas import AgentType @@ -35,7 +37,8 @@ class ToolFilterMixin: return filtered_tools -class WorkflowNodeClassicAgent(ToolFilterMixin, ClassicAgent): +class _WorkflowNodeMixin: + """Common __init__ for all workflow node agents.""" def __init__( self, @@ -56,11 +59,25 @@ class WorkflowNodeClassicAgent(ToolFilterMixin, ClassicAgent): self._allowed_tool_ids = tool_ids or [] +class WorkflowNodeClassicAgent(ToolFilterMixin, _WorkflowNodeMixin, ClassicAgent): + pass + + +class WorkflowNodeAgenticAgent(ToolFilterMixin, _WorkflowNodeMixin, AgenticAgent): + pass + + +class WorkflowNodeResearchAgent(ToolFilterMixin, _WorkflowNodeMixin, ResearchAgent): + pass + + class WorkflowNodeAgentFactory: _agents: Dict[AgentType, Type[BaseAgent]] = { AgentType.CLASSIC: WorkflowNodeClassicAgent, AgentType.REACT: WorkflowNodeClassicAgent, # backwards compat + AgentType.AGENTIC: WorkflowNodeAgenticAgent, + AgentType.RESEARCH: WorkflowNodeResearchAgent, } @classmethod diff --git a/application/agents/workflows/schemas.py b/application/agents/workflows/schemas.py index 5355b88e..2a5bc79e 100644 --- a/application/agents/workflows/schemas.py +++ b/application/agents/workflows/schemas.py @@ -18,6 +18,8 @@ class NodeType(str, Enum): class AgentType(str, Enum): CLASSIC = "classic" REACT = "react" + AGENTIC = "agentic" + RESEARCH = "research" class ExecutionStatus(str, Enum): diff --git a/application/agents/workflows/workflow_engine.py b/application/agents/workflows/workflow_engine.py index 5444458a..00d471da 100644 --- a/application/agents/workflows/workflow_engine.py +++ b/application/agents/workflows/workflow_engine.py @@ -7,6 +7,7 @@ from application.agents.workflows.cel_evaluator import CelEvaluationError, evalu from application.agents.workflows.node_agent import WorkflowNodeAgentFactory from application.agents.workflows.schemas import ( AgentNodeConfig, + AgentType, ConditionNodeConfig, ExecutionStatus, NodeExecutionLog, @@ -223,18 +224,32 @@ class WorkflowEngine: f'Model "{node_model_id}" does not support structured output for node "{node.title}"' ) - node_agent = WorkflowNodeAgentFactory.create( - agent_type=node_config.agent_type, - endpoint=self.agent.endpoint, - llm_name=node_llm_name, - model_id=node_model_id, - api_key=node_api_key, - tool_ids=node_config.tools, - prompt=node_config.system_prompt, - chat_history=self.agent.chat_history, - decoded_token=self.agent.decoded_token, - json_schema=node_json_schema, - ) + factory_kwargs = { + "agent_type": node_config.agent_type, + "endpoint": self.agent.endpoint, + "llm_name": node_llm_name, + "model_id": node_model_id, + "api_key": node_api_key, + "tool_ids": node_config.tools, + "prompt": node_config.system_prompt, + "chat_history": self.agent.chat_history, + "decoded_token": self.agent.decoded_token, + "json_schema": node_json_schema, + } + + # Agentic/research agents need retriever_config for on-demand search + if node_config.agent_type in (AgentType.AGENTIC, AgentType.RESEARCH): + factory_kwargs["retriever_config"] = { + "source": {"active_docs": node_config.sources} if node_config.sources else {}, + "retriever_name": node_config.retriever or "classic", + "chunks": int(node_config.chunks) if node_config.chunks else 2, + "model_id": node_model_id, + "llm_name": node_llm_name, + "api_key": node_api_key, + "decoded_token": self.agent.decoded_token, + } + + node_agent = WorkflowNodeAgentFactory.create(**factory_kwargs) full_response_parts: List[str] = [] structured_response_parts: List[str] = [] diff --git a/application/prompts/agentic/creative.txt b/application/prompts/agentic/creative.txt index da2f8ff2..a1360b76 100644 --- a/application/prompts/agentic/creative.txt +++ b/application/prompts/agentic/creative.txt @@ -13,4 +13,4 @@ Use the search_internal tool to find relevant information before answering quest You may search multiple times with different queries if needed. Do not guess when documents are available — search first, then answer based on what you find. If no relevant documents are found, use your general knowledge and tool capabilities. -Allow yourself to be very creative and use your imagination. \ No newline at end of file +Allow yourself to be very creative and use your imagination. diff --git a/application/prompts/agentic/default.txt b/application/prompts/agentic/default.txt index 41339fe6..a2118414 100644 --- a/application/prompts/agentic/default.txt +++ b/application/prompts/agentic/default.txt @@ -12,4 +12,4 @@ You have access to a search tool that searches the user's uploaded documents and Use the search_internal tool to find relevant information before answering questions. You may search multiple times with different queries if needed. Do not guess when documents are available — search first, then answer based on what you find. -If no relevant documents are found, use your general knowledge and tool capabilities. \ No newline at end of file +If no relevant documents are found, use your general knowledge and tool capabilities. diff --git a/application/prompts/agentic/strict.txt b/application/prompts/agentic/strict.txt index 8878b323..dd327918 100644 --- a/application/prompts/agentic/strict.txt +++ b/application/prompts/agentic/strict.txt @@ -13,4 +13,4 @@ Use the search_internal tool to find relevant information before answering quest You may search multiple times with different queries if needed. You MUST search before answering any factual question. Do not guess or use general knowledge when documents are available. If you dont have enough information from the search results or tools, answer "I don't know" or "I don't have enough information". -Never make up information or provide false information! \ No newline at end of file +Never make up information or provide false information! diff --git a/application/prompts/research/clarification.txt b/application/prompts/research/clarification.txt index 000c73c2..499d46e3 100644 --- a/application/prompts/research/clarification.txt +++ b/application/prompts/research/clarification.txt @@ -20,4 +20,4 @@ You MUST respond with ONLY a valid JSON object (no markdown, no code fences): "needs_clarification": true or false, "questions": ["question 1", "question 2"] (only if needs_clarification is true, max 3 questions), "reason": "brief explanation of why clarification is or isn't needed" -} \ No newline at end of file +} diff --git a/application/prompts/research/planning.txt b/application/prompts/research/planning.txt index 8263bcaf..74c84a94 100644 --- a/application/prompts/research/planning.txt +++ b/application/prompts/research/planning.txt @@ -20,4 +20,4 @@ You MUST respond with ONLY a valid JSON object in this exact format (no markdown "steps": [ {"query": "specific sub-question to investigate", "rationale": "why this step is needed"} ] -} \ No newline at end of file +} diff --git a/application/prompts/research/step.txt b/application/prompts/research/step.txt index aecaf2b8..c0bf48a0 100644 --- a/application/prompts/research/step.txt +++ b/application/prompts/research/step.txt @@ -10,4 +10,4 @@ Instructions: 5. Cite specific documents and passages you found. Reference sources by their titles or filenames. 6. If you cannot find relevant information through tools, use your general knowledge but clearly indicate this. -Be thorough — prefer completeness over brevity. Include all relevant details you find. \ No newline at end of file +Be thorough — prefer completeness over brevity. Include all relevant details you find. diff --git a/application/prompts/research/synthesis.txt b/application/prompts/research/synthesis.txt index 66bb1cad..30ea3958 100644 --- a/application/prompts/research/synthesis.txt +++ b/application/prompts/research/synthesis.txt @@ -19,4 +19,4 @@ Write a well-structured, thorough report that: Available sources for citation: {references} -Format the report with clear headings and sections. Be comprehensive but well-organized. \ No newline at end of file +Format the report with clear headings and sections. Be comprehensive but well-organized. diff --git a/frontend/src/agents/WorkflowBuilder.tsx b/frontend/src/agents/WorkflowBuilder.tsx index 38a2ac67..d3c7c8e1 100644 --- a/frontend/src/agents/WorkflowBuilder.tsx +++ b/frontend/src/agents/WorkflowBuilder.tsx @@ -55,7 +55,7 @@ import WorkflowPreview from './workflow/WorkflowPreview'; import type { Model } from '../models/types'; interface AgentNodeConfig { - agent_type: 'classic'; + agent_type: 'classic' | 'agentic' | 'research'; llm_name?: string; model_id?: string; system_prompt: string; @@ -884,6 +884,12 @@ function WorkflowBuilderInner() { Classic + + Agentic + + + Research +
diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 845145be..5e765ea9 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -75,7 +75,7 @@ import type { Model } from '../../models/types'; const PRIMARY_ACTION_SPINNER_DELAY_MS = 180; interface AgentNodeConfig { - agent_type: 'classic'; + agent_type: 'classic' | 'agentic' | 'research'; llm_name?: string; model_id?: string; system_prompt: string; @@ -1748,6 +1748,12 @@ function WorkflowBuilderInner() { Classic + + Agentic + + + Research +
diff --git a/tests/agents/test_agentic_agent.py b/tests/agents/test_agentic_agent.py new file mode 100644 index 00000000..f742fffd --- /dev/null +++ b/tests/agents/test_agentic_agent.py @@ -0,0 +1,139 @@ +"""Tests for AgenticAgent — LLM-controlled retrieval agent.""" + +from unittest.mock import Mock + +import pytest +from application.agents.agentic_agent import AgenticAgent + + +@pytest.mark.unit +class TestAgenticAgentInit: + + def test_initialization( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = AgenticAgent(**agent_base_params) + assert isinstance(agent, AgenticAgent) + assert agent.retriever_config == {} + + def test_initialization_with_retriever_config( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + rc = {"source": {"active_docs": ["abc"]}, "retriever_name": "classic"} + agent = AgenticAgent(retriever_config=rc, **agent_base_params) + assert agent.retriever_config == rc + + def test_inherits_base_properties( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = AgenticAgent(**agent_base_params) + assert agent.endpoint == agent_base_params["endpoint"] + assert agent.llm_name == agent_base_params["llm_name"] + assert agent.model_id == agent_base_params["model_id"] + + +@pytest.mark.unit +class TestAgenticAgentGenInner: + + def test_basic_flow_yields_sources_and_tool_calls( + self, + agent_base_params, + mock_llm, + mock_llm_handler, + mock_llm_creator, + mock_llm_handler_creator, + mock_mongo_db, + log_context, + ): + mock_llm.gen_stream = Mock(return_value=iter(["Answer"])) + + def mock_handler(*args, **kwargs): + yield "Processed" + + mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) + + agent = AgenticAgent(**agent_base_params) + results = list(agent._gen_inner("Test query", log_context)) + + sources = [r for r in results if "sources" in r] + tool_calls = [r for r in results if "tool_calls" in r] + assert len(sources) == 1 + assert len(tool_calls) == 1 + + def test_logs_agent_component( + self, + agent_base_params, + mock_llm, + mock_llm_handler, + mock_llm_creator, + mock_llm_handler_creator, + mock_mongo_db, + log_context, + ): + mock_llm.gen_stream = Mock(return_value=iter(["Answer"])) + + def mock_handler(*args, **kwargs): + yield "Done" + + mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) + + agent = AgenticAgent(**agent_base_params) + list(agent._gen_inner("Query", log_context)) + + agent_logs = [s for s in log_context.stacks if s["component"] == "agent"] + assert len(agent_logs) == 1 + assert "tool_calls" in agent_logs[0]["data"] + + def test_no_pre_fetched_docs_in_messages( + self, + agent_base_params, + mock_llm, + mock_llm_handler, + mock_llm_creator, + mock_llm_handler_creator, + mock_mongo_db, + log_context, + ): + mock_llm.gen_stream = Mock(return_value=iter(["Answer"])) + + def mock_handler(*args, **kwargs): + yield "Done" + + mock_llm_handler.process_message_flow = Mock(side_effect=mock_handler) + + agent = AgenticAgent(**agent_base_params) + list(agent._gen_inner("Query", log_context)) + + call_kwargs = mock_llm.gen_stream.call_args[1] + messages = call_kwargs["messages"] + # System prompt should not contain {summaries} replacement + assert messages[0]["role"] == "system" + assert messages[-1]["role"] == "user" + assert messages[-1]["content"] == "Query" + + +@pytest.mark.unit +class TestAgenticAgentCollectSources: + + def test_collect_internal_sources_from_cache( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = AgenticAgent(**agent_base_params) + + mock_tool = Mock() + mock_tool.retrieved_docs = [ + {"text": "Found", "title": "Doc", "source": "test"}, + ] + cache_key = f"internal_search:internal:{agent.user or ''}" + agent.tool_executor._loaded_tools[cache_key] = mock_tool + + agent._collect_internal_sources() + assert len(agent.retrieved_docs) == 1 + assert agent.retrieved_docs[0]["title"] == "Doc" + + def test_collect_internal_sources_no_cache( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = AgenticAgent(**agent_base_params) + agent._collect_internal_sources() + assert agent.retrieved_docs == [] diff --git a/tests/agents/test_internal_search_tool.py b/tests/agents/test_internal_search_tool.py new file mode 100644 index 00000000..b9e4fce0 --- /dev/null +++ b/tests/agents/test_internal_search_tool.py @@ -0,0 +1,250 @@ +"""Tests for InternalSearchTool and its helper functions.""" + +from unittest.mock import Mock, patch + +import pytest +from application.agents.tools.internal_search import ( + INTERNAL_TOOL_ID, + InternalSearchTool, + add_internal_search_tool, + build_internal_tool_config, + build_internal_tool_entry, +) + + +@pytest.mark.unit +class TestInternalSearchToolSearch: + + def _make_tool(self, **config_overrides): + config = {"source": {}, "retriever_name": "classic", "chunks": 2} + config.update(config_overrides) + return InternalSearchTool(config) + + def test_search_no_query_returns_error(self): + tool = self._make_tool() + result = tool.execute_action("search", query="") + assert "required" in result.lower() + + def test_search_returns_formatted_docs(self): + tool = self._make_tool() + mock_retriever = Mock() + mock_retriever.search.return_value = [ + {"text": "Hello world", "title": "Doc1", "source": "test", "filename": "doc1.md"}, + ] + tool._retriever = mock_retriever + + result = tool.execute_action("search", query="hello") + assert "doc1.md" in result + assert "Hello world" in result + assert len(tool.retrieved_docs) == 1 + + def test_search_no_results(self): + tool = self._make_tool() + mock_retriever = Mock() + mock_retriever.search.return_value = [] + tool._retriever = mock_retriever + + result = tool.execute_action("search", query="nonexistent") + assert "No documents found" in result + + def test_search_accumulates_docs(self): + tool = self._make_tool() + mock_retriever = Mock() + tool._retriever = mock_retriever + + mock_retriever.search.return_value = [ + {"text": "A", "title": "D1", "source": "s1"}, + ] + tool.execute_action("search", query="first") + + mock_retriever.search.return_value = [ + {"text": "B", "title": "D2", "source": "s2"}, + ] + tool.execute_action("search", query="second") + + assert len(tool.retrieved_docs) == 2 + + def test_search_deduplicates_docs(self): + tool = self._make_tool() + doc = {"text": "Same", "title": "Same", "source": "same"} + mock_retriever = Mock() + mock_retriever.search.return_value = [doc] + tool._retriever = mock_retriever + + tool.execute_action("search", query="q1") + tool.execute_action("search", query="q2") + + assert len(tool.retrieved_docs) == 1 + + def test_search_with_path_filter(self): + tool = self._make_tool() + mock_retriever = Mock() + mock_retriever.search.return_value = [ + {"text": "A", "title": "T", "source": "src/main.py", "filename": "main.py"}, + {"text": "B", "title": "T", "source": "docs/readme.md", "filename": "readme.md"}, + ] + tool._retriever = mock_retriever + + result = tool.execute_action("search", query="code", path_filter="src/") + assert "main.py" in result + assert "readme.md" not in result + + def test_search_path_filter_no_match(self): + tool = self._make_tool() + mock_retriever = Mock() + mock_retriever.search.return_value = [ + {"text": "A", "title": "T", "source": "other/file.txt"}, + ] + tool._retriever = mock_retriever + + result = tool.execute_action("search", query="code", path_filter="src/") + assert "No documents found" in result + + def test_search_retriever_error(self): + tool = self._make_tool() + mock_retriever = Mock() + mock_retriever.search.side_effect = Exception("Connection error") + tool._retriever = mock_retriever + + result = tool.execute_action("search", query="test") + assert "failed" in result.lower() or "error" in result.lower() + + def test_unknown_action(self): + tool = self._make_tool() + result = tool.execute_action("nonexistent") + assert "Unknown action" in result + + +@pytest.mark.unit +class TestInternalSearchToolListFiles: + + def test_list_files_no_structure(self): + tool = InternalSearchTool({"source": {}}) + tool._dir_structure_loaded = True + tool._directory_structure = None + + result = tool.execute_action("list_files") + assert "No file structure" in result + + def test_list_files_root(self): + tool = InternalSearchTool({"source": {}}) + tool._dir_structure_loaded = True + tool._directory_structure = { + "src": {"main.py": {}}, + "README.md": {"type": "md", "token_count": 100}, + } + + result = tool.execute_action("list_files") + assert "src/" in result + assert "README.md" in result + + def test_list_files_nested_path(self): + tool = InternalSearchTool({"source": {}}) + tool._dir_structure_loaded = True + tool._directory_structure = { + "src": { + "utils": {"helper.py": {}}, + }, + } + + result = tool.execute_action("list_files", path="src") + assert "utils/" in result + + def test_list_files_invalid_path(self): + tool = InternalSearchTool({"source": {}}) + tool._dir_structure_loaded = True + tool._directory_structure = {"src": {}} + + result = tool.execute_action("list_files", path="nonexistent") + assert "not found" in result + + +@pytest.mark.unit +class TestInternalSearchToolMetadata: + + def test_actions_without_directory_structure(self): + tool = InternalSearchTool({"has_directory_structure": False}) + meta = tool.get_actions_metadata() + + action_names = [a["name"] for a in meta] + assert "search" in action_names + assert "list_files" not in action_names + + # search should not have path_filter + search = meta[0] + assert "path_filter" not in search["parameters"]["properties"] + + def test_actions_with_directory_structure(self): + tool = InternalSearchTool({"has_directory_structure": True}) + meta = tool.get_actions_metadata() + + action_names = [a["name"] for a in meta] + assert "search" in action_names + assert "list_files" in action_names + + # search should have path_filter + search = next(a for a in meta if a["name"] == "search") + assert "path_filter" in search["parameters"]["properties"] + + +@pytest.mark.unit +class TestBuildHelpers: + + def test_build_entry_without_directory_structure(self): + entry = build_internal_tool_entry(has_directory_structure=False) + assert entry["name"] == "internal_search" + action_names = [a["name"] for a in entry["actions"]] + assert "search" in action_names + assert "list_files" not in action_names + + def test_build_entry_with_directory_structure(self): + entry = build_internal_tool_entry(has_directory_structure=True) + action_names = [a["name"] for a in entry["actions"]] + assert "list_files" in action_names + + def test_build_config(self): + config = build_internal_tool_config( + source={"active_docs": ["abc"]}, + retriever_name="semantic", + chunks=4, + ) + assert config["source"] == {"active_docs": ["abc"]} + assert config["retriever_name"] == "semantic" + assert config["chunks"] == 4 + + def test_internal_tool_id(self): + assert INTERNAL_TOOL_ID == "internal" + + def test_add_internal_search_tool_with_sources(self): + tools_dict = {} + retriever_config = { + "source": {"active_docs": ["abc"]}, + "retriever_name": "classic", + "chunks": 2, + "model_id": "gpt-4", + "llm_name": "openai", + "api_key": "key", + } + + with patch( + "application.agents.tools.internal_search.sources_have_directory_structure", + return_value=False, + ): + add_internal_search_tool(tools_dict, retriever_config) + + assert INTERNAL_TOOL_ID in tools_dict + assert tools_dict[INTERNAL_TOOL_ID]["name"] == "internal_search" + assert "config" in tools_dict[INTERNAL_TOOL_ID] + + def test_add_internal_search_tool_no_sources(self): + tools_dict = {} + retriever_config = {"source": {}} + + add_internal_search_tool(tools_dict, retriever_config) + + assert INTERNAL_TOOL_ID not in tools_dict + + def test_add_internal_search_tool_empty_config(self): + tools_dict = {} + add_internal_search_tool(tools_dict, {}) + assert INTERNAL_TOOL_ID not in tools_dict diff --git a/tests/agents/test_research_agent.py b/tests/agents/test_research_agent.py new file mode 100644 index 00000000..3cc1e6d3 --- /dev/null +++ b/tests/agents/test_research_agent.py @@ -0,0 +1,400 @@ +"""Tests for ResearchAgent — multi-step research with budget controls.""" + +import json +import time +from unittest.mock import Mock, patch + +import pytest +from application.agents.research_agent import ( + CitationManager, + ResearchAgent, + DEFAULT_MAX_STEPS, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_TOKEN_BUDGET, +) + + +# --------------------------------------------------------------------------- +# CitationManager +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCitationManager: + + def test_add_returns_citation_number(self): + cm = CitationManager() + num = cm.add({"source": "s1", "title": "T1"}) + assert num == 1 + + def test_add_deduplicates(self): + cm = CitationManager() + n1 = cm.add({"source": "s1", "title": "T1"}) + n2 = cm.add({"source": "s1", "title": "T1"}) + assert n1 == n2 + assert len(cm.citations) == 1 + + def test_add_different_sources(self): + cm = CitationManager() + n1 = cm.add({"source": "s1", "title": "T1"}) + n2 = cm.add({"source": "s2", "title": "T2"}) + assert n1 != n2 + assert len(cm.citations) == 2 + + def test_add_docs_returns_mapping(self): + cm = CitationManager() + docs = [ + {"source": "s1", "title": "Doc A"}, + {"source": "s2", "title": "Doc B"}, + ] + text = cm.add_docs(docs) + assert "[1] Doc A" in text + assert "[2] Doc B" in text + + def test_format_references(self): + cm = CitationManager() + cm.add({"source": "http://example.com", "title": "Example", "filename": "ex.md"}) + refs = cm.format_references() + assert "[1]" in refs + assert "ex.md" in refs + + def test_format_references_empty(self): + cm = CitationManager() + assert "No sources" in cm.format_references() + + def test_get_all_docs(self): + cm = CitationManager() + cm.add({"source": "s1", "title": "T1"}) + cm.add({"source": "s2", "title": "T2"}) + docs = cm.get_all_docs() + assert len(docs) == 2 + + +# --------------------------------------------------------------------------- +# ResearchAgent Init & Budget +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResearchAgentInit: + + def test_initialization( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = ResearchAgent(**agent_base_params) + assert isinstance(agent, ResearchAgent) + assert agent.max_steps == DEFAULT_MAX_STEPS + assert agent.timeout_seconds == DEFAULT_TIMEOUT_SECONDS + assert agent.token_budget == DEFAULT_TOKEN_BUDGET + assert agent.retriever_config == {} + + def test_custom_budget( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = ResearchAgent( + max_steps=3, + timeout_seconds=60, + token_budget=50_000, + **agent_base_params, + ) + assert agent.max_steps == 3 + assert agent.timeout_seconds == 60 + assert agent.token_budget == 50_000 + + def test_with_retriever_config( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + rc = {"source": {"active_docs": ["abc"]}} + agent = ResearchAgent(retriever_config=rc, **agent_base_params) + assert agent.retriever_config == rc + + +@pytest.mark.unit +class TestResearchAgentBudget: + + def _make_agent(self, agent_base_params, mock_llm_creator, mock_llm_handler_creator, **kwargs): + return ResearchAgent(**kwargs, **agent_base_params) + + def test_timeout_detection( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent( + agent_base_params, mock_llm_creator, mock_llm_handler_creator, + timeout_seconds=0, + ) + agent._start_time = time.monotonic() - 1 + assert agent._is_timed_out() is True + + def test_not_timed_out( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent( + agent_base_params, mock_llm_creator, mock_llm_handler_creator, + timeout_seconds=300, + ) + agent._start_time = time.monotonic() + assert agent._is_timed_out() is False + + def test_token_budget_tracking( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent( + agent_base_params, mock_llm_creator, mock_llm_handler_creator, + token_budget=1000, + ) + agent._track_tokens(500) + assert agent._budget_remaining() == 500 + assert agent._is_over_budget() is False + + agent._track_tokens(500) + assert agent._budget_remaining() == 0 + assert agent._is_over_budget() is True + + def test_snapshot_llm_tokens_returns_delta( + self, agent_base_params, mock_llm, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent( + agent_base_params, mock_llm_creator, mock_llm_handler_creator, + ) + mock_llm.token_usage = {"prompt_tokens": 100, "generated_tokens": 50} + + delta1 = agent._snapshot_llm_tokens() + assert delta1 == 150 + + # Simulate more tokens used + mock_llm.token_usage = {"prompt_tokens": 200, "generated_tokens": 100} + delta2 = agent._snapshot_llm_tokens() + assert delta2 == 150 # 300 - 150 + + def test_elapsed( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent( + agent_base_params, mock_llm_creator, mock_llm_handler_creator, + ) + agent._start_time = time.monotonic() - 1.5 + elapsed = agent._elapsed() + assert elapsed >= 1.0 + + +# --------------------------------------------------------------------------- +# ResearchAgent Phases +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestResearchAgentClarification: + + def test_is_follow_up_no_history( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = ResearchAgent(**agent_base_params) + assert agent._is_follow_up() is False + + def test_is_follow_up_with_clarification_metadata( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent_base_params["chat_history"] = [ + {"prompt": "What?", "response": "Clarify", "metadata": {"is_clarification": True}}, + ] + agent = ResearchAgent(**agent_base_params) + assert agent._is_follow_up() is True + + def test_is_follow_up_without_metadata( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent_base_params["chat_history"] = [ + {"prompt": "What?", "response": "Normal answer"}, + ] + agent = ResearchAgent(**agent_base_params) + assert agent._is_follow_up() is False + + def test_clarification_returns_none_on_no_clarification_needed( + self, agent_base_params, mock_llm, mock_llm_creator, mock_llm_handler_creator + ): + response = Mock() + response.choices = [Mock()] + response.choices[0].message = Mock() + response.choices[0].message.content = json.dumps( + {"needs_clarification": False, "reason": "Clear enough"} + ) + mock_llm.gen = Mock(return_value=response) + mock_llm.token_usage = {"prompt_tokens": 10, "generated_tokens": 5} + + agent = ResearchAgent(**agent_base_params) + result = agent._clarification_phase("What is Python?") + assert result is None + + def test_clarification_returns_questions( + self, agent_base_params, mock_llm, mock_llm_creator, mock_llm_handler_creator + ): + clarification_json = json.dumps({ + "needs_clarification": True, + "questions": ["Which version?", "What context?"], + }) + # Return a plain string so _extract_text handles it directly + mock_llm.gen = Mock(return_value=clarification_json) + mock_llm.token_usage = {"prompt_tokens": 10, "generated_tokens": 5} + + agent = ResearchAgent(**agent_base_params) + result = agent._clarification_phase("Tell me about it") + assert result is not None + assert "Which version?" in result + assert "What context?" in result + + +@pytest.mark.unit +class TestResearchAgentPlanning: + + def test_planning_returns_steps_and_complexity( + self, agent_base_params, mock_llm, mock_llm_creator, mock_llm_handler_creator + ): + plan_json = json.dumps({ + "complexity": "moderate", + "steps": [ + {"query": "sub-question 1", "rationale": "reason 1"}, + {"query": "sub-question 2", "rationale": "reason 2"}, + ], + }) + # Return plain string so _extract_text handles it directly + mock_llm.gen = Mock(return_value=plan_json) + mock_llm.token_usage = {"prompt_tokens": 10, "generated_tokens": 5} + + agent = ResearchAgent(**agent_base_params) + steps, complexity = agent._planning_phase("Compare A and B") + + assert complexity == "moderate" + assert len(steps) == 2 + assert steps[0]["query"] == "sub-question 1" + + def test_planning_caps_steps_by_complexity( + self, agent_base_params, mock_llm, mock_llm_creator, mock_llm_handler_creator + ): + plan_json = json.dumps({ + "complexity": "simple", + "steps": [ + {"query": f"q{i}", "rationale": f"r{i}"} for i in range(10) + ], + }) + response = Mock() + response.choices = [Mock()] + response.choices[0].message = Mock() + response.choices[0].message.content = plan_json + mock_llm.gen = Mock(return_value=response) + mock_llm.token_usage = {"prompt_tokens": 10, "generated_tokens": 5} + + agent = ResearchAgent(**agent_base_params) + steps, complexity = agent._planning_phase("Simple question") + + assert complexity == "simple" + assert len(steps) <= 2 # COMPLEXITY_CAPS["simple"] == 2 + + def test_planning_fallback_on_error( + self, agent_base_params, mock_llm, mock_llm_creator, mock_llm_handler_creator + ): + mock_llm.gen = Mock(side_effect=Exception("LLM down")) + mock_llm.token_usage = {"prompt_tokens": 0, "generated_tokens": 0} + + agent = ResearchAgent(**agent_base_params) + steps, complexity = agent._planning_phase("Anything") + + assert complexity == "simple" + assert len(steps) == 1 + assert steps[0]["query"] == "Anything" + + +@pytest.mark.unit +class TestResearchAgentExtractText: + + def _make_agent(self, agent_base_params, mock_llm_creator, mock_llm_handler_creator): + return ResearchAgent(**agent_base_params) + + def test_extract_from_string( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + assert agent._extract_text("hello") == "hello" + + def test_extract_from_openai_response( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + response = Mock() + response.choices = [Mock()] + response.choices[0].message = Mock() + response.choices[0].message.content = "OpenAI content" + response.message = None + response.content = None + assert agent._extract_text(response) == "OpenAI content" + + def test_extract_from_anthropic_response( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + text_block = Mock() + text_block.text = "Anthropic content" + response = Mock() + response.content = [text_block] + response.message = None + response.choices = None + assert agent._extract_text(response) == "Anthropic content" + + def test_extract_from_none( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + assert agent._extract_text(None) == "" + + +@pytest.mark.unit +class TestResearchAgentParseJson: + + def _make_agent(self, agent_base_params, mock_llm_creator, mock_llm_handler_creator): + return ResearchAgent(**agent_base_params) + + def test_parse_plan_direct_json( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + text = '{"steps": [{"query": "q1"}], "complexity": "simple"}' + result = agent._parse_plan_json(text) + assert isinstance(result, dict) + assert len(result["steps"]) == 1 + + def test_parse_plan_from_code_fence( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + text = 'Here is the plan:\n```json\n{"steps": [{"query": "q1"}]}\n```' + result = agent._parse_plan_json(text) + assert isinstance(result, dict) + + def test_parse_plan_invalid_returns_empty( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + result = agent._parse_plan_json("not json at all") + assert result == [] + + def test_parse_clarification_json( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + text = '{"needs_clarification": false, "reason": "clear"}' + result = agent._parse_clarification_json(text) + assert result["needs_clarification"] is False + + def test_parse_clarification_json_from_code_fence( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + text = '```json\n{"needs_clarification": true, "questions": ["q1"]}\n```' + result = agent._parse_clarification_json(text) + assert result["needs_clarification"] is True + + def test_parse_clarification_json_invalid( + self, agent_base_params, mock_llm_creator, mock_llm_handler_creator + ): + agent = self._make_agent(agent_base_params, mock_llm_creator, mock_llm_handler_creator) + result = agent._parse_clarification_json("not json") + assert result is None diff --git a/tests/agents/test_think_tool.py b/tests/agents/test_think_tool.py new file mode 100644 index 00000000..45f9e727 --- /dev/null +++ b/tests/agents/test_think_tool.py @@ -0,0 +1,52 @@ +"""Tests for ThinkTool — the chain-of-thought pseudo-tool.""" + +import pytest +from application.agents.tools.think import ( + THINK_TOOL_ENTRY, + THINK_TOOL_ID, + ThinkTool, +) + + +@pytest.mark.unit +class TestThinkTool: + + def test_id_constant(self): + assert THINK_TOOL_ID == "think" + + def test_entry_has_reason_action(self): + actions = THINK_TOOL_ENTRY["actions"] + assert len(actions) == 1 + assert actions[0]["name"] == "reason" + assert actions[0]["active"] is True + + def test_execute_reason_returns_continue(self): + tool = ThinkTool() + result = tool.execute_action("reason", reasoning="step by step thinking") + assert result == "Continue." + + def test_execute_unknown_action_returns_continue(self): + tool = ThinkTool() + result = tool.execute_action("unknown_action") + assert result == "Continue." + + def test_get_actions_metadata(self): + tool = ThinkTool() + meta = tool.get_actions_metadata() + assert len(meta) == 1 + assert meta[0]["name"] == "reason" + props = meta[0]["parameters"]["properties"] + assert "reasoning" in props + assert props["reasoning"]["filled_by_llm"] is True + + def test_get_config_requirements_empty(self): + tool = ThinkTool() + assert tool.get_config_requirements() == {} + + def test_init_accepts_no_config(self): + tool = ThinkTool() + assert tool is not None + + def test_init_accepts_config(self): + tool = ThinkTool(config={"key": "value"}) + assert tool is not None diff --git a/tests/agents/test_tool_executor.py b/tests/agents/test_tool_executor.py new file mode 100644 index 00000000..d995c22b --- /dev/null +++ b/tests/agents/test_tool_executor.py @@ -0,0 +1,279 @@ +"""Tests for ToolExecutor — tool discovery, preparation, and execution.""" + +from unittest.mock import Mock + +import pytest +from application.agents.tool_executor import ToolExecutor + + +@pytest.mark.unit +class TestToolExecutorInit: + + def test_default_state(self): + executor = ToolExecutor() + assert executor.user_api_key is None + assert executor.user is None + assert executor.tool_calls == [] + assert executor._loaded_tools == {} + assert executor.conversation_id is None + + def test_init_with_params(self): + executor = ToolExecutor( + user_api_key="key", user="alice", decoded_token={"sub": "alice"} + ) + assert executor.user_api_key == "key" + assert executor.user == "alice" + + +@pytest.mark.unit +class TestToolExecutorGetTools: + + def test_get_tools_uses_api_key_when_present(self, mock_mongo_db): + executor = ToolExecutor(user_api_key="test_key", user="alice") + tools = executor.get_tools() + assert isinstance(tools, dict) + + def test_get_tools_uses_user_when_no_api_key(self, mock_mongo_db): + executor = ToolExecutor(user="alice") + tools = executor.get_tools() + assert isinstance(tools, dict) + + def test_get_tools_defaults_to_local(self, mock_mongo_db): + executor = ToolExecutor() + tools = executor.get_tools() + assert isinstance(tools, dict) + + +@pytest.mark.unit +class TestToolExecutorPrepare: + + def test_prepare_tools_for_llm_empty(self): + executor = ToolExecutor() + result = executor.prepare_tools_for_llm({}) + assert result == [] + + def test_prepare_tools_for_llm_non_api_tool(self): + executor = ToolExecutor() + tools_dict = { + "t1": { + "name": "test_tool", + "actions": [ + { + "name": "do_thing", + "description": "Does a thing", + "active": True, + "parameters": { + "properties": { + "query": { + "type": "string", + "description": "The query", + "filled_by_llm": True, + "required": True, + } + } + }, + } + ], + } + } + + result = executor.prepare_tools_for_llm(tools_dict) + assert len(result) == 1 + assert result[0]["type"] == "function" + assert result[0]["function"]["name"] == "do_thing_t1" + assert "query" in result[0]["function"]["parameters"]["properties"] + + def test_prepare_tools_skips_inactive_actions(self): + executor = ToolExecutor() + tools_dict = { + "t1": { + "name": "test_tool", + "actions": [ + {"name": "active_one", "description": "D", "active": True, "parameters": {"properties": {}}}, + {"name": "inactive_one", "description": "D", "active": False, "parameters": {"properties": {}}}, + ], + } + } + + result = executor.prepare_tools_for_llm(tools_dict) + assert len(result) == 1 + assert result[0]["function"]["name"] == "active_one_t1" + + def test_build_tool_parameters_filters_non_llm_fields(self): + executor = ToolExecutor() + action = { + "parameters": { + "properties": { + "query": { + "type": "string", + "description": "Search query", + "filled_by_llm": True, + "value": "default_val", + "required": True, + }, + "hidden": { + "type": "string", + "filled_by_llm": False, + }, + } + } + } + + result = executor._build_tool_parameters(action) + assert "query" in result["properties"] + assert "hidden" not in result["properties"] + assert "query" in result["required"] + # filled_by_llm, value, required stripped from schema + assert "filled_by_llm" not in result["properties"]["query"] + assert "value" not in result["properties"]["query"] + + +@pytest.mark.unit +class TestToolExecutorExecute: + + def _make_call(self, name="action_toolid", call_id="c1", arguments="{}"): + call = Mock() + call.name = name + call.id = call_id + call.arguments = arguments + return call + + def test_execute_parse_failure(self, monkeypatch): + executor = ToolExecutor() + + monkeypatch.setattr( + "application.agents.tool_executor.ToolActionParser", + lambda _cls: Mock(parse_args=Mock(return_value=(None, None, {}))), + ) + + call = self._make_call(name="bad") + gen = executor.execute({}, call, "MockLLM") + + events = [] + result = None + while True: + try: + events.append(next(gen)) + except StopIteration as e: + result = e.value + break + + assert result[0] == "Failed to parse tool call." + assert len(executor.tool_calls) == 1 + assert events[0]["data"]["status"] == "error" + + def test_execute_tool_not_found(self, monkeypatch): + executor = ToolExecutor() + + monkeypatch.setattr( + "application.agents.tool_executor.ToolActionParser", + lambda _cls: Mock(parse_args=Mock(return_value=("missing_id", "action", {}))), + ) + + call = self._make_call() + gen = executor.execute({}, call, "MockLLM") + + events = [] + result = None + while True: + try: + events.append(next(gen)) + except StopIteration as e: + result = e.value + break + + assert "not found" in result[0] + assert events[0]["data"]["status"] == "error" + + def test_execute_success(self, mock_tool_manager, monkeypatch): + executor = ToolExecutor(user="test_user") + + monkeypatch.setattr( + "application.agents.tool_executor.ToolActionParser", + lambda _cls: Mock(parse_args=Mock(return_value=("t1", "test_action", {"param1": "val"}))), + ) + + tools_dict = { + "t1": { + "name": "test_tool", + "config": {"key": "val"}, + "actions": [ + {"name": "test_action", "description": "Test", "parameters": {"properties": {}}}, + ], + } + } + + call = self._make_call(name="test_action_t1", call_id="c1") + gen = executor.execute(tools_dict, call, "MockLLM") + + events = [] + result = None + while True: + try: + events.append(next(gen)) + except StopIteration as e: + result = e.value + break + + assert result[0] == "Tool result" + assert result[1] == "c1" + + statuses = [e["data"]["status"] for e in events] + assert "pending" in statuses + assert "completed" in statuses + + def test_get_truncated_tool_calls(self): + executor = ToolExecutor() + executor.tool_calls = [ + { + "tool_name": "test", + "call_id": "1", + "action_name": "act", + "arguments": {}, + "result": "A" * 100, + } + ] + + truncated = executor.get_truncated_tool_calls() + assert len(truncated) == 1 + assert len(truncated[0]["result"]) <= 53 + assert truncated[0]["status"] == "completed" + + def test_tool_caching(self, mock_tool_manager, monkeypatch): + executor = ToolExecutor(user="test_user") + + monkeypatch.setattr( + "application.agents.tool_executor.ToolActionParser", + lambda _cls: Mock(parse_args=Mock(return_value=("t1", "test_action", {}))), + ) + + tools_dict = { + "t1": { + "name": "test_tool", + "config": {"key": "val"}, + "actions": [ + {"name": "test_action", "description": "Test", "parameters": {"properties": {}}}, + ], + } + } + + call = self._make_call(name="test_action_t1") + + # First execution — loads tool + gen = executor.execute(tools_dict, call, "MockLLM") + while True: + try: + next(gen) + except StopIteration: + break + + # Second execution — should use cache + gen = executor.execute(tools_dict, call, "MockLLM") + while True: + try: + next(gen) + except StopIteration: + break + + # load_tool called only once due to cache + assert mock_tool_manager.load_tool.call_count == 1 diff --git a/tests/agents/test_workflow_agent_types.py b/tests/agents/test_workflow_agent_types.py new file mode 100644 index 00000000..4b751d17 --- /dev/null +++ b/tests/agents/test_workflow_agent_types.py @@ -0,0 +1,475 @@ +"""Tests for new agent types (agentic, research) in the workflow builder.""" + +from types import SimpleNamespace +from typing import Any, Dict + +import pytest + +from application.agents.agentic_agent import AgenticAgent +from application.agents.classic_agent import ClassicAgent +from application.agents.research_agent import ResearchAgent +from application.agents.workflows.node_agent import ( + WorkflowNodeAgenticAgent, + WorkflowNodeAgentFactory, + WorkflowNodeClassicAgent, + WorkflowNodeResearchAgent, +) +from application.agents.workflows.schemas import ( + AgentNodeConfig, + AgentType, + NodeType, + Workflow, + WorkflowGraph, + WorkflowNode, +) +from application.agents.workflows.workflow_engine import WorkflowEngine + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class StubNodeAgent: + """Minimal agent stub that yields pre-defined events.""" + + def __init__(self, events): + self.events = events + + def gen(self, _prompt): + yield from self.events + + +def create_engine() -> WorkflowEngine: + graph = WorkflowGraph(workflow=Workflow(name="Test"), nodes=[], edges=[]) + agent = SimpleNamespace( + endpoint="stream", + llm_name="openai", + model_id="gpt-4o-mini", + api_key="test-key", + chat_history=[], + decoded_token={"sub": "user-1"}, + ) + return WorkflowEngine(graph, agent) + + +def create_agent_node( + node_id: str, + agent_type: str = "classic", + sources: list = None, + chunks: str = "2", + retriever: str = "", + output_variable: str = "", +) -> WorkflowNode: + config: Dict[str, Any] = { + "agent_type": agent_type, + "system_prompt": "You are a helpful assistant.", + "prompt_template": "", + "stream_to_user": False, + "tools": [], + "sources": sources or [], + "chunks": chunks, + "retriever": retriever, + } + if output_variable: + config["output_variable"] = output_variable + return WorkflowNode( + id=node_id, + workflow_id="workflow-1", + type=NodeType.AGENT, + title="Agent", + position={"x": 0, "y": 0}, + config=config, + ) + + +# --------------------------------------------------------------------------- +# AgentType enum +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAgentTypeEnum: + + def test_agentic_value_exists(self): + assert AgentType.AGENTIC == "agentic" + + def test_research_value_exists(self): + assert AgentType.RESEARCH == "research" + + def test_classic_still_exists(self): + assert AgentType.CLASSIC == "classic" + + def test_react_still_exists(self): + assert AgentType.REACT == "react" + + +# --------------------------------------------------------------------------- +# AgentNodeConfig schema validation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAgentNodeConfigValidation: + + def test_accepts_agentic_agent_type(self): + config = AgentNodeConfig(agent_type="agentic") + assert config.agent_type == AgentType.AGENTIC + + def test_accepts_research_agent_type(self): + config = AgentNodeConfig(agent_type="research") + assert config.agent_type == AgentType.RESEARCH + + def test_rejects_unknown_agent_type(self): + with pytest.raises(Exception): + AgentNodeConfig(agent_type="nonexistent") + + def test_default_agent_type_is_classic(self): + config = AgentNodeConfig() + assert config.agent_type == AgentType.CLASSIC + + +# --------------------------------------------------------------------------- +# WorkflowNodeAgentFactory registry +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestWorkflowNodeAgentFactoryRegistry: + + def test_factory_has_agentic(self): + assert AgentType.AGENTIC in WorkflowNodeAgentFactory._agents + assert WorkflowNodeAgentFactory._agents[AgentType.AGENTIC] is WorkflowNodeAgenticAgent + + def test_factory_has_research(self): + assert AgentType.RESEARCH in WorkflowNodeAgentFactory._agents + assert WorkflowNodeAgentFactory._agents[AgentType.RESEARCH] is WorkflowNodeResearchAgent + + def test_factory_raises_for_unknown_type(self): + with pytest.raises(ValueError, match="Unsupported agent type"): + WorkflowNodeAgentFactory.create( + agent_type="nonexistent", + endpoint="stream", + llm_name="openai", + model_id="gpt-4o-mini", + api_key="key", + ) + + +# --------------------------------------------------------------------------- +# WorkflowNode agent classes (inheritance) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestWorkflowNodeAgentClasses: + + def test_agentic_agent_inherits_correctly(self): + assert issubclass(WorkflowNodeAgenticAgent, AgenticAgent) + + def test_research_agent_inherits_correctly(self): + assert issubclass(WorkflowNodeResearchAgent, ResearchAgent) + + def test_classic_agent_inherits_correctly(self): + assert issubclass(WorkflowNodeClassicAgent, ClassicAgent) + + +# --------------------------------------------------------------------------- +# Workflow engine: agentic agent node execution +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestWorkflowEngineAgenticNode: + + def test_agentic_node_executes_and_saves_output(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_agentic", + agent_type="agentic", + output_variable="result", + ) + node_events = [{"answer": "agentic answer"}] + + captured: Dict[str, Any] = {} + + def capture_create(**kwargs): + captured.update(kwargs) + return StubNodeAgent(node_events) + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(capture_create), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + assert engine.state["node_agent_agentic_output"] == "agentic answer" + assert engine.state["result"] == "agentic answer" + + def test_agentic_node_passes_retriever_config(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_rc", + agent_type="agentic", + sources=["source-abc"], + chunks="4", + retriever="semantic", + ) + node_events = [{"answer": "ok"}] + + captured: Dict[str, Any] = {} + + def capture_create(**kwargs): + captured.update(kwargs) + return StubNodeAgent(node_events) + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(capture_create), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + rc = captured.get("retriever_config") + assert rc is not None + assert rc["source"] == {"active_docs": ["source-abc"]} + assert rc["retriever_name"] == "semantic" + assert rc["chunks"] == 4 + + def test_agentic_node_empty_sources_gives_empty_source_dict(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_nosrc", + agent_type="agentic", + sources=[], + ) + node_events = [{"answer": "ok"}] + + captured: Dict[str, Any] = {} + + def capture_create(**kwargs): + captured.update(kwargs) + return StubNodeAgent(node_events) + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(capture_create), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + rc = captured["retriever_config"] + assert rc["source"] == {} + + +# --------------------------------------------------------------------------- +# Workflow engine: research agent node execution +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestWorkflowEngineResearchNode: + + def test_research_node_executes_and_saves_output(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_research", + agent_type="research", + output_variable="report", + ) + node_events = [{"answer": "research report"}] + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(lambda **kwargs: StubNodeAgent(node_events)), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + assert engine.state["node_agent_research_output"] == "research report" + assert engine.state["report"] == "research report" + + def test_research_node_passes_retriever_config(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_rr", + agent_type="research", + sources=["doc-1", "doc-2"], + chunks="6", + ) + node_events = [{"answer": "ok"}] + + captured: Dict[str, Any] = {} + + def capture_create(**kwargs): + captured.update(kwargs) + return StubNodeAgent(node_events) + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(capture_create), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + rc = captured["retriever_config"] + assert rc["source"] == {"active_docs": ["doc-1", "doc-2"]} + assert rc["chunks"] == 6 + assert rc["decoded_token"] == {"sub": "user-1"} + + def test_research_node_handles_structured_output(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_rs", + agent_type="research", + output_variable="data", + ) + # Simulate structured JSON output from research agent + node_events = [ + {"answer": '{"findings": "important"}', "structured": True}, + ] + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(lambda **kwargs: StubNodeAgent(node_events)), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + # structured=True causes the engine to parse JSON + assert engine.state["data"] == {"findings": "important"} + + +# --------------------------------------------------------------------------- +# Workflow engine: classic node does NOT get retriever_config +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestWorkflowEngineClassicNodeNoRetrieverConfig: + + def test_classic_node_does_not_pass_retriever_config(self, monkeypatch): + engine = create_engine() + node = create_agent_node( + node_id="agent_classic", + agent_type="classic", + sources=["some-source"], + ) + node_events = [{"answer": "classic answer"}] + + captured: Dict[str, Any] = {} + + def capture_create(**kwargs): + captured.update(kwargs) + return StubNodeAgent(node_events) + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(capture_create), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + list(engine._execute_agent_node(node)) + + assert "retriever_config" not in captured + + +# --------------------------------------------------------------------------- +# Workflow engine: streaming events from new agent types +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestWorkflowEngineStreamingEvents: + + def test_agentic_node_streams_answer_events(self, monkeypatch): + engine = create_engine() + node = create_agent_node(node_id="agent_s1", agent_type="agentic") + # Modify config to enable streaming + node.config["stream_to_user"] = True + + node_events = [ + {"answer": "chunk 1"}, + {"answer": "chunk 2"}, + ] + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(lambda **kwargs: StubNodeAgent(node_events)), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + results = list(engine._execute_agent_node(node)) + answer_events = [r for r in results if "answer" in r] + assert len(answer_events) == 2 + + def test_research_node_passes_through_non_answer_events(self, monkeypatch): + """Research agents yield research_plan/research_progress events. + The workflow engine only forwards 'answer' events to the user.""" + engine = create_engine() + node = create_agent_node(node_id="agent_s2", agent_type="research") + node.config["stream_to_user"] = True + + node_events = [ + {"type": "research_plan", "data": {"steps": [], "complexity": "simple"}}, + {"type": "research_progress", "data": {"status": "planning"}}, + {"answer": "final report"}, + ] + + monkeypatch.setattr( + WorkflowNodeAgentFactory, + "create", + staticmethod(lambda **kwargs: StubNodeAgent(node_events)), + ) + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + lambda _provider: None, + ) + + results = list(engine._execute_agent_node(node)) + # Only answer events are streamed to user + answer_events = [r for r in results if "answer" in r] + assert len(answer_events) == 1 + assert answer_events[0]["answer"] == "final report" + + # State still captures the full text + assert engine.state["node_agent_s2_output"] == "final report" diff --git a/tests/integration/test_chat.py b/tests/integration/test_chat.py index 350faaba..2651d169 100644 --- a/tests/integration/test_chat.py +++ b/tests/integration/test_chat.py @@ -492,6 +492,486 @@ This is test documentation for integration tests. self.record_result(test_name, False, str(e)) return False + # ------------------------------------------------------------------------- + # Agentic / Research Agent Tests + # ------------------------------------------------------------------------- + + def _create_agent_with_type(self, agent_type: str) -> Optional[tuple]: + """Create a test agent with the given agent_type. Returns (agent_id, api_key) or None.""" + if not self.is_authenticated: + return None + + payload = { + "name": f"Chat Test {agent_type.title()} Agent {int(time.time())}", + "description": f"Integration test {agent_type} agent", + "prompt_id": "default", + "chunks": 2, + "retriever": "classic", + "agent_type": agent_type, + "status": "draft", + } + + try: + response = self.post("/api/create_agent", json=payload, timeout=10) + if response.status_code in [200, 201]: + result = response.json() + agent_id = result.get("id") + api_key = result.get("key") + if agent_id: + return (agent_id, api_key) + except Exception: + pass + + return None + + def test_stream_agentic_agent(self) -> bool: + """Test /stream endpoint with an agentic agent.""" + test_name = "Stream endpoint (agentic agent)" + + agent_result = self._create_agent_with_type("agentic") + if not agent_result: + if not self.require_auth(test_name): + return True + self.record_result(test_name, True, "Skipped (no agent)") + return True + + agent_id, _ = agent_result + self.print_header(f"Testing {test_name}") + + payload = { + "question": "What is DocsGPT?", + "history": "[]", + "agent_id": agent_id, + } + + try: + self.print_info(f"POST /stream with agentic agent_id={agent_id[:8]}...") + + response = requests.post( + f"{self.base_url}/stream", + json=payload, + headers=self.headers, + stream=True, + timeout=60, + ) + + self.print_info(f"Status Code: {response.status_code}") + + if response.status_code != 200: + self.print_error(f"Expected 200, got {response.status_code}") + self.record_result(test_name, False, f"Status {response.status_code}") + return False + + events = [] + full_response = "" + for line in response.iter_lines(): + if line: + line_str = line.decode("utf-8") + if line_str.startswith("data: "): + try: + data = json_module.loads(line_str[6:]) + events.append(data) + if data.get("type") in ["stream", "answer"]: + full_response += data.get("message", "") or data.get("answer", "") + elif data.get("type") == "end": + break + except json_module.JSONDecodeError: + pass + + self.print_success(f"Received {len(events)} events") + if full_response: + self.print_success(f"Answer preview: {full_response[:100]}...") + self.record_result(test_name, True, "Success") + return True + + except Exception as e: + self.print_error(f"Error: {str(e)}") + self.record_result(test_name, False, str(e)) + return False + + def test_answer_agentic_agent(self) -> bool: + """Test /api/answer endpoint with an agentic agent.""" + test_name = "Answer endpoint (agentic agent)" + + agent_result = self._create_agent_with_type("agentic") + if not agent_result: + if not self.require_auth(test_name): + return True + self.record_result(test_name, True, "Skipped (no agent)") + return True + + agent_id, _ = agent_result + self.print_header(f"Testing {test_name}") + + payload = { + "question": "What is DocsGPT?", + "history": "[]", + "agent_id": agent_id, + } + + try: + self.print_info(f"POST /api/answer with agentic agent_id={agent_id[:8]}...") + + response = self.post("/api/answer", json=payload, timeout=60) + + self.print_info(f"Status Code: {response.status_code}") + + if response.status_code != 200: + self.print_error(f"Expected 200, got {response.status_code}") + self.record_result(test_name, False, f"Status {response.status_code}") + return False + + result = response.json() + answer = result.get("answer", "") + self.print_success(f"Answer received: {answer[:100]}...") + self.record_result(test_name, True, "Success") + return True + + except Exception as e: + self.print_error(f"Error: {str(e)}") + self.record_result(test_name, False, str(e)) + return False + + def test_stream_research_agent(self) -> bool: + """Test /stream endpoint with a research agent. + + Research agents emit additional SSE event types: + - research_plan: the decomposed research steps + - research_progress: per-step status updates + """ + test_name = "Stream endpoint (research agent)" + + agent_result = self._create_agent_with_type("research") + if not agent_result: + if not self.require_auth(test_name): + return True + self.record_result(test_name, True, "Skipped (no agent)") + return True + + agent_id, _ = agent_result + self.print_header(f"Testing {test_name}") + + payload = { + "question": "What is DocsGPT?", + "history": "[]", + "agent_id": agent_id, + } + + try: + self.print_info(f"POST /stream with research agent_id={agent_id[:8]}...") + + response = requests.post( + f"{self.base_url}/stream", + json=payload, + headers=self.headers, + stream=True, + timeout=120, + ) + + self.print_info(f"Status Code: {response.status_code}") + + if response.status_code != 200: + self.print_error(f"Expected 200, got {response.status_code}") + self.record_result(test_name, False, f"Status {response.status_code}") + return False + + events = [] + full_response = "" + saw_plan = False + saw_progress = False + for line in response.iter_lines(): + if line: + line_str = line.decode("utf-8") + if line_str.startswith("data: "): + try: + data = json_module.loads(line_str[6:]) + events.append(data) + event_type = data.get("type", "") + if event_type in ["stream", "answer"]: + full_response += data.get("message", "") or data.get("answer", "") + elif event_type == "research_plan": + saw_plan = True + steps = data.get("data", {}).get("steps", []) + self.print_info(f"Research plan: {len(steps)} steps, complexity={data.get('data', {}).get('complexity')}") + elif event_type == "research_progress": + saw_progress = True + elif event_type == "end": + break + except json_module.JSONDecodeError: + pass + + self.print_success(f"Received {len(events)} events") + if saw_plan: + self.print_success("Received research_plan event") + if saw_progress: + self.print_success("Received research_progress events") + if full_response: + self.print_success(f"Report preview: {full_response[:100]}...") + + self.record_result(test_name, True, "Success") + return True + + except Exception as e: + self.print_error(f"Error: {str(e)}") + self.record_result(test_name, False, str(e)) + return False + + def test_answer_research_agent(self) -> bool: + """Test /api/answer endpoint with a research agent.""" + test_name = "Answer endpoint (research agent)" + + agent_result = self._create_agent_with_type("research") + if not agent_result: + if not self.require_auth(test_name): + return True + self.record_result(test_name, True, "Skipped (no agent)") + return True + + agent_id, _ = agent_result + self.print_header(f"Testing {test_name}") + + payload = { + "question": "What is DocsGPT?", + "history": "[]", + "agent_id": agent_id, + } + + try: + self.print_info(f"POST /api/answer with research agent_id={agent_id[:8]}...") + + response = self.post("/api/answer", json=payload, timeout=120) + + self.print_info(f"Status Code: {response.status_code}") + + if response.status_code != 200: + self.print_error(f"Expected 200, got {response.status_code}") + self.record_result(test_name, False, f"Status {response.status_code}") + return False + + result = response.json() + answer = result.get("answer", "") + self.print_success(f"Answer received: {answer[:100]}...") + self.record_result(test_name, True, "Success") + return True + + except Exception as e: + self.print_error(f"Error: {str(e)}") + self.record_result(test_name, False, str(e)) + return False + + # ------------------------------------------------------------------------- + # Workflow with Agentic Node Tests + # ------------------------------------------------------------------------- + + def _create_workflow_with_agentic_node(self) -> Optional[str]: + """Create a workflow with a single agentic agent node. + + Returns the workflow_id or None on failure. + """ + nodes = [ + { + "id": "start_1", + "type": "start", + "title": "Start", + "data": {}, + }, + { + "id": "agent_1", + "type": "agent", + "title": "Agentic Node", + "data": { + "agent_type": "agentic", + "system_prompt": "You are a helpful assistant.", + "prompt_template": "", + "stream_to_user": True, + "tools": [], + "sources": [], + "chunks": "2", + }, + }, + { + "id": "end_1", + "type": "end", + "title": "End", + "data": {}, + }, + ] + edges = [ + {"id": "edge_1", "source": "start_1", "target": "agent_1"}, + {"id": "edge_2", "source": "agent_1", "target": "end_1"}, + ] + + payload = { + "name": f"Agentic Workflow Test {int(time.time())}", + "nodes": nodes, + "edges": edges, + } + + try: + response = self.post("/api/workflows", json=payload, timeout=15) + if response.status_code in [200, 201]: + result = response.json() + return result.get("id") + except Exception: + pass + + return None + + def _create_workflow_agent(self, workflow_id: str) -> Optional[tuple]: + """Create an agent of type 'workflow' referencing a workflow. + + Returns (agent_id, api_key) or None. + """ + payload = { + "name": f"Workflow Agent Test {int(time.time())}", + "description": "Integration test workflow agent with agentic node", + "prompt_id": "default", + "chunks": 2, + "retriever": "classic", + "agent_type": "workflow", + "workflow": workflow_id, + "status": "draft", + } + + try: + response = self.post("/api/create_agent", json=payload, timeout=10) + if response.status_code in [200, 201]: + result = response.json() + agent_id = result.get("id") + api_key = result.get("key") + if agent_id: + return (agent_id, api_key) + except Exception: + pass + + return None + + def test_stream_workflow_with_agentic_node(self) -> bool: + """Test /stream with a workflow agent that contains an agentic node.""" + test_name = "Stream endpoint (workflow with agentic node)" + + if not self.require_auth(test_name): + return True + + workflow_id = self._create_workflow_with_agentic_node() + if not workflow_id: + self.print_warning("Could not create workflow") + self.record_result(test_name, True, "Skipped (workflow creation failed)") + return True + + agent_result = self._create_workflow_agent(workflow_id) + if not agent_result: + self.print_warning("Could not create workflow agent") + self.record_result(test_name, True, "Skipped (agent creation failed)") + return True + + agent_id, _ = agent_result + self.print_header(f"Testing {test_name}") + + payload = { + "question": "What is DocsGPT?", + "history": "[]", + "agent_id": agent_id, + } + + try: + self.print_info(f"POST /stream with workflow agent_id={agent_id[:8]}...") + self.print_info(f"Workflow ID: {workflow_id}") + + response = requests.post( + f"{self.base_url}/stream", + json=payload, + headers=self.headers, + stream=True, + timeout=60, + ) + + self.print_info(f"Status Code: {response.status_code}") + + if response.status_code != 200: + self.print_error(f"Expected 200, got {response.status_code}") + self.record_result(test_name, False, f"Status {response.status_code}") + return False + + events = [] + full_response = "" + for line in response.iter_lines(): + if line: + line_str = line.decode("utf-8") + if line_str.startswith("data: "): + try: + data = json_module.loads(line_str[6:]) + events.append(data) + if data.get("type") in ["stream", "answer"]: + full_response += data.get("message", "") or data.get("answer", "") + elif data.get("type") == "end": + break + except json_module.JSONDecodeError: + pass + + self.print_success(f"Received {len(events)} events") + if full_response: + self.print_success(f"Answer preview: {full_response[:100]}...") + + self.record_result(test_name, True, "Success") + return True + + except Exception as e: + self.print_error(f"Error: {str(e)}") + self.record_result(test_name, False, str(e)) + return False + + def test_answer_workflow_with_agentic_node(self) -> bool: + """Test /api/answer with a workflow agent that contains an agentic node.""" + test_name = "Answer endpoint (workflow with agentic node)" + + if not self.require_auth(test_name): + return True + + workflow_id = self._create_workflow_with_agentic_node() + if not workflow_id: + self.print_warning("Could not create workflow") + self.record_result(test_name, True, "Skipped (workflow creation failed)") + return True + + agent_result = self._create_workflow_agent(workflow_id) + if not agent_result: + self.print_warning("Could not create workflow agent") + self.record_result(test_name, True, "Skipped (agent creation failed)") + return True + + agent_id, _ = agent_result + self.print_header(f"Testing {test_name}") + + payload = { + "question": "What is DocsGPT?", + "history": "[]", + "agent_id": agent_id, + } + + try: + self.print_info(f"POST /api/answer with workflow agent_id={agent_id[:8]}...") + + response = self.post("/api/answer", json=payload, timeout=60) + + self.print_info(f"Status Code: {response.status_code}") + + if response.status_code != 200: + self.print_error(f"Expected 200, got {response.status_code}") + self.record_result(test_name, False, f"Status {response.status_code}") + return False + + result = response.json() + answer = result.get("answer", "") + self.print_success(f"Answer received: {answer[:100]}...") + self.record_result(test_name, True, "Success") + return True + + except Exception as e: + self.print_error(f"Error: {str(e)}") + self.record_result(test_name, False, str(e)) + return False + # ------------------------------------------------------------------------- # Validation Tests # ------------------------------------------------------------------------- @@ -916,6 +1396,27 @@ This is test documentation for integration tests. self.test_answer_endpoint_with_agent() time.sleep(1) + # Agentic agent tests + self.test_stream_agentic_agent() + time.sleep(1) + + self.test_answer_agentic_agent() + time.sleep(1) + + # Research agent tests + self.test_stream_research_agent() + time.sleep(2) + + self.test_answer_research_agent() + time.sleep(2) + + # Workflow with agentic node tests + self.test_stream_workflow_with_agentic_node() + time.sleep(2) + + self.test_answer_workflow_with_agentic_node() + time.sleep(2) + # API key tests self.test_stream_endpoint_with_api_key() time.sleep(1) From 820ee3a843a94966acaec8c7efa9c5231ec32283 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 22:53:09 +0000 Subject: [PATCH 16/41] fix test --- tests/agents/test_research_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agents/test_research_agent.py b/tests/agents/test_research_agent.py index 3cc1e6d3..30b29ebc 100644 --- a/tests/agents/test_research_agent.py +++ b/tests/agents/test_research_agent.py @@ -2,7 +2,7 @@ import json import time -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest from application.agents.research_agent import ( From f7bfd38b28f247a8727891665b74a448fa0511ca Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 12:52:30 +0000 Subject: [PATCH 17/41] fix: proper fallback handling within agent during stream --- application/agents/base.py | 2 + .../api/answer/services/stream_processor.py | 6 + application/llm/base.py | 99 +++- application/llm/llm_creator.py | 2 + tests/llm/test_fallback.py | 482 ++++++++++++++++++ 5 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 tests/llm/test_fallback.py diff --git a/application/agents/base.py b/application/agents/base.py index 8e6363fc..15735c8c 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -39,6 +39,7 @@ class BaseAgent(ABC): llm=None, llm_handler=None, tool_executor: Optional[ToolExecutor] = None, + backup_models: Optional[List[str]] = None, ): self.endpoint = endpoint self.llm_name = llm_name @@ -63,6 +64,7 @@ class BaseAgent(ABC): decoded_token=decoded_token, model_id=model_id, agent_id=agent_id, + backup_models=backup_models, ) self.retrieved_docs = retrieved_docs or [] diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index f7ee03d6..28a2e8d4 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -444,6 +444,7 @@ class StreamProcessor: "user_api_key": effective_key, "json_schema": data_key.get("json_schema"), "default_model_id": data_key.get("default_model_id", ""), + "models": data_key.get("models", []), } ) @@ -816,6 +817,10 @@ class StreamProcessor: from application.llm.handlers.handler_creator import LLMHandlerCreator from application.agents.tool_executor import ToolExecutor + # Compute backup models: agent's configured models minus the active one + agent_models = self.agent_config.get("models", []) + backup_models = [m for m in agent_models if m != self.model_id] + llm = LLMCreator.create_llm( provider or settings.LLM_PROVIDER, api_key=system_api_key, @@ -823,6 +828,7 @@ class StreamProcessor: decoded_token=self.decoded_token, model_id=self.model_id, agent_id=self.agent_id, + backup_models=backup_models, ) llm_handler = LLMHandlerCreator.create_handler( provider if provider else "default" diff --git a/application/llm/base.py b/application/llm/base.py index 9a0f249e..c8e30b4f 100644 --- a/application/llm/base.py +++ b/application/llm/base.py @@ -16,22 +16,61 @@ class BaseLLM(ABC): agent_id=None, model_id=None, base_url=None, + backup_models=None, ): self.decoded_token = decoded_token self.agent_id = str(agent_id) if agent_id else None self.model_id = model_id self.base_url = base_url self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0} + self._backup_models = backup_models or [] self._fallback_llm = None - self._fallback_sequence_index = 0 @property def fallback_llm(self): - """Lazy-loaded fallback LLM from FALLBACK_* settings.""" - if self._fallback_llm is None and settings.FALLBACK_LLM_PROVIDER: - try: - from application.llm.llm_creator import LLMCreator + """Lazy-loaded fallback LLM: tries per-agent backup models first, + then the global FALLBACK_* settings.""" + if self._fallback_llm is not None: + return self._fallback_llm + from application.llm.llm_creator import LLMCreator + from application.core.model_utils import ( + get_provider_from_model_id, + get_api_key_for_provider, + ) + + # Try per-agent backup models first + for backup_model_id in self._backup_models: + try: + provider = get_provider_from_model_id(backup_model_id) + if not provider: + logger.warning( + f"Could not resolve provider for backup model: {backup_model_id}" + ) + continue + api_key = get_api_key_for_provider(provider) + self._fallback_llm = LLMCreator.create_llm( + provider, + api_key=api_key, + user_api_key=getattr(self, "user_api_key", None), + decoded_token=self.decoded_token, + model_id=backup_model_id, + agent_id=self.agent_id, + ) + logger.info( + f"Fallback LLM initialized from agent backup model: " + f"{provider}/{backup_model_id}" + ) + return self._fallback_llm + except Exception as e: + logger.warning( + f"Failed to initialize backup model {backup_model_id}: {str(e)}" + ) + continue + + # Fall back to global FALLBACK_* settings + if settings.FALLBACK_LLM_PROVIDER: + try: self._fallback_llm = LLMCreator.create_llm( settings.FALLBACK_LLM_PROVIDER, api_key=settings.FALLBACK_LLM_API_KEY or settings.API_KEY, @@ -41,12 +80,14 @@ class BaseLLM(ABC): agent_id=self.agent_id, ) logger.info( - f"Fallback LLM initialized: {settings.FALLBACK_LLM_PROVIDER}/{settings.FALLBACK_LLM_NAME}" + f"Fallback LLM initialized from global settings: " + f"{settings.FALLBACK_LLM_PROVIDER}/{settings.FALLBACK_LLM_NAME}" ) except Exception as e: logger.error( f"Failed to initialize fallback LLM: {str(e)}", exc_info=True ) + return self._fallback_llm @staticmethod @@ -74,20 +115,60 @@ class BaseLLM(ABC): method = decorator(method) return method(self, *args, **kwargs) + is_stream = "stream" in method_name + + if is_stream: + return self._stream_with_fallback( + decorated_method, method_name, *args, **kwargs + ) + try: return decorated_method() except Exception as e: if not self.fallback_llm: logger.error(f"Primary LLM failed and no fallback configured: {str(e)}") raise + fallback = self.fallback_llm logger.warning( - f"Primary LLM failed. Falling back to {settings.FALLBACK_LLM_PROVIDER}/{settings.FALLBACK_LLM_NAME}. Error: {str(e)}" + f"Primary LLM failed. Falling back to " + f"{fallback.model_id}. Error: {str(e)}" ) fallback_method = getattr( - self.fallback_llm, method_name.replace("_raw_", "") + fallback, method_name.replace("_raw_", "") ) - return fallback_method(*args, **kwargs) + fallback_kwargs = {**kwargs, "model": fallback.model_id} + return fallback_method(*args, **fallback_kwargs) + + def _stream_with_fallback( + self, decorated_method, method_name, *args, **kwargs + ): + """ + Wrapper generator that catches mid-stream errors and falls back. + + Unlike non-streaming calls where exceptions are raised immediately, + streaming generators raise exceptions during iteration. This wrapper + ensures that if the primary LLM fails at any point during streaming + (creation or mid-stream), we fall back to the backup model. + """ + try: + yield from decorated_method() + except Exception as e: + if not self.fallback_llm: + logger.error( + f"Primary LLM failed and no fallback configured: {str(e)}" + ) + raise + fallback = self.fallback_llm + logger.warning( + f"Primary LLM failed mid-stream. Falling back to " + f"{fallback.model_id}. Error: {str(e)}" + ) + fallback_method = getattr( + fallback, method_name.replace("_raw_", "") + ) + fallback_kwargs = {**kwargs, "model": fallback.model_id} + yield from fallback_method(*args, **fallback_kwargs) def gen(self, model, messages, stream=False, tools=None, *args, **kwargs): decorators = [gen_token_usage, gen_cache] diff --git a/application/llm/llm_creator.py b/application/llm/llm_creator.py index 9f444359..d01fa2d8 100644 --- a/application/llm/llm_creator.py +++ b/application/llm/llm_creator.py @@ -38,6 +38,7 @@ class LLMCreator: decoded_token, model_id=None, agent_id=None, + backup_models=None, *args, **kwargs, ): @@ -59,6 +60,7 @@ class LLMCreator: model_id=model_id, agent_id=agent_id, base_url=base_url, + backup_models=backup_models, *args, **kwargs, ) diff --git a/tests/llm/test_fallback.py b/tests/llm/test_fallback.py new file mode 100644 index 00000000..510962ca --- /dev/null +++ b/tests/llm/test_fallback.py @@ -0,0 +1,482 @@ +"""Integration tests for LLM fallback behaviour. + +Verifies that when a primary model fails (immediately or mid-stream), the +per-agent backup model is used before the global FALLBACK_* settings. +""" + +from unittest.mock import MagicMock + +import pytest + +from application.llm.base import BaseLLM + + +# --------------------------------------------------------------------------- +# Concrete LLM stubs +# --------------------------------------------------------------------------- + + +class FakeLLM(BaseLLM): + """Minimal concrete BaseLLM for testing.""" + + def __init__(self, responses=None, stream_chunks=None, fail_at=None, **kwargs): + # Accept and discard api_key / user_api_key so LLMCreator.create_llm + # signatures work without errors. + kwargs.pop("api_key", None) + kwargs.pop("user_api_key", None) + super().__init__(**kwargs) + self.responses = responses or ["fake response"] + self.stream_chunks = stream_chunks or ["chunk1", "chunk2"] + self.fail_at = fail_at # None = no failure, 0 = immediate, N = after N chunks + self.user_api_key = None + self.gen_called = False + self.gen_stream_called = False + self.last_model_received = None # tracks the model kwarg passed to gen/gen_stream + + def _raw_gen(self, baseself, model, messages, stream, tools=None, **kwargs): + if self.fail_at is not None: + raise RuntimeError("primary model unavailable") + return self.responses[0] + + def _raw_gen_stream(self, baseself, model, messages, stream, tools=None, **kwargs): + yielded = 0 + for chunk in self.stream_chunks: + if self.fail_at is not None and yielded >= self.fail_at: + raise RuntimeError("mid-stream failure") + yield chunk + yielded += 1 + + # Wrap gen/gen_stream so we can track whether the fallback instance was used + # and which model kwarg it received + def gen(self, *args, **kwargs): + self.gen_called = True + self.last_model_received = kwargs.get("model") + return super().gen(*args, **kwargs) + + def gen_stream(self, *args, **kwargs): + self.gen_stream_called = True + self.last_model_received = kwargs.get("model") + return super().gen_stream(*args, **kwargs) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _noop_decorator(func): + """Pass-through decorator that replaces cache / token-usage wrappers.""" + + def wrapper(self_llm, model, messages, stream, tools=None, **kwargs): + return func(self_llm, model, messages, stream, tools, **kwargs) + + return wrapper + + +def _noop_stream_decorator(func): + """Pass-through generator decorator for streaming wrappers.""" + + def wrapper(self_llm, model, messages, stream, tools=None, **kwargs): + yield from func(self_llm, model, messages, stream, tools, **kwargs) + + return wrapper + + +@pytest.fixture(autouse=True) +def _patch_decorators(monkeypatch): + """Replace cache & token-usage decorators with no-ops so tests focus on + fallback logic without needing Redis or token-counting infra.""" + monkeypatch.setattr("application.llm.base.gen_cache", _noop_decorator) + monkeypatch.setattr("application.llm.base.gen_token_usage", _noop_decorator) + monkeypatch.setattr("application.llm.base.stream_cache", _noop_stream_decorator) + monkeypatch.setattr( + "application.llm.base.stream_token_usage", _noop_stream_decorator + ) + + +@pytest.fixture +def patch_model_utils(monkeypatch): + """Patch model_utils functions used by fallback_llm property.""" + + def _apply(get_provider=None, get_api_key=None, create_llm=None): + if get_provider: + monkeypatch.setattr( + "application.core.model_utils.get_provider_from_model_id", + get_provider, + ) + if get_api_key: + monkeypatch.setattr( + "application.core.model_utils.get_api_key_for_provider", + get_api_key, + ) + if create_llm: + monkeypatch.setattr( + "application.llm.llm_creator.LLMCreator.create_llm", + create_llm, + ) + + return _apply + + +CALL_ARGS = dict(model="test-model", messages=[{"role": "user", "content": "hi"}]) + + +# --------------------------------------------------------------------------- +# Tests — fallback_llm property resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestFallbackLLMResolution: + + def test_backup_model_preferred_over_global_fallback(self, patch_model_utils): + """When agent has backup models configured, the first valid one is used + as fallback — not the global FALLBACK_* settings.""" + backup_llm = FakeLLM(responses=["backup response"]) + + patch_model_utils( + get_provider=lambda mid: "openai", + get_api_key=lambda prov: "fake-key", + create_llm=lambda type, **kw: backup_llm, + ) + + primary = FakeLLM(backup_models=["backup-model-id"]) + fallback = primary.fallback_llm + + assert fallback is backup_llm + + def test_global_fallback_used_when_no_backup_models( + self, monkeypatch, patch_model_utils + ): + """When no per-agent backup models exist, global FALLBACK_* is used.""" + global_fallback = FakeLLM(responses=["global fallback"]) + + patch_model_utils( + create_llm=lambda type, **kw: global_fallback, + ) + monkeypatch.setattr( + "application.llm.base.settings", + MagicMock( + FALLBACK_LLM_PROVIDER="openai", + FALLBACK_LLM_NAME="gpt-4o", + FALLBACK_LLM_API_KEY="key", + API_KEY="key", + ), + ) + + primary = FakeLLM(backup_models=[]) + fallback = primary.fallback_llm + + assert fallback is global_fallback + + def test_skips_unresolvable_backup_model_tries_next(self, patch_model_utils): + """If the first backup model can't be resolved, skip it and try the next.""" + good_backup = FakeLLM(responses=["good backup"]) + call_count = {"n": 0} + + def fake_get_provider(model_id): + call_count["n"] += 1 + if model_id == "bad-model": + return None # unresolvable + return "openai" + + patch_model_utils( + get_provider=fake_get_provider, + get_api_key=lambda prov: "key", + create_llm=lambda type, **kw: good_backup, + ) + + primary = FakeLLM(backup_models=["bad-model", "good-model"]) + fallback = primary.fallback_llm + + assert fallback is good_backup + assert call_count["n"] == 2 # tried both + + def test_no_fallback_when_nothing_configured(self, monkeypatch): + """No backup models + no global FALLBACK_* → fallback_llm is None.""" + monkeypatch.setattr( + "application.llm.base.settings", + MagicMock(FALLBACK_LLM_PROVIDER=None), + ) + primary = FakeLLM(backup_models=[]) + assert primary.fallback_llm is None + + +# --------------------------------------------------------------------------- +# Tests — non-streaming fallback (gen) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestNonStreamingFallback: + + def test_primary_success_no_fallback(self): + """When primary succeeds, fallback is never touched.""" + primary = FakeLLM(responses=["primary ok"]) + result = primary.gen(**CALL_ARGS) + assert result == "primary ok" + + def test_primary_fails_uses_backup_model(self, patch_model_utils): + """Primary fails immediately → backup model from agent config is used.""" + backup = FakeLLM(responses=["backup ok"]) + + patch_model_utils( + get_provider=lambda mid: "openai", + get_api_key=lambda p: "k", + create_llm=lambda type, **kw: backup, + ) + + primary = FakeLLM(fail_at=0, backup_models=["backup-model"]) + result = primary.gen(**CALL_ARGS) + assert result == "backup ok" + assert backup.gen_called + + def test_no_fallback_raises(self, monkeypatch): + """Primary fails and no fallback configured → exception propagates.""" + monkeypatch.setattr( + "application.llm.base.settings", + MagicMock(FALLBACK_LLM_PROVIDER=None), + ) + primary = FakeLLM(fail_at=0, backup_models=[]) + with pytest.raises(RuntimeError, match="primary model unavailable"): + primary.gen(**CALL_ARGS) + + +# --------------------------------------------------------------------------- +# Tests — streaming fallback (gen_stream) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestStreamingFallback: + + def test_stream_primary_success(self): + """Full stream completes without triggering fallback.""" + primary = FakeLLM(stream_chunks=["a", "b", "c"]) + chunks = list(primary.gen_stream(**CALL_ARGS)) + assert chunks == ["a", "b", "c"] + + def test_stream_immediate_failure_uses_backup(self, patch_model_utils): + """Primary fails before yielding anything → entire backup stream returned.""" + backup = FakeLLM(stream_chunks=["fallback1", "fallback2"]) + + patch_model_utils( + get_provider=lambda m: "openai", + get_api_key=lambda p: "k", + create_llm=lambda type, **kw: backup, + ) + + primary = FakeLLM( + stream_chunks=["x", "y"], + fail_at=0, # fail before first chunk + backup_models=["backup-model"], + ) + chunks = list(primary.gen_stream(**CALL_ARGS)) + assert chunks == ["fallback1", "fallback2"] + assert backup.gen_stream_called + + def test_stream_mid_stream_failure_uses_backup(self, patch_model_utils): + """Primary yields some chunks then fails → backup stream follows partial output.""" + backup = FakeLLM(stream_chunks=["recovery1", "recovery2"]) + + patch_model_utils( + get_provider=lambda m: "openai", + get_api_key=lambda p: "k", + create_llm=lambda type, **kw: backup, + ) + + primary = FakeLLM( + stream_chunks=["ok1", "ok2", "ok3"], + fail_at=2, # yields ok1, ok2, then fails before ok3 + backup_models=["backup-model"], + ) + chunks = list(primary.gen_stream(**CALL_ARGS)) + # First two from primary, then full backup stream + assert chunks == ["ok1", "ok2", "recovery1", "recovery2"] + + def test_stream_no_fallback_raises(self, monkeypatch): + """Primary stream fails and no fallback → exception propagates.""" + monkeypatch.setattr( + "application.llm.base.settings", + MagicMock(FALLBACK_LLM_PROVIDER=None), + ) + primary = FakeLLM(stream_chunks=["x"], fail_at=0, backup_models=[]) + with pytest.raises(RuntimeError, match="mid-stream failure"): + list(primary.gen_stream(**CALL_ARGS)) + + +# --------------------------------------------------------------------------- +# Tests — backup model priority over global fallback +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestBackupModelPriority: + + def test_agent_backup_tried_before_global_on_gen_failure(self, patch_model_utils): + """On gen() failure, agent's backup model is used — not the global fallback.""" + backup = FakeLLM(responses=["agent backup"]) + created_models = [] + + def fake_create_llm(type, **kw): + created_models.append(kw.get("model_id")) + return backup + + patch_model_utils( + get_provider=lambda m: "openai", + get_api_key=lambda p: "k", + create_llm=fake_create_llm, + ) + + primary = FakeLLM(fail_at=0, backup_models=["agent-backup-model"]) + result = primary.gen(**CALL_ARGS) + + assert result == "agent backup" + assert "agent-backup-model" in created_models + + def test_agent_backup_tried_before_global_on_stream_failure( + self, patch_model_utils + ): + """On gen_stream() failure, agent's backup model is used — not the global.""" + backup = FakeLLM(stream_chunks=["agent-stream"]) + created_models = [] + + def fake_create_llm(type, **kw): + created_models.append(kw.get("model_id")) + return backup + + patch_model_utils( + get_provider=lambda m: "openai", + get_api_key=lambda p: "k", + create_llm=fake_create_llm, + ) + + primary = FakeLLM( + stream_chunks=["x"], fail_at=0, backup_models=["agent-backup-model"] + ) + chunks = list(primary.gen_stream(**CALL_ARGS)) + + assert chunks == ["agent-stream"] + assert "agent-backup-model" in created_models + + def test_global_fallback_used_when_all_backup_models_fail( + self, monkeypatch, patch_model_utils + ): + """If every agent backup model fails to initialize, fall through to global.""" + global_fallback = FakeLLM(responses=["global ok"]) + call_order = [] + + def fake_get_provider(mid): + if mid == "broken-backup": + return "nonexistent_provider" + return "openai" + + def fake_create_llm(type, **kw): + model_id = kw.get("model_id") + call_order.append(model_id) + if model_id == "broken-backup": + raise ValueError("provider init failed") + return global_fallback + + patch_model_utils( + get_provider=fake_get_provider, + get_api_key=lambda p: "k", + create_llm=fake_create_llm, + ) + monkeypatch.setattr( + "application.llm.base.settings", + MagicMock( + FALLBACK_LLM_PROVIDER="openai", + FALLBACK_LLM_NAME="global-model", + FALLBACK_LLM_API_KEY="gk", + API_KEY="gk", + ), + ) + + primary = FakeLLM(fail_at=0, backup_models=["broken-backup"]) + result = primary.gen(**CALL_ARGS) + + assert result == "global ok" + # Tried broken-backup first, then fell through to global-model + assert call_order == ["broken-backup", "global-model"] + + +# --------------------------------------------------------------------------- +# Tests — fallback uses its own model_id, not the primary's +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestFallbackModelIdOverride: + """The fallback LLM must be called with its own model_id — not the + primary's. Otherwise providers like Groq receive an unknown model name + (e.g. a Qwen model_id) and return 404.""" + + def test_gen_fallback_receives_own_model_id(self, patch_model_utils): + """Non-streaming: fallback.gen() is called with fallback.model_id.""" + backup = FakeLLM( + responses=["backup ok"], model_id="groq-gpt-oss-120b" + ) + + patch_model_utils( + get_provider=lambda m: "groq", + get_api_key=lambda p: "k", + create_llm=lambda type, **kw: backup, + ) + + primary = FakeLLM( + fail_at=0, + model_id="qwen/qwen3-4b-2507", + backup_models=["groq-gpt-oss-120b"], + ) + result = primary.gen(**CALL_ARGS) + + assert result == "backup ok" + assert backup.last_model_received == "groq-gpt-oss-120b" + + def test_gen_stream_fallback_receives_own_model_id(self, patch_model_utils): + """Streaming: fallback.gen_stream() is called with fallback.model_id.""" + backup = FakeLLM( + stream_chunks=["ok"], model_id="groq-gpt-oss-120b" + ) + + patch_model_utils( + get_provider=lambda m: "groq", + get_api_key=lambda p: "k", + create_llm=lambda type, **kw: backup, + ) + + primary = FakeLLM( + stream_chunks=["x"], + fail_at=0, + model_id="qwen/qwen3-4b-2507", + backup_models=["groq-gpt-oss-120b"], + ) + chunks = list(primary.gen_stream(**CALL_ARGS)) + + assert chunks == ["ok"] + assert backup.last_model_received == "groq-gpt-oss-120b" + + def test_mid_stream_fallback_receives_own_model_id(self, patch_model_utils): + """Mid-stream failure: fallback still gets its own model_id, not the + primary's that was already partially streaming.""" + backup = FakeLLM( + stream_chunks=["recovered"], model_id="groq-gpt-oss-120b" + ) + + patch_model_utils( + get_provider=lambda m: "groq", + get_api_key=lambda p: "k", + create_llm=lambda type, **kw: backup, + ) + + primary = FakeLLM( + stream_chunks=["partial1", "partial2", "boom"], + fail_at=2, + model_id="qwen/qwen3-4b-2507", + backup_models=["groq-gpt-oss-120b"], + ) + chunks = list(primary.gen_stream(**CALL_ARGS)) + + assert chunks == ["partial1", "partial2", "recovered"] + assert backup.last_model_received == "groq-gpt-oss-120b" From 15d4e9dbf5c46594e8bfa4179e6526788549986d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 13:08:22 +0000 Subject: [PATCH 18/41] fix: strict json in openai base --- application/llm/openai.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/application/llm/openai.py b/application/llm/openai.py index d3ece394..8bfc3cef 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -101,9 +101,13 @@ class OpenAILLM(BaseLLM): for item in content: if "function_call" in item: # Function calls need their own message - cleaned_args = self._remove_null_values( - item["function_call"]["args"] - ) + args = item["function_call"]["args"] + if isinstance(args, str): + try: + args = json.loads(args) + except (json.JSONDecodeError, TypeError): + pass + cleaned_args = self._remove_null_values(args) tool_call = { "id": item["function_call"]["call_id"], "type": "function", From 7c1decbcc3c20e227505d862511ea7db7d2b025c Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 13:39:09 +0000 Subject: [PATCH 19/41] fix: npm publish --- .github/workflows/npm-publish.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a930d9f6..5d2bb09b 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -10,6 +10,10 @@ on: required: true default: patch +permissions: + contents: write + pull-requests: write + jobs: publish: runs-on: ubuntu-latest @@ -88,10 +92,23 @@ jobs: package.json > _tmp.json && mv _tmp.json package.json npm install --package-lock-only - - name: Commit version bump + - name: Commit version bump and create PR run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + BRANCH="chore/bump-npm-v${{ steps.version_docsgpt.outputs.version }}" + git checkout -b "$BRANCH" git add package.json package-lock.json git commit -m "chore: bump npm libraries to v${{ steps.version_docsgpt.outputs.version }}" - git push + git push origin "$BRANCH" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create PR + run: | + gh pr create \ + --title "chore: bump npm libraries to v${{ steps.version_docsgpt.outputs.version }}" \ + --body "Automated version bump after npm publish." \ + --base main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From ee6530fe00ed9cd1376680731140402af70b441a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 13:42:51 +0000 Subject: [PATCH 20/41] chore: bump npm --- extensions/react-widget/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index 072a700f..05a3f41c 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -1,6 +1,6 @@ { "name": "docsgpt", - "version": "0.5.1", + "version": "0.6.0", "private": false, "description": "DocsGPT 🦖 is an innovative open-source tool designed to simplify the retrieval of information from project documentation using advanced GPT models 🤖.", "source": "./src/index.html", From 4056278fef34b852555be2eb3f78008771a6f2cb Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 13:48:05 +0000 Subject: [PATCH 21/41] chore: bump npm --- extensions/react-widget/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index 05a3f41c..7a9b80df 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -1,6 +1,6 @@ { "name": "docsgpt", - "version": "0.6.0", + "version": "0.6.1", "private": false, "description": "DocsGPT 🦖 is an innovative open-source tool designed to simplify the retrieval of information from project documentation using advanced GPT models 🤖.", "source": "./src/index.html", From 5478e4234c276a8550964bd195504ebb0f60c44b Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 14:06:05 +0000 Subject: [PATCH 22/41] chore: bump npm again --- application/agents/tool_executor.py | 14 ++++++++++---- extensions/react-widget/package.json | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py index 0fde060d..69739076 100644 --- a/application/agents/tool_executor.py +++ b/application/agents/tool_executor.py @@ -185,7 +185,10 @@ class ToolExecutor: target_dict[param] = value # Load tool (with caching) - tool = self._get_or_load_tool(tool_data, tool_id, action_name) + tool = self._get_or_load_tool( + tool_data, tool_id, action_name, + headers=headers, query_params=query_params, + ) resolved_arguments = ( {"query_params": query_params, "headers": headers, "body": body} @@ -238,7 +241,10 @@ class ToolExecutor: return result, call_id - def _get_or_load_tool(self, tool_data: Dict, tool_id: str, action_name: str): + def _get_or_load_tool( + self, tool_data: Dict, tool_id: str, action_name: str, + headers: Optional[Dict] = None, query_params: Optional[Dict] = None, + ): """Load a tool, using cache when possible.""" cache_key = f"{tool_data['name']}:{tool_id}:{self.user or ''}" if cache_key in self._loaded_tools: @@ -251,8 +257,8 @@ class ToolExecutor: tool_config = { "url": action_config["url"], "method": action_config["method"], - "headers": {}, - "query_params": {}, + "headers": headers or {}, + "query_params": query_params or {}, } if "body_content_type" in action_config: tool_config["body_content_type"] = action_config.get( diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index 7a9b80df..588c825e 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -1,6 +1,6 @@ { "name": "docsgpt", - "version": "0.6.1", + "version": "0.6.2", "private": false, "description": "DocsGPT 🦖 is an innovative open-source tool designed to simplify the retrieval of information from project documentation using advanced GPT models 🤖.", "source": "./src/index.html", From 6d81a15e978f2c40bab3fc12d4db8c0e3b96f1e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Mar 2026 14:16:32 +0000 Subject: [PATCH 23/41] chore: bump npm libraries to v0.6.3 --- extensions/react-widget/package-lock.json | 4 ++-- extensions/react-widget/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/react-widget/package-lock.json b/extensions/react-widget/package-lock.json index 027e6a13..59afc9f1 100644 --- a/extensions/react-widget/package-lock.json +++ b/extensions/react-widget/package-lock.json @@ -1,12 +1,12 @@ { "name": "docsgpt", - "version": "0.5.1", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docsgpt", - "version": "0.5.1", + "version": "0.6.3", "license": "Apache-2.0", "dependencies": { "@babel/plugin-transform-flow-strip-types": "^7.23.3", diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index 588c825e..dde9335d 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -1,6 +1,6 @@ { "name": "docsgpt", - "version": "0.6.2", + "version": "0.6.3", "private": false, "description": "DocsGPT 🦖 is an innovative open-source tool designed to simplify the retrieval of information from project documentation using advanced GPT models 🤖.", "source": "./src/index.html", From 5f0d39e5f11f93dda4610a72897a6e981522c720 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 18:42:59 +0000 Subject: [PATCH 24/41] tests: vectors --- tests/vectorstore/__init__.py | 0 tests/vectorstore/test_base.py | 362 +++++++++++++++++++++ tests/vectorstore/test_document_class.py | 39 +++ tests/vectorstore/test_elasticsearch.py | 292 +++++++++++++++++ tests/vectorstore/test_embeddings_local.py | 140 ++++++++ tests/vectorstore/test_faiss.py | 361 ++++++++++++++++++++ tests/vectorstore/test_lancedb.py | 312 ++++++++++++++++++ tests/vectorstore/test_milvus.py | 95 ++++++ tests/vectorstore/test_mongodb.py | 259 +++++++++++++++ tests/vectorstore/test_pgvector.py | 297 +++++++++++++++++ tests/vectorstore/test_qdrant.py | 230 +++++++++++++ tests/vectorstore/test_vector_creator.py | 39 +++ 12 files changed, 2426 insertions(+) create mode 100644 tests/vectorstore/__init__.py create mode 100644 tests/vectorstore/test_base.py create mode 100644 tests/vectorstore/test_document_class.py create mode 100644 tests/vectorstore/test_elasticsearch.py create mode 100644 tests/vectorstore/test_embeddings_local.py create mode 100644 tests/vectorstore/test_faiss.py create mode 100644 tests/vectorstore/test_lancedb.py create mode 100644 tests/vectorstore/test_milvus.py create mode 100644 tests/vectorstore/test_mongodb.py create mode 100644 tests/vectorstore/test_pgvector.py create mode 100644 tests/vectorstore/test_qdrant.py create mode 100644 tests/vectorstore/test_vector_creator.py diff --git a/tests/vectorstore/__init__.py b/tests/vectorstore/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/vectorstore/test_base.py b/tests/vectorstore/test_base.py new file mode 100644 index 00000000..fc8eb544 --- /dev/null +++ b/tests/vectorstore/test_base.py @@ -0,0 +1,362 @@ +import os +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from application.vectorstore.base import ( + BaseVectorStore, + EmbeddingsSingleton, + RemoteEmbeddings, +) + + +# --- RemoteEmbeddings --- + + +@pytest.mark.unit +class TestRemoteEmbeddings: + def test_init_sets_url_and_headers(self): + emb = RemoteEmbeddings( + api_url="http://localhost:8080/", model_name="model-v1", api_key="sk-key" + ) + assert emb.api_url == "http://localhost:8080" + assert emb.model_name == "model-v1" + assert emb.headers["Authorization"] == "Bearer sk-key" + + def test_init_no_api_key(self): + emb = RemoteEmbeddings(api_url="http://host", model_name="m") + assert "Authorization" not in emb.headers + + @patch("application.vectorstore.base.requests.post") + def test_embed_sends_correct_payload(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = { + "data": [{"index": 0, "embedding": [0.1, 0.2]}] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "model-v1") + result = emb._embed("test input") + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert call_kwargs[1]["json"]["input"] == "test input" + assert call_kwargs[1]["json"]["model"] == "model-v1" + assert result == [[0.1, 0.2]] + + @patch("application.vectorstore.base.requests.post") + def test_embed_sorts_by_index(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = { + "data": [ + {"index": 1, "embedding": [0.3, 0.4]}, + {"index": 0, "embedding": [0.1, 0.2]}, + ] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + result = emb._embed(["a", "b"]) + assert result == [[0.1, 0.2], [0.3, 0.4]] + + @patch("application.vectorstore.base.requests.post") + def test_embed_raises_on_error_response(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = {"error": "rate limit exceeded"} + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + with pytest.raises(ValueError, match="rate limit exceeded"): + emb._embed("test") + + @patch("application.vectorstore.base.requests.post") + def test_embed_raises_on_unexpected_format(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = {"unexpected": True} + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + with pytest.raises(ValueError, match="Unexpected response format"): + emb._embed("test") + + @patch("application.vectorstore.base.requests.post") + def test_embed_raises_on_non_dict_response(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = [1, 2, 3] + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + with pytest.raises(ValueError, match="Unexpected response format"): + emb._embed("test") + + @patch("application.vectorstore.base.requests.post") + def test_embed_query(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = { + "data": [{"index": 0, "embedding": [0.1, 0.2, 0.3]}] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + emb.dimension = None # Reset so it gets set from response + result = emb.embed_query("hello") + assert result == [0.1, 0.2, 0.3] + assert emb.dimension == 3 + + @patch("application.vectorstore.base.requests.post") + def test_embed_query_raises_on_bad_structure(self, mock_post): + mock_resp = Mock() + # Return multiple embeddings for a single query + mock_resp.json.return_value = { + "data": [ + {"index": 0, "embedding": [0.1]}, + {"index": 1, "embedding": [0.2]}, + ] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + with pytest.raises(ValueError, match="Unexpected result structure"): + emb.embed_query("hello") + + @patch("application.vectorstore.base.requests.post") + def test_embed_documents(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = { + "data": [ + {"index": 0, "embedding": [0.1, 0.2]}, + {"index": 1, "embedding": [0.3, 0.4]}, + ] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + emb.dimension = None # Reset so it gets set from response + result = emb.embed_documents(["doc1", "doc2"]) + assert result == [[0.1, 0.2], [0.3, 0.4]] + assert emb.dimension == 2 + + def test_embed_documents_empty(self): + emb = RemoteEmbeddings("http://host", "m") + assert emb.embed_documents([]) == [] + + @patch("application.vectorstore.base.requests.post") + def test_call_with_string(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = { + "data": [{"index": 0, "embedding": [0.5]}] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + result = emb("hello") + assert result == [0.5] + + @patch("application.vectorstore.base.requests.post") + def test_call_with_list(self, mock_post): + mock_resp = Mock() + mock_resp.json.return_value = { + "data": [{"index": 0, "embedding": [0.5]}] + } + mock_resp.raise_for_status = Mock() + mock_post.return_value = mock_resp + + emb = RemoteEmbeddings("http://host", "m") + result = emb(["hello"]) + assert result == [[0.5]] + + def test_call_with_invalid_type(self): + emb = RemoteEmbeddings("http://host", "m") + with pytest.raises(ValueError, match="Input must be a string or a list"): + emb(123) + + +# --- EmbeddingsSingleton --- + + +@pytest.mark.unit +class TestEmbeddingsSingleton: + def setup_method(self): + EmbeddingsSingleton._instances = {} + + @patch("application.vectorstore.base.OpenAIEmbeddings") + def test_get_instance_openai(self, mock_openai_cls): + mock_instance = Mock() + mock_openai_cls.return_value = mock_instance + + result = EmbeddingsSingleton.get_instance("openai_text-embedding-ada-002") + assert result is mock_instance + + @patch("application.vectorstore.base.OpenAIEmbeddings") + def test_singleton_returns_same_instance(self, mock_openai_cls): + mock_instance = Mock() + mock_openai_cls.return_value = mock_instance + + r1 = EmbeddingsSingleton.get_instance("openai_text-embedding-ada-002") + r2 = EmbeddingsSingleton.get_instance("openai_text-embedding-ada-002") + assert r1 is r2 + mock_openai_cls.assert_called_once() + + @patch("application.vectorstore.base._get_embeddings_wrapper") + def test_get_instance_huggingface(self, mock_get_wrapper): + mock_wrapper_cls = Mock() + mock_instance = Mock() + mock_wrapper_cls.return_value = mock_instance + mock_get_wrapper.return_value = mock_wrapper_cls + + result = EmbeddingsSingleton.get_instance( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + assert result is mock_instance + + @patch("application.vectorstore.base._get_embeddings_wrapper") + def test_get_instance_unknown_falls_back_to_wrapper(self, mock_get_wrapper): + mock_wrapper_cls = Mock() + mock_instance = Mock() + mock_wrapper_cls.return_value = mock_instance + mock_get_wrapper.return_value = mock_wrapper_cls + + result = EmbeddingsSingleton.get_instance("custom_model_name") + mock_wrapper_cls.assert_called_once_with("custom_model_name") + assert result is mock_instance + + +# --- BaseVectorStore --- + + +class ConcreteVectorStore(BaseVectorStore): + """Concrete implementation for testing base class methods.""" + + def search(self, *args, **kwargs): + return [] + + def add_texts(self, texts, metadatas=None, *args, **kwargs): + return [] + + +@pytest.mark.unit +class TestBaseVectorStore: + def setup_method(self): + EmbeddingsSingleton._instances = {} + + def test_default_methods_are_noop(self): + store = ConcreteVectorStore() + assert store.delete_index() is None + assert store.save_local() is None + assert store.get_chunks() is None + assert store.add_chunk("text") is None + assert store.delete_chunk("id") is None + + @patch("application.vectorstore.base.settings") + def test_is_azure_configured_true(self, mock_settings): + mock_settings.OPENAI_API_BASE = "https://azure.openai.com" + mock_settings.OPENAI_API_VERSION = "2023-05-15" + mock_settings.AZURE_DEPLOYMENT_NAME = "my-deploy" + + store = ConcreteVectorStore() + assert store.is_azure_configured() + + @patch("application.vectorstore.base.settings") + def test_is_azure_configured_false(self, mock_settings): + mock_settings.OPENAI_API_BASE = None + mock_settings.OPENAI_API_VERSION = None + mock_settings.AZURE_DEPLOYMENT_NAME = None + + store = ConcreteVectorStore() + assert not store.is_azure_configured() + + @patch("application.vectorstore.base.settings") + def test_get_embeddings_remote(self, mock_settings): + mock_settings.EMBEDDINGS_BASE_URL = "http://remote:8080" + + store = ConcreteVectorStore() + result = store._get_embeddings("model-name", "api-key") + + assert isinstance(result, RemoteEmbeddings) + assert result.api_url == "http://remote:8080" + + @patch("application.vectorstore.base.settings") + @patch("application.vectorstore.base.EmbeddingsSingleton.get_instance") + def test_get_embeddings_openai(self, mock_get_instance, mock_settings): + mock_settings.EMBEDDINGS_BASE_URL = None + mock_settings.OPENAI_API_BASE = None + mock_settings.OPENAI_API_VERSION = None + mock_settings.AZURE_DEPLOYMENT_NAME = None + + mock_emb = Mock() + mock_get_instance.return_value = mock_emb + + store = ConcreteVectorStore() + result = store._get_embeddings("openai_text-embedding-ada-002", "sk-key") + assert result is mock_emb + + @patch("application.vectorstore.base.settings") + @patch("application.vectorstore.base.EmbeddingsSingleton.get_instance") + def test_get_embeddings_openai_azure(self, mock_get_instance, mock_settings): + mock_settings.EMBEDDINGS_BASE_URL = None + mock_settings.OPENAI_API_BASE = "https://azure.openai.com" + mock_settings.OPENAI_API_VERSION = "2023-05-15" + mock_settings.AZURE_DEPLOYMENT_NAME = "deploy" + mock_settings.AZURE_EMBEDDINGS_DEPLOYMENT_NAME = "embed-deploy" + + mock_emb = Mock() + mock_get_instance.return_value = mock_emb + + store = ConcreteVectorStore() + result = store._get_embeddings("openai_text-embedding-ada-002", "sk-key") + assert result is mock_emb + + @patch("application.vectorstore.base.settings") + @patch("application.vectorstore.base.EmbeddingsSingleton.get_instance") + @patch("os.path.exists", return_value=False) + def test_get_embeddings_huggingface_no_local_model( + self, mock_exists, mock_get_instance, mock_settings + ): + mock_settings.EMBEDDINGS_BASE_URL = None + mock_emb = Mock() + mock_get_instance.return_value = mock_emb + + store = ConcreteVectorStore() + result = store._get_embeddings( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + assert result is mock_emb + + @patch("application.vectorstore.base.settings") + @patch("application.vectorstore.base.EmbeddingsSingleton.get_instance") + @patch("os.path.exists") + def test_get_embeddings_huggingface_local_model( + self, mock_exists, mock_get_instance, mock_settings + ): + mock_settings.EMBEDDINGS_BASE_URL = None + mock_exists.side_effect = lambda p: p == "/app/models/all-mpnet-base-v2" + mock_emb = Mock() + mock_get_instance.return_value = mock_emb + + store = ConcreteVectorStore() + result = store._get_embeddings( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + assert result is mock_emb + mock_get_instance.assert_called_with("/app/models/all-mpnet-base-v2") + + @patch("application.vectorstore.base.settings") + @patch("application.vectorstore.base.EmbeddingsSingleton.get_instance") + def test_get_embeddings_generic(self, mock_get_instance, mock_settings): + mock_settings.EMBEDDINGS_BASE_URL = None + mock_emb = Mock() + mock_get_instance.return_value = mock_emb + + store = ConcreteVectorStore() + result = store._get_embeddings("some_custom_embedding") + assert result is mock_emb + mock_get_instance.assert_called_with("some_custom_embedding") diff --git a/tests/vectorstore/test_document_class.py b/tests/vectorstore/test_document_class.py new file mode 100644 index 00000000..6620cc09 --- /dev/null +++ b/tests/vectorstore/test_document_class.py @@ -0,0 +1,39 @@ +import pytest +from application.vectorstore.document_class import Document + + +@pytest.mark.unit +class TestDocument: + def test_create_document(self): + doc = Document(page_content="hello world", metadata={"source": "test"}) + assert doc.page_content == "hello world" + assert doc.metadata == {"source": "test"} + + def test_document_is_string(self): + doc = Document(page_content="hello world", metadata={}) + assert isinstance(doc, str) + assert str(doc) == "hello world" + + def test_document_string_equality(self): + doc = Document(page_content="hello", metadata={"k": "v"}) + assert doc == "hello" + + def test_document_empty_metadata(self): + doc = Document(page_content="text", metadata={}) + assert doc.metadata == {} + + def test_document_empty_content(self): + doc = Document(page_content="", metadata={"a": 1}) + assert doc.page_content == "" + assert doc == "" + + def test_document_preserves_complex_metadata(self): + meta = {"source": "file.txt", "page": 3, "nested": {"key": "val"}} + doc = Document(page_content="content", metadata=meta) + assert doc.metadata["nested"]["key"] == "val" + + def test_document_string_operations(self): + doc = Document(page_content="hello world", metadata={}) + assert doc.upper() == "HELLO WORLD" + assert doc.split() == ["hello", "world"] + assert "world" in doc diff --git a/tests/vectorstore/test_elasticsearch.py b/tests/vectorstore/test_elasticsearch.py new file mode 100644 index 00000000..0b435c5d --- /dev/null +++ b/tests/vectorstore/test_elasticsearch.py @@ -0,0 +1,292 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + + +def _make_es_store(source_id="test-source"): + """Helper to create an ElasticsearchStore with mocked deps.""" + # Reset class-level connection + from application.vectorstore.elasticsearch import ElasticsearchStore + + ElasticsearchStore._es_connection = None + + with patch( + "application.vectorstore.elasticsearch.settings" + ) as mock_settings, patch.dict( + "sys.modules", {"elasticsearch": MagicMock(), "elasticsearch.helpers": MagicMock()} + ): + mock_settings.ELASTIC_URL = "http://localhost:9200" + mock_settings.ELASTIC_USERNAME = "elastic" + mock_settings.ELASTIC_PASSWORD = "password" + mock_settings.ELASTIC_CLOUD_ID = None + mock_settings.ELASTIC_INDEX = "test_index" + mock_settings.EMBEDDINGS_NAME = "test_model" + + import elasticsearch + + mock_es = MagicMock() + elasticsearch.Elasticsearch.return_value = mock_es + + store = ElasticsearchStore( + source_id=source_id, + embeddings_key="key", + index_name="test_index", + ) + + return store, mock_es, mock_settings + + +@pytest.mark.unit +class TestElasticsearchStoreInit: + def test_source_id_cleaned(self): + store, _, _ = _make_es_store(source_id="application/indexes/abc123/") + assert store.source_id == "abc123" + + def test_init_with_url(self): + store, mock_es, _ = _make_es_store() + assert store.docsearch is mock_es + assert store.index_name == "test_index" + + def test_init_with_cloud_id(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + ElasticsearchStore._es_connection = None + + with patch( + "application.vectorstore.elasticsearch.settings" + ) as mock_settings, patch.dict( + "sys.modules", {"elasticsearch": MagicMock()} + ): + mock_settings.ELASTIC_URL = None + mock_settings.ELASTIC_CLOUD_ID = "my-cloud-id" + mock_settings.ELASTIC_USERNAME = "user" + mock_settings.ELASTIC_PASSWORD = "pass" + mock_settings.ELASTIC_INDEX = "idx" + mock_settings.EMBEDDINGS_NAME = "model" + + store = ElasticsearchStore( + source_id="src", embeddings_key="k", index_name="idx" + ) + assert store.docsearch is not None + + def test_init_no_url_no_cloud_id_raises(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + ElasticsearchStore._es_connection = None + + with patch( + "application.vectorstore.elasticsearch.settings" + ) as mock_settings, patch.dict( + "sys.modules", {"elasticsearch": MagicMock()} + ): + mock_settings.ELASTIC_URL = None + mock_settings.ELASTIC_CLOUD_ID = None + mock_settings.ELASTIC_INDEX = "idx" + mock_settings.EMBEDDINGS_NAME = "model" + + with pytest.raises(ValueError, match="provide either"): + ElasticsearchStore(source_id="src", embeddings_key="k") + + def test_reuses_class_connection(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + ElasticsearchStore._es_connection = None + + with patch( + "application.vectorstore.elasticsearch.settings" + ) as mock_settings, patch.dict( + "sys.modules", {"elasticsearch": MagicMock()} + ): + mock_settings.ELASTIC_URL = "http://localhost:9200" + mock_settings.ELASTIC_USERNAME = "user" + mock_settings.ELASTIC_PASSWORD = "pass" + mock_settings.ELASTIC_CLOUD_ID = None + mock_settings.ELASTIC_INDEX = "idx" + mock_settings.EMBEDDINGS_NAME = "model" + + import elasticsearch + + mock_es = MagicMock() + elasticsearch.Elasticsearch.return_value = mock_es + + store1 = ElasticsearchStore(source_id="src1", embeddings_key="k") + store2 = ElasticsearchStore(source_id="src2", embeddings_key="k") + + assert store1.docsearch is store2.docsearch + elasticsearch.Elasticsearch.assert_called_once() + + +@pytest.mark.unit +class TestElasticsearchStoreSearch: + def test_search_builds_query(self): + store, mock_es, mock_settings = _make_es_store() + + mock_emb = Mock() + mock_emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) + + with patch.object(store, "_get_embeddings", return_value=mock_emb): + mock_es.search.return_value = { + "hits": { + "hits": [ + { + "_source": { + "text": "doc1", + "metadata": {"source": "file.txt"}, + } + }, + { + "_source": { + "text": "doc2", + "metadata": {"source": "file2.txt"}, + } + }, + ] + } + } + + results = store.search("query", k=2) + + assert len(results) == 2 + assert results[0].page_content == "doc1" + assert results[1].metadata == {"source": "file2.txt"} + + def test_search_empty_results(self): + store, mock_es, _ = _make_es_store() + + mock_emb = Mock() + mock_emb.embed_query = Mock(return_value=[0.1]) + + with patch.object(store, "_get_embeddings", return_value=mock_emb): + mock_es.search.return_value = {"hits": {"hits": []}} + results = store.search("query") + + assert results == [] + + +@pytest.mark.unit +class TestElasticsearchStoreAddTexts: + def test_add_texts(self): + store, mock_es, mock_settings = _make_es_store() + + mock_emb = Mock() + mock_emb.embed_documents = Mock(return_value=[[0.1, 0.2], [0.3, 0.4]]) + + mock_bulk = Mock(return_value=(2, 0)) + mock_helpers = MagicMock() + mock_helpers.bulk = mock_bulk + + with patch.object( + store, "_get_embeddings", return_value=mock_emb + ), patch.object( + store, "_create_index_if_not_exists" + ), patch.dict( + "sys.modules", {"elasticsearch.helpers": mock_helpers} + ): + ids = store.add_texts( + ["text1", "text2"], + metadatas=[{"a": 1}, {"b": 2}], + ) + + assert len(ids) == 2 + + def test_add_texts_empty_raises(self): + """Empty texts causes IndexError because code accesses vectors[0] unconditionally.""" + store, _, _ = _make_es_store() + + mock_emb = Mock() + mock_emb.embed_documents = Mock(return_value=[]) + + with patch.object(store, "_get_embeddings", return_value=mock_emb): + with pytest.raises(IndexError): + store.add_texts([], metadatas=[]) + + +@pytest.mark.unit +class TestElasticsearchStoreDeleteIndex: + def test_delete_index_calls_delete_by_query(self): + store, mock_es, _ = _make_es_store(source_id="src1") + + store.delete_index() + + mock_es.delete_by_query.assert_called_once_with( + index="test_index", + query={"match": {"metadata.source_id.keyword": "src1"}}, + ) + + +@pytest.mark.unit +class TestElasticsearchStoreIndex: + def test_index_returns_mapping(self): + store, _, _ = _make_es_store() + + mapping = store.index(dims_length=768) + + assert mapping["mappings"]["properties"]["vector"]["type"] == "dense_vector" + assert mapping["mappings"]["properties"]["vector"]["dims"] == 768 + assert mapping["mappings"]["properties"]["vector"]["similarity"] == "cosine" + + def test_create_index_if_not_exists_existing(self): + store, mock_es, _ = _make_es_store() + mock_es.indices.exists.return_value = True + + store._create_index_if_not_exists("test_index", 768) + + mock_es.indices.create.assert_not_called() + + def test_create_index_if_not_exists_new(self): + store, mock_es, _ = _make_es_store() + mock_es.indices.exists.return_value = False + + store._create_index_if_not_exists("test_index", 768) + + mock_es.indices.create.assert_called_once() + + +@pytest.mark.unit +class TestElasticsearchStoreConnectToElasticsearch: + def test_connect_with_url(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + with patch.dict("sys.modules", {"elasticsearch": MagicMock()}): + import elasticsearch + + mock_es = MagicMock() + elasticsearch.Elasticsearch.return_value = mock_es + + result = ElasticsearchStore.connect_to_elasticsearch( + es_url="http://localhost:9200", + username="user", + password="pass", + ) + assert result is mock_es + + def test_connect_with_both_raises(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + with patch.dict("sys.modules", {"elasticsearch": MagicMock()}): + with pytest.raises(ValueError, match="Both es_url and cloud_id"): + ElasticsearchStore.connect_to_elasticsearch( + es_url="http://localhost", cloud_id="cloud-123" + ) + + def test_connect_with_neither_raises(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + with patch.dict("sys.modules", {"elasticsearch": MagicMock()}): + with pytest.raises(ValueError, match="provide either"): + ElasticsearchStore.connect_to_elasticsearch() + + def test_connect_with_api_key(self): + from application.vectorstore.elasticsearch import ElasticsearchStore + + with patch.dict("sys.modules", {"elasticsearch": MagicMock()}): + import elasticsearch + + mock_es = MagicMock() + elasticsearch.Elasticsearch.return_value = mock_es + + result = ElasticsearchStore.connect_to_elasticsearch( + es_url="http://localhost:9200", + api_key="my-api-key", + ) + assert result is mock_es diff --git a/tests/vectorstore/test_embeddings_local.py b/tests/vectorstore/test_embeddings_local.py new file mode 100644 index 00000000..7b85bd35 --- /dev/null +++ b/tests/vectorstore/test_embeddings_local.py @@ -0,0 +1,140 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + + +@pytest.mark.unit +class TestEmbeddingsWrapper: + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_init_success(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 768 + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("test-model") + + mock_st_cls.assert_called_once() + assert wrapper.dimension == 768 + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_init_failure(self, mock_st_cls): + mock_st_cls.side_effect = Exception("model not found") + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + with pytest.raises(Exception, match="model not found"): + EmbeddingsWrapper("bad-model") + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_init_none_model(self, mock_st_cls): + mock_st_cls.return_value = None + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + with pytest.raises((ValueError, AttributeError)): + EmbeddingsWrapper("bad-model") + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_init_null_first_module(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = None + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + with pytest.raises(ValueError, match="failed to load properly"): + EmbeddingsWrapper("bad-model") + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_embed_query(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 3 + mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1, 0.2, 0.3])) + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("model") + result = wrapper.embed_query("hello world") + + mock_model.encode.assert_called_once_with("hello world") + assert result == [0.1, 0.2, 0.3] + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_embed_documents(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 3 + mock_model.encode.return_value = MagicMock( + tolist=Mock(return_value=[[0.1, 0.2], [0.3, 0.4]]) + ) + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("model") + result = wrapper.embed_documents(["doc1", "doc2"]) + + mock_model.encode.assert_called_with(["doc1", "doc2"]) + assert result == [[0.1, 0.2], [0.3, 0.4]] + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_call_with_string(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 3 + mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1])) + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("model") + result = wrapper("hello") + assert result == [0.1] + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_call_with_list(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 3 + mock_model.encode.return_value = MagicMock( + tolist=Mock(return_value=[[0.1], [0.2]]) + ) + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("model") + result = wrapper(["a", "b"]) + assert result == [[0.1], [0.2]] + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_call_with_invalid_type(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 3 + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("model") + with pytest.raises(ValueError, match="Input must be a string or a list"): + wrapper(123) + + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_trust_remote_code_default(self, mock_st_cls): + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + mock_model.get_sentence_embedding_dimension.return_value = 768 + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + EmbeddingsWrapper("model") + + call_kwargs = mock_st_cls.call_args[1] + assert call_kwargs["trust_remote_code"] is True diff --git a/tests/vectorstore/test_faiss.py b/tests/vectorstore/test_faiss.py new file mode 100644 index 00000000..d0413bb1 --- /dev/null +++ b/tests/vectorstore/test_faiss.py @@ -0,0 +1,361 @@ +import io +import os +import tempfile +from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch + +import pytest + + +@pytest.fixture +def mock_embeddings(): + emb = Mock() + emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) + emb.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) + emb.dimension = 3 + return emb + + +@pytest.fixture +def mock_storage(): + storage = Mock() + storage.file_exists = Mock(return_value=True) + storage.get_file = Mock(return_value=io.BytesIO(b"fake data")) + storage.save_file = Mock() + return storage + + +@pytest.fixture +def mock_docsearch(): + ds = Mock() + ds.similarity_search = Mock(return_value=[]) + ds.add_texts = Mock(return_value=["id1"]) + ds.add_documents = Mock(return_value=["id1"]) + ds.save_local = Mock() + ds.delete = Mock() + ds.index = Mock() + ds.index.d = 3 + ds.docstore = Mock() + ds.docstore._dict = { + "doc1": Mock(page_content="text1", metadata={"source": "a"}), + "doc2": Mock(page_content="text2", metadata={"source": "b"}), + } + return ds + + +@pytest.mark.unit +class TestFaissStoreInit: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_init_with_docs(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="test", embeddings_key="key", docs_init=[Mock()]) + mock_faiss.from_documents.assert_called_once() + assert store.docsearch is mock_ds + + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_init_missing_index_files( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_storage = Mock() + mock_storage.file_exists.return_value = False + mock_storage_creator.get_storage.return_value = mock_storage + + from application.vectorstore.faiss import FaissStore + + with pytest.raises(Exception, match="Error loading FAISS index"): + FaissStore(source_id="test", embeddings_key="key") + + +@pytest.mark.unit +class TestFaissStoreSearch: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_search_delegates_to_docsearch( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_ds.similarity_search.return_value = ["doc1"] + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + result = store.search("query", k=5) + mock_ds.similarity_search.assert_called_once_with("query", k=5) + assert result == ["doc1"] + + +@pytest.mark.unit +class TestFaissStoreAddTexts: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_add_texts_delegates( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_ds.add_texts.return_value = ["id1", "id2"] + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + result = store.add_texts(["text1", "text2"]) + assert result == ["id1", "id2"] + + +@pytest.mark.unit +class TestFaissStoreGetChunks: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_get_chunks(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + + doc1 = Mock(page_content="text1", metadata={"source": "a"}) + doc2 = Mock(page_content="text2", metadata={"source": "b"}) + + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_ds.docstore._dict = {"id1": doc1, "id2": doc2} + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + chunks = store.get_chunks() + + assert len(chunks) == 2 + texts = {c["text"] for c in chunks} + assert texts == {"text1", "text2"} + + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_get_chunks_empty(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_ds.docstore._dict = {} + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + assert store.get_chunks() == [] + + +@pytest.mark.unit +class TestFaissStoreSaveLocal: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_save_local_with_path( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_faiss.from_documents.return_value = mock_ds + mock_storage = Mock() + mock_storage_creator.get_storage.return_value = mock_storage + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + + # Mock _save_to_storage to avoid file I/O + store._save_to_storage = Mock(return_value=True) + + with patch("os.makedirs"): + result = store.save_local(path="/tmp/test_save") + + mock_ds.save_local.assert_called_once_with("/tmp/test_save") + store._save_to_storage.assert_called_once() + assert result is True + + +@pytest.mark.unit +class TestFaissStoreDeleteIndex: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_delete_index_delegates( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + store.delete_index(["id1"]) + mock_ds.delete.assert_called_once_with(["id1"]) + + +@pytest.mark.unit +class TestFaissStoreAssertEmbeddingDimensions: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_dimension_mismatch_raises( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = ( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + mock_emb = Mock(dimension=768) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=512) # Mismatched dimension + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + with pytest.raises(ValueError, match="Embedding dimension mismatch"): + FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_missing_dimension_attr_raises( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + mock_settings.EMBEDDINGS_NAME = ( + "huggingface_sentence-transformers/all-mpnet-base-v2" + ) + mock_emb = Mock(spec=[]) # No dimension attribute + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=768) + mock_faiss.from_documents.return_value = mock_ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + with pytest.raises(AttributeError, match="dimension"): + FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + + +@pytest.mark.unit +class TestFaissStoreDeleteChunk: + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_delete_chunk(self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_emb = Mock(dimension=3) + mock_get_emb.return_value = mock_emb + mock_ds = Mock() + mock_ds.index = Mock(d=3) + mock_faiss.from_documents.return_value = mock_ds + mock_storage = Mock() + mock_storage_creator.get_storage.return_value = mock_storage + + from application.vectorstore.faiss import FaissStore + + store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + store._save_to_storage = Mock(return_value=True) + + result = store.delete_chunk("chunk_id") + mock_ds.delete.assert_called_once_with(["chunk_id"]) + store._save_to_storage.assert_called_once() + assert result is True + + +@pytest.mark.unit +class TestGetVectorstore: + def test_with_path(self): + from application.vectorstore.faiss import get_vectorstore + + assert get_vectorstore("abc123") == "indexes/abc123" + + def test_without_path(self): + from application.vectorstore.faiss import get_vectorstore + + assert get_vectorstore("") == "indexes" + assert get_vectorstore(None) == "indexes" diff --git a/tests/vectorstore/test_lancedb.py b/tests/vectorstore/test_lancedb.py new file mode 100644 index 00000000..49507ea0 --- /dev/null +++ b/tests/vectorstore/test_lancedb.py @@ -0,0 +1,312 @@ +from unittest.mock import MagicMock, Mock, PropertyMock, patch + +import pytest + + +def _make_lancedb_store(source_id="test-source"): + """Helper to create a LanceDBVectorStore with mocked deps.""" + with patch( + "application.vectorstore.lancedb.settings" + ) as mock_settings: + mock_settings.LANCEDB_PATH = "/tmp/lancedb" + mock_settings.LANCEDB_TABLE_NAME = "docs" + mock_settings.EMBEDDINGS_NAME = "test_model" + + from application.vectorstore.lancedb import LanceDBVectorStore + + store = LanceDBVectorStore( + path="/tmp/lancedb", + table_name_prefix="docs", + source_id=source_id, + embeddings_key="key", + ) + + return store, mock_settings + + +@pytest.mark.unit +class TestLanceDBVectorStoreInit: + def test_table_name_with_source_id(self): + store, _ = _make_lancedb_store(source_id="src1") + assert store.table_name == "docs_src1" + + def test_table_name_without_source_id(self): + with patch("application.vectorstore.lancedb.settings") as mock_settings: + mock_settings.LANCEDB_PATH = "/tmp" + mock_settings.LANCEDB_TABLE_NAME = "docs" + + from application.vectorstore.lancedb import LanceDBVectorStore + + store = LanceDBVectorStore( + path="/tmp", table_name_prefix="docs", source_id=None + ) + assert store.table_name == "docs" + + def test_init_defaults(self): + store, _ = _make_lancedb_store() + assert store.path == "/tmp/lancedb" + assert store._lance_db is None + assert store.docsearch is None + + +@pytest.mark.unit +class TestLanceDBVectorStoreLazyLoading: + def test_pa_lazy_load(self): + store, _ = _make_lancedb_store() + mock_pa = MagicMock() + + with patch("importlib.import_module", return_value=mock_pa) as mock_import: + result = store.pa + mock_import.assert_called_with("pyarrow") + assert result is mock_pa + + def test_pa_cached(self): + store, _ = _make_lancedb_store() + mock_pa = MagicMock() + store._pa = mock_pa + + assert store.pa is mock_pa + + def test_lancedb_lazy_load(self): + store, _ = _make_lancedb_store() + mock_ldb = MagicMock() + + with patch("importlib.import_module", return_value=mock_ldb) as mock_import: + result = store.lancedb + mock_import.assert_called_with("lancedb") + assert result is mock_ldb + + def test_lance_db_connection(self): + store, _ = _make_lancedb_store() + mock_ldb_module = MagicMock() + mock_conn = MagicMock() + mock_ldb_module.connect.return_value = mock_conn + store._lancedb_module = mock_ldb_module + + result = store.lance_db + mock_ldb_module.connect.assert_called_once_with("/tmp/lancedb") + assert result is mock_conn + + def test_lance_db_cached(self): + store, _ = _make_lancedb_store() + mock_conn = MagicMock() + store._lance_db = mock_conn + + assert store.lance_db is mock_conn + + def test_table_opens_existing(self): + store, _ = _make_lancedb_store() + mock_conn = MagicMock() + mock_table = MagicMock() + mock_conn.table_names.return_value = [store.table_name] + mock_conn.open_table.return_value = mock_table + store._lance_db = mock_conn + + result = store.table + mock_conn.open_table.assert_called_once_with(store.table_name) + assert result is mock_table + + def test_table_returns_none_for_missing(self): + store, _ = _make_lancedb_store() + mock_conn = MagicMock() + mock_conn.table_names.return_value = [] + store._lance_db = mock_conn + + result = store.table + assert result is None + + +@pytest.mark.unit +class TestLanceDBVectorStoreEnsureTableExists: + def test_creates_table_when_missing(self): + store, _ = _make_lancedb_store() + mock_conn = MagicMock() + mock_conn.table_names.return_value = [] + store._lance_db = mock_conn + + mock_emb = MagicMock() + mock_emb.dimension = 768 + mock_pa = MagicMock() + store._pa = mock_pa + + with patch.object(store, "_get_embeddings", return_value=mock_emb): + store.ensure_table_exists() + + mock_conn.create_table.assert_called_once() + + def test_noop_when_table_exists(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + mock_conn = MagicMock() + mock_conn.table_names.return_value = [store.table_name] + mock_conn.open_table.return_value = mock_table + store._lance_db = mock_conn + + store.ensure_table_exists() + mock_conn.create_table.assert_not_called() + + +@pytest.mark.unit +class TestLanceDBVectorStoreAddTexts: + def test_add_texts(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + + mock_emb = MagicMock() + mock_emb.embed_documents.return_value = [[0.1, 0.2], [0.3, 0.4]] + + with patch.object(store, "_get_embeddings", return_value=mock_emb), patch.object( + store, "ensure_table_exists" + ): + store.add_texts( + ["text1", "text2"], + metadatas=[{"a": "1"}, {"b": "2"}], + source_id="src1", + ) + + mock_table.add.assert_called_once() + vectors = mock_table.add.call_args[0][0] + assert len(vectors) == 2 + assert vectors[0]["text"] == "text1" + assert vectors[0]["vector"] == [0.1, 0.2] + + def test_add_texts_with_source_id_in_metadata(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + + mock_emb = MagicMock() + mock_emb.embed_documents.return_value = [[0.1]] + + with patch.object(store, "_get_embeddings", return_value=mock_emb), patch.object( + store, "ensure_table_exists" + ): + store.add_texts(["text1"], metadatas=[{"k": "v"}], source_id="src1") + + vectors = mock_table.add.call_args[0][0] + metadata_keys = [m["key"] for m in vectors[0]["metadata"]] + assert "source_id" in metadata_keys + + def test_add_texts_default_metadata(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + + mock_emb = MagicMock() + mock_emb.embed_documents.return_value = [[0.1]] + + with patch.object(store, "_get_embeddings", return_value=mock_emb), patch.object( + store, "ensure_table_exists" + ): + store.add_texts(["text1"]) + + mock_table.add.assert_called_once() + + +@pytest.mark.unit +class TestLanceDBVectorStoreSearch: + def test_search(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + + mock_emb = MagicMock() + mock_emb.embed_query.return_value = [0.1, 0.2] + + mock_result = MagicMock() + mock_result.limit.return_value.to_list.return_value = [ + {"_distance": 0.1, "text": "result1", "metadata": {"k": "v"}}, + ] + mock_table.search.return_value = mock_result + + with patch.object(store, "_get_embeddings", return_value=mock_emb), patch.object( + store, "ensure_table_exists" + ): + results = store.search("query", k=3) + + assert len(results) == 1 + assert results[0][1] == "result1" + mock_result.limit.assert_called_once_with(3) + + +@pytest.mark.unit +class TestLanceDBVectorStoreDeleteIndex: + def test_delete_index_drops_table(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + mock_conn = MagicMock() + mock_conn.table_names.return_value = [store.table_name] + mock_conn.open_table.return_value = mock_table + store._lance_db = mock_conn + + store.delete_index() + + mock_conn.drop_table.assert_called_once_with(store.table_name) + + def test_delete_index_noop_when_no_table(self): + store, _ = _make_lancedb_store() + store.docsearch = None + mock_conn = MagicMock() + mock_conn.table_names.return_value = [] + store._lance_db = mock_conn + + store.delete_index() + mock_conn.drop_table.assert_not_called() + + +@pytest.mark.unit +class TestLanceDBVectorStoreAssertEmbeddingDimensions: + def test_matching_dimensions(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + mock_table.schema = {"vector": MagicMock()} + mock_table.schema["vector"].type.value_type.__len__ = Mock(return_value=768) + store.docsearch = mock_table + + mock_emb = MagicMock() + mock_emb.dimension = 768 + + # Should not raise + store.assert_embedding_dimensions(mock_emb) + + def test_mismatched_dimensions_raises(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + + type_mock = MagicMock() + type_mock.__len__ = Mock(return_value=512) + mock_table.schema.__getitem__ = Mock(return_value=MagicMock()) + mock_table.schema["vector"].type.value_type = type_mock + + mock_emb = MagicMock() + mock_emb.dimension = 768 + + with pytest.raises(ValueError, match="Embedding dimension mismatch"): + store.assert_embedding_dimensions(mock_emb) + + +@pytest.mark.unit +class TestLanceDBVectorStoreFilterDocuments: + def test_filter_documents(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + mock_table.filter.return_value.to_list.return_value = [{"text": "filtered"}] + store.docsearch = mock_table + + with patch.object(store, "ensure_table_exists"): + results = store.filter_documents({"source_id": "src1"}) + + assert len(results) == 1 + + def test_filter_documents_requires_source_id(self): + store, _ = _make_lancedb_store() + mock_table = MagicMock() + store.docsearch = mock_table + + with patch.object(store, "ensure_table_exists"): + with pytest.raises(ValueError, match="must contain 'source_id'"): + store.filter_documents({"other_key": "value"}) diff --git a/tests/vectorstore/test_milvus.py b/tests/vectorstore/test_milvus.py new file mode 100644 index 00000000..e993ea8b --- /dev/null +++ b/tests/vectorstore/test_milvus.py @@ -0,0 +1,95 @@ +from unittest.mock import MagicMock, Mock, patch +from uuid import UUID + +import pytest + + +def _make_milvus_store(source_id="test-source"): + """Helper to create a MilvusStore with mocked deps.""" + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings" + ) as mock_get_emb, patch( + "application.vectorstore.milvus.settings" + ) as mock_settings, patch.dict( + "sys.modules", + { + "langchain_milvus": MagicMock(), + }, + ): + mock_emb = Mock() + mock_get_emb.return_value = mock_emb + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.MILVUS_URI = "http://localhost:19530" + mock_settings.MILVUS_TOKEN = "token" + mock_settings.MILVUS_COLLECTION_NAME = "test_collection" + + from langchain_milvus import Milvus + + mock_docsearch = MagicMock() + Milvus.return_value = mock_docsearch + + from application.vectorstore.milvus import MilvusStore + + store = MilvusStore(source_id=source_id, embeddings_key="key") + store._docsearch = mock_docsearch + + return store, mock_docsearch + + +@pytest.mark.unit +class TestMilvusStoreInit: + def test_source_id_stored(self): + store, _ = _make_milvus_store(source_id="src1") + assert store._source_id == "src1" + + +@pytest.mark.unit +class TestMilvusStoreSearch: + def test_search(self): + store, mock_ds = _make_milvus_store(source_id="src1") + mock_ds.similarity_search.return_value = ["doc1", "doc2"] + + results = store.search("query", k=3) + + mock_ds.similarity_search.assert_called_once() + call_kwargs = mock_ds.similarity_search.call_args + assert call_kwargs[1]["query"] == "query" + assert call_kwargs[1]["k"] == 3 + assert call_kwargs[1]["expr"] == "source_id == 'src1'" + assert results == ["doc1", "doc2"] + + +@pytest.mark.unit +class TestMilvusStoreAddTexts: + def test_add_texts(self): + store, mock_ds = _make_milvus_store() + mock_ds.add_texts.return_value = ["id1", "id2"] + + result = store.add_texts( + ["text1", "text2"], metadatas=[{"a": 1}, {"b": 2}] + ) + + mock_ds.add_texts.assert_called_once() + call_kwargs = mock_ds.add_texts.call_args + assert call_kwargs[1]["texts"] == ["text1", "text2"] + # ids should be UUIDs + ids = call_kwargs[1]["ids"] + assert len(ids) == 2 + for uid in ids: + UUID(uid) # Validates it's a valid UUID + + assert result == ["id1", "id2"] + + +@pytest.mark.unit +class TestMilvusStoreSaveLocal: + def test_save_local_is_noop(self): + store, _ = _make_milvus_store() + assert store.save_local() is None + + +@pytest.mark.unit +class TestMilvusStoreDeleteIndex: + def test_delete_index_is_noop(self): + store, _ = _make_milvus_store() + assert store.delete_index() is None diff --git a/tests/vectorstore/test_mongodb.py b/tests/vectorstore/test_mongodb.py new file mode 100644 index 00000000..7266515a --- /dev/null +++ b/tests/vectorstore/test_mongodb.py @@ -0,0 +1,259 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + + +def _make_mongodb_store(source_id="test-source"): + """Helper to create a MongoDBVectorStore with all external deps mocked.""" + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings" + ) as mock_get_emb, patch( + "application.vectorstore.mongodb.settings" + ) as mock_settings, patch.dict( + "sys.modules", {"pymongo": MagicMock()} + ): + mock_emb = Mock() + mock_emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) + mock_emb.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) + mock_get_emb.return_value = mock_emb + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.MONGO_URI = "mongodb://localhost:27017" + + from application.vectorstore.mongodb import MongoDBVectorStore + + store = MongoDBVectorStore( + source_id=source_id, + embeddings_key="key", + collection="test_docs", + database="test_db", + ) + + mock_collection = MagicMock() + store._collection = mock_collection + + return store, mock_collection, mock_emb + + +@pytest.mark.unit +class TestMongoDBVectorStoreInit: + def test_source_id_cleaned(self): + store, _, _ = _make_mongodb_store(source_id="application/indexes/abc123/") + assert store._source_id == "abc123" + + +@pytest.mark.unit +class TestMongoDBVectorStoreSearch: + def test_search_builds_pipeline(self): + store, mock_collection, mock_emb = _make_mongodb_store() + + doc1 = { + "_id": "id1", + "text": "hello world", + "embedding": [0.1, 0.2], + "source": "test", + } + mock_collection.aggregate.return_value = iter([doc1]) + + results = store.search("query", k=3) + + mock_emb.embed_query.assert_called_once_with("query") + mock_collection.aggregate.assert_called_once() + pipeline = mock_collection.aggregate.call_args[0][0] + assert pipeline[0]["$vectorSearch"]["limit"] == 3 + assert pipeline[0]["$vectorSearch"]["numCandidates"] == 30 + + assert len(results) == 1 + assert str(results[0]) == "hello world" + + def test_search_removes_id_text_embedding_from_metadata(self): + store, mock_collection, _ = _make_mongodb_store() + + doc = { + "_id": "id1", + "text": "content", + "embedding": [0.1], + "custom_key": "custom_val", + } + mock_collection.aggregate.return_value = iter([doc]) + + results = store.search("q", k=1) + metadata = results[0].metadata + assert "_id" not in metadata + assert "text" not in metadata + assert "embedding" not in metadata + assert metadata["custom_key"] == "custom_val" + + +@pytest.mark.unit +class TestMongoDBVectorStoreAddTexts: + def test_add_texts_batches(self): + store, mock_collection, mock_emb = _make_mongodb_store() + # Generate 150 texts to trigger batching at 100 + texts = [f"text_{i}" for i in range(150)] + metadatas = [{"i": i} for i in range(150)] + mock_emb.embed_documents.return_value = [[0.1]] * 100 # per batch + + mock_collection.insert_many.return_value = Mock( + inserted_ids=list(range(100)) + ) + + result = store.add_texts(texts, metadatas) + + # Should have been called twice: batch of 100, then batch of 50 + assert mock_collection.insert_many.call_count == 2 + + def test_add_texts_default_metadata(self): + store, mock_collection, mock_emb = _make_mongodb_store() + mock_emb.embed_documents.return_value = [[0.1]] + mock_collection.insert_many.return_value = Mock(inserted_ids=["id1"]) + + store.add_texts(["text1"]) + mock_collection.insert_many.assert_called_once() + + def test_add_texts_empty(self): + store, mock_collection, mock_emb = _make_mongodb_store() + mock_emb.embed_documents.return_value = [] + mock_collection.insert_many.return_value = Mock(inserted_ids=[]) + + result = store.add_texts([], []) + # _insert_texts returns [] for empty input + assert result == [] + + +@pytest.mark.unit +class TestMongoDBVectorStoreInsertTexts: + def test_insert_texts_empty_returns_empty(self): + store, _, _ = _make_mongodb_store() + result = store._insert_texts([], []) + assert result == [] + + def test_insert_texts_builds_correct_documents(self): + store, mock_collection, mock_emb = _make_mongodb_store() + mock_emb.embed_documents.return_value = [[0.1, 0.2]] + mock_collection.insert_many.return_value = Mock(inserted_ids=["id1"]) + + store._insert_texts(["hello"], [{"source": "test"}]) + + inserted_docs = mock_collection.insert_many.call_args[0][0] + assert len(inserted_docs) == 1 + assert inserted_docs[0]["text"] == "hello" + assert inserted_docs[0]["embedding"] == [0.1, 0.2] + assert inserted_docs[0]["source"] == "test" + + +@pytest.mark.unit +class TestMongoDBVectorStoreDeleteIndex: + def test_delete_index_calls_delete_many(self): + store, mock_collection, _ = _make_mongodb_store(source_id="src1") + + store.delete_index() + + mock_collection.delete_many.assert_called_once_with({"source_id": "src1"}) + + +@pytest.mark.unit +class TestMongoDBVectorStoreGetChunks: + def test_get_chunks(self): + store, mock_collection, _ = _make_mongodb_store() + + docs = [ + { + "_id": "id1", + "text": "chunk1", + "embedding": [0.1], + "source_id": "src", + "extra": "val", + }, + { + "_id": "id2", + "text": "chunk2", + "embedding": [0.2], + "source_id": "src", + }, + ] + mock_collection.find.return_value = iter(docs) + + chunks = store.get_chunks() + + assert len(chunks) == 2 + assert chunks[0]["doc_id"] == "id1" + assert chunks[0]["text"] == "chunk1" + assert chunks[0]["metadata"] == {"extra": "val"} + assert "embedding" not in chunks[0]["metadata"] + assert "source_id" not in chunks[0]["metadata"] + + def test_get_chunks_skips_empty_text(self): + store, mock_collection, _ = _make_mongodb_store() + + docs = [ + {"_id": "id1", "text": None, "embedding": [0.1], "source_id": "src"}, + ] + mock_collection.find.return_value = iter(docs) + + chunks = store.get_chunks() + assert len(chunks) == 0 + + def test_get_chunks_returns_empty_on_error(self): + store, mock_collection, _ = _make_mongodb_store() + mock_collection.find.side_effect = Exception("connection error") + + assert store.get_chunks() == [] + + +@pytest.mark.unit +class TestMongoDBVectorStoreAddChunk: + def test_add_chunk(self): + store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1") + mock_emb.embed_documents.return_value = [[0.1, 0.2]] + mock_collection.insert_one.return_value = Mock(inserted_id="new_id") + + result = store.add_chunk("hello chunk", metadata={"key": "val"}) + + assert result == "new_id" + inserted = mock_collection.insert_one.call_args[0][0] + assert inserted["text"] == "hello chunk" + assert inserted["source_id"] == "src1" + assert inserted["key"] == "val" + + def test_add_chunk_default_metadata(self): + store, mock_collection, mock_emb = _make_mongodb_store() + mock_emb.embed_documents.return_value = [[0.1]] + mock_collection.insert_one.return_value = Mock(inserted_id="id") + + store.add_chunk("text") + + inserted = mock_collection.insert_one.call_args[0][0] + assert "source_id" in inserted + + def test_add_chunk_raises_on_empty_embedding(self): + store, _, mock_emb = _make_mongodb_store() + mock_emb.embed_documents.return_value = [] + + with pytest.raises(ValueError, match="Could not generate embedding"): + store.add_chunk("text") + + +@pytest.mark.unit +class TestMongoDBVectorStoreDeleteChunk: + def test_delete_chunk_success(self): + store, mock_collection, _ = _make_mongodb_store() + mock_collection.delete_one.return_value = Mock(deleted_count=1) + + with patch("application.vectorstore.mongodb.ObjectId", create=True): + # We need to mock bson.objectid.ObjectId + with patch.dict("sys.modules", {"bson": MagicMock(), "bson.objectid": MagicMock()}): + from unittest.mock import MagicMock as MM + mock_oid = MM() + with patch( + "bson.objectid.ObjectId", return_value=mock_oid + ): + result = store.delete_chunk("507f1f77bcf86cd799439011") + + assert result is True + + def test_delete_chunk_returns_false_on_error(self): + store, mock_collection, _ = _make_mongodb_store() + mock_collection.delete_one.side_effect = Exception("fail") + + result = store.delete_chunk("bad_id") + assert result is False diff --git a/tests/vectorstore/test_pgvector.py b/tests/vectorstore/test_pgvector.py new file mode 100644 index 00000000..bae56c1f --- /dev/null +++ b/tests/vectorstore/test_pgvector.py @@ -0,0 +1,297 @@ +from unittest.mock import MagicMock, Mock, call, patch + +import pytest + + +def _make_store( + source_id="test-source", + embeddings_key="key", + connection_string="postgresql://user:pass@localhost/db", +): + """Helper to create a PGVectorStore with all external deps mocked.""" + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings" + ) as mock_get_emb, patch( + "application.vectorstore.pgvector.settings" + ) as mock_settings, patch.dict( + "sys.modules", + { + "psycopg2": MagicMock(), + "psycopg2.extras": MagicMock(), + "pgvector": MagicMock(), + "pgvector.psycopg2": MagicMock(), + }, + ): + mock_emb = Mock() + mock_emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) + mock_emb.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) + mock_emb.dimension = 768 + mock_get_emb.return_value = mock_emb + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.PGVECTOR_CONNECTION_STRING = connection_string + + from application.vectorstore.pgvector import PGVectorStore + + # Patch _ensure_table_exists to avoid DB calls during init + with patch.object(PGVectorStore, "_ensure_table_exists"): + store = PGVectorStore( + source_id=source_id, + embeddings_key=embeddings_key, + connection_string=connection_string, + ) + # Provide a mock connection + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_conn.closed = False + store._connection = mock_conn + + return store, mock_conn, mock_cursor, mock_emb + + +@pytest.mark.unit +class TestPGVectorStoreInit: + def test_source_id_cleaned(self): + store, _, _, _ = _make_store(source_id="application/indexes/abc123/") + assert store._source_id == "abc123" + + def test_missing_connection_string_raises(self): + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings" + ) as mock_get_emb, patch( + "application.vectorstore.pgvector.settings" + ) as mock_settings, patch.dict( + "sys.modules", + { + "psycopg2": MagicMock(), + "psycopg2.extras": MagicMock(), + "pgvector": MagicMock(), + "pgvector.psycopg2": MagicMock(), + }, + ): + mock_get_emb.return_value = Mock(dimension=768) + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.PGVECTOR_CONNECTION_STRING = None + + from application.vectorstore.pgvector import PGVectorStore + + with pytest.raises(ValueError, match="connection string is required"): + PGVectorStore( + source_id="test", embeddings_key="key", connection_string=None + ) + + +@pytest.mark.unit +class TestPGVectorStoreSearch: + def test_search_returns_documents(self): + store, mock_conn, mock_cursor, mock_emb = _make_store() + mock_cursor.fetchall.return_value = [ + ("hello world", {"source": "test.txt"}, 0.1), + ("foo bar", {"source": "test2.txt"}, 0.2), + ] + + results = store.search("query", k=2) + + mock_emb.embed_query.assert_called_once_with("query") + assert len(results) == 2 + assert results[0].page_content == "hello world" + assert results[0].metadata == {"source": "test.txt"} + + def test_search_returns_empty_on_error(self): + store, mock_conn, mock_cursor, _ = _make_store() + mock_cursor.execute.side_effect = Exception("connection lost") + + results = store.search("query") + assert results == [] + + def test_search_handles_null_metadata(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.fetchall.return_value = [("text", None, 0.5)] + + results = store.search("query") + assert len(results) == 1 + assert results[0].metadata == {} + + +@pytest.mark.unit +class TestPGVectorStoreAddTexts: + def test_add_texts_inserts_and_returns_ids(self): + store, mock_conn, mock_cursor, mock_emb = _make_store() + mock_emb.embed_documents.return_value = [[0.1, 0.2], [0.3, 0.4]] + mock_cursor.fetchone.side_effect = [(1,), (2,)] + + ids = store.add_texts(["text1", "text2"], [{"a": 1}, {"b": 2}]) + + assert ids == ["1", "2"] + assert mock_cursor.execute.call_count == 2 + mock_conn.commit.assert_called_once() + + def test_add_texts_empty_returns_empty(self): + store, _, _, _ = _make_store() + assert store.add_texts([]) == [] + + def test_add_texts_default_metadatas(self): + store, mock_conn, mock_cursor, mock_emb = _make_store() + mock_emb.embed_documents.return_value = [[0.1, 0.2]] + mock_cursor.fetchone.return_value = (1,) + + ids = store.add_texts(["text1"]) + assert ids == ["1"] + + def test_add_texts_rolls_back_on_error(self): + store, mock_conn, mock_cursor, mock_emb = _make_store() + mock_emb.embed_documents.return_value = [[0.1]] + mock_cursor.execute.side_effect = Exception("insert failed") + + with pytest.raises(Exception, match="insert failed"): + store.add_texts(["text1"]) + + mock_conn.rollback.assert_called_once() + + +@pytest.mark.unit +class TestPGVectorStoreDeleteIndex: + def test_delete_index_deletes_by_source_id(self): + store, mock_conn, mock_cursor, _ = _make_store(source_id="src123") + + store.delete_index() + + mock_cursor.execute.assert_called_once() + sql = mock_cursor.execute.call_args[0][0] + assert "DELETE FROM" in sql + assert mock_cursor.execute.call_args[0][1] == ("src123",) + mock_conn.commit.assert_called_once() + + def test_delete_index_rolls_back_on_error(self): + store, mock_conn, mock_cursor, _ = _make_store() + mock_cursor.execute.side_effect = Exception("fail") + + with pytest.raises(Exception): + store.delete_index() + + mock_conn.rollback.assert_called_once() + + +@pytest.mark.unit +class TestPGVectorStoreSaveLocal: + def test_save_local_is_noop(self): + store, _, _, _ = _make_store() + assert store.save_local() is None + + +@pytest.mark.unit +class TestPGVectorStoreGetChunks: + def test_get_chunks(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.fetchall.return_value = [ + (1, "text1", {"key": "val"}), + (2, "text2", None), + ] + + chunks = store.get_chunks() + assert len(chunks) == 2 + assert chunks[0] == {"doc_id": "1", "text": "text1", "metadata": {"key": "val"}} + assert chunks[1] == {"doc_id": "2", "text": "text2", "metadata": {}} + + def test_get_chunks_returns_empty_on_error(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.execute.side_effect = Exception("fail") + + assert store.get_chunks() == [] + + +@pytest.mark.unit +class TestPGVectorStoreAddChunk: + def test_add_chunk(self): + store, mock_conn, mock_cursor, mock_emb = _make_store(source_id="src1") + mock_emb.embed_documents.return_value = [[0.1, 0.2]] + mock_cursor.fetchone.return_value = (42,) + + chunk_id = store.add_chunk("hello", metadata={"key": "val"}) + + assert chunk_id == "42" + mock_conn.commit.assert_called_once() + + def test_add_chunk_raises_on_empty_embedding(self): + store, _, _, mock_emb = _make_store() + mock_emb.embed_documents.return_value = [] + + with pytest.raises(ValueError, match="Could not generate embedding"): + store.add_chunk("text") + + def test_add_chunk_includes_source_id_in_metadata(self): + store, mock_conn, mock_cursor, mock_emb = _make_store(source_id="src1") + mock_emb.embed_documents.return_value = [[0.1, 0.2]] + mock_cursor.fetchone.return_value = (1,) + + store.add_chunk("hello", metadata={"key": "val"}) + + # Verify source_id is passed as a parameter to the INSERT + insert_call = mock_cursor.execute.call_args + params = insert_call[0][1] + # source_id is the 4th param in the insert + assert params[3] == "src1" + + +@pytest.mark.unit +class TestPGVectorStoreDeleteChunk: + def test_delete_chunk_success(self): + store, mock_conn, mock_cursor, _ = _make_store() + mock_cursor.rowcount = 1 + + result = store.delete_chunk("42") + assert result is True + mock_conn.commit.assert_called_once() + + def test_delete_chunk_not_found(self): + store, mock_conn, mock_cursor, _ = _make_store() + mock_cursor.rowcount = 0 + + result = store.delete_chunk("999") + assert result is False + + def test_delete_chunk_returns_false_on_error(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.execute.side_effect = Exception("fail") + + result = store.delete_chunk("42") + assert result is False + + +@pytest.mark.unit +class TestPGVectorStoreConnection: + def test_get_connection_creates_new_when_closed(self): + store, mock_conn, _, _ = _make_store() + mock_conn.closed = True + + mock_psycopg2 = MagicMock() + new_conn = MagicMock() + mock_psycopg2.connect.return_value = new_conn + store._psycopg2 = mock_psycopg2 + + conn = store._get_connection() + mock_psycopg2.connect.assert_called_once() + assert conn is new_conn + + def test_get_connection_reuses_open(self): + store, mock_conn, _, _ = _make_store() + mock_conn.closed = False + + conn = store._get_connection() + assert conn is mock_conn + + def test_ensure_table_exists(self): + store, mock_conn, mock_cursor, _ = _make_store() + # Call _ensure_table_exists directly + store._ensure_table_exists() + + # Should execute CREATE EXTENSION, CREATE TABLE, and CREATE INDEX statements + assert mock_cursor.execute.call_count >= 3 + mock_conn.commit.assert_called() + + def test_del_closes_connection(self): + store, mock_conn, _, _ = _make_store() + mock_conn.closed = False + + store.__del__() + mock_conn.close.assert_called_once() diff --git a/tests/vectorstore/test_qdrant.py b/tests/vectorstore/test_qdrant.py new file mode 100644 index 00000000..2deb6a69 --- /dev/null +++ b/tests/vectorstore/test_qdrant.py @@ -0,0 +1,230 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + + +def _make_qdrant_store(source_id="test-source"): + """Helper to create a QdrantStore with all external deps mocked.""" + mock_models = MagicMock() + mock_qdrant_langchain = MagicMock() + + with patch( + "application.vectorstore.base.BaseVectorStore._get_embeddings" + ) as mock_get_emb, patch( + "application.vectorstore.qdrant.settings" + ) as mock_settings, patch.dict( + "sys.modules", + { + "qdrant_client": MagicMock(), + "qdrant_client.models": mock_models, + "langchain_community": MagicMock(), + "langchain_community.vectorstores": MagicMock(), + "langchain_community.vectorstores.qdrant": mock_qdrant_langchain, + }, + ): + mock_emb = Mock() + mock_emb.embed_query = Mock(return_value=[0.1, 0.2, 0.3]) + mock_emb.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) + mock_emb.client = [None, Mock(word_embedding_dimension=768)] + mock_get_emb.return_value = mock_emb + + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_settings.QDRANT_COLLECTION_NAME = "test_collection" + mock_settings.QDRANT_LOCATION = ":memory:" + mock_settings.QDRANT_URL = None + mock_settings.QDRANT_PORT = 6333 + mock_settings.QDRANT_GRPC_PORT = 6334 + mock_settings.QDRANT_HTTPS = False + mock_settings.QDRANT_PREFER_GRPC = False + mock_settings.QDRANT_API_KEY = None + mock_settings.QDRANT_PREFIX = None + mock_settings.QDRANT_TIMEOUT = None + mock_settings.QDRANT_PATH = None + mock_settings.QDRANT_DISTANCE_FUNC = "Cosine" + + mock_docsearch = MagicMock() + mock_collections = MagicMock() + mock_collections.collections = [MagicMock(name="test_collection")] + mock_docsearch.client.get_collections.return_value = mock_collections + mock_qdrant_langchain.Qdrant.construct_instance.return_value = mock_docsearch + + from application.vectorstore.qdrant import QdrantStore + + store = QdrantStore(source_id=source_id, embeddings_key="key") + + return store, mock_docsearch, mock_settings + + +@pytest.mark.unit +class TestQdrantStoreInit: + def test_source_id_cleaned(self): + store, _, _ = _make_qdrant_store(source_id="application/indexes/abc123/") + assert store._source_id == "abc123" + + def test_filter_constructed(self): + store, _, _ = _make_qdrant_store(source_id="src1") + assert store._filter is not None + + +@pytest.mark.unit +class TestQdrantStoreSearch: + def test_search_delegates(self): + store, mock_ds, _ = _make_qdrant_store() + mock_ds.similarity_search.return_value = ["result1"] + + results = store.search("query", k=5) + + mock_ds.similarity_search.assert_called_once() + assert results == ["result1"] + + +@pytest.mark.unit +class TestQdrantStoreAddTexts: + def test_add_texts_delegates(self): + store, mock_ds, _ = _make_qdrant_store() + mock_ds.add_texts.return_value = ["id1"] + + result = store.add_texts(["text1"], metadatas=[{"a": 1}]) + mock_ds.add_texts.assert_called_once_with(["text1"], metadatas=[{"a": 1}]) + assert result == ["id1"] + + +@pytest.mark.unit +class TestQdrantStoreSaveLocal: + def test_save_local_is_noop(self): + store, _, _ = _make_qdrant_store() + assert store.save_local() is None + + +@pytest.mark.unit +class TestQdrantStoreDeleteIndex: + def test_delete_index(self): + store, mock_ds, _ = _make_qdrant_store() + + with patch("application.vectorstore.qdrant.settings") as ms: + ms.QDRANT_COLLECTION_NAME = "test_collection" + store.delete_index() + + mock_ds.client.delete.assert_called_once_with( + collection_name="test_collection", + points_selector=store._filter, + ) + + +@pytest.mark.unit +class TestQdrantStoreGetChunks: + def test_get_chunks(self): + store, mock_ds, _ = _make_qdrant_store() + + record1 = MagicMock() + record1.id = "id1" + record1.payload = { + "page_content": "text1", + "metadata": {"source": "test"}, + } + record2 = MagicMock() + record2.id = "id2" + record2.payload = { + "page_content": "text2", + "metadata": {"source": "test2"}, + } + + # First call returns records with offset, second returns empty with None offset + mock_ds.client.scroll.side_effect = [ + ([record1, record2], None), + ] + + chunks = store.get_chunks() + + assert len(chunks) == 2 + assert chunks[0] == { + "doc_id": "id1", + "text": "text1", + "metadata": {"source": "test"}, + } + + def test_get_chunks_pagination(self): + store, mock_ds, _ = _make_qdrant_store() + + record1 = MagicMock() + record1.id = "id1" + record1.payload = {"page_content": "text1", "metadata": {}} + + record2 = MagicMock() + record2.id = "id2" + record2.payload = {"page_content": "text2", "metadata": {}} + + mock_ds.client.scroll.side_effect = [ + ([record1], "offset_token"), + ([record2], None), + ] + + chunks = store.get_chunks() + assert len(chunks) == 2 + assert mock_ds.client.scroll.call_count == 2 + + def test_get_chunks_returns_empty_on_error(self): + store, mock_ds, _ = _make_qdrant_store() + mock_ds.client.scroll.side_effect = Exception("fail") + + assert store.get_chunks() == [] + + +@pytest.mark.unit +class TestQdrantStoreAddChunk: + def test_add_chunk(self): + store, mock_ds, _ = _make_qdrant_store(source_id="src1") + mock_ds.add_documents.return_value = ["new-id"] + + result = store.add_chunk("hello", metadata={"key": "val"}) + + assert result == "new-id" + mock_ds.add_documents.assert_called_once() + doc = mock_ds.add_documents.call_args[0][0][0] + assert doc.page_content == "hello" + assert doc.metadata["source_id"] == "src1" + assert doc.metadata["key"] == "val" + + def test_add_chunk_default_metadata(self): + store, mock_ds, _ = _make_qdrant_store(source_id="src1") + mock_ds.add_documents.return_value = ["id"] + + store.add_chunk("text") + + doc = mock_ds.add_documents.call_args[0][0][0] + assert doc.metadata["source_id"] == "src1" + + def test_add_chunk_fallback_id(self): + store, mock_ds, _ = _make_qdrant_store() + mock_ds.add_documents.return_value = [] + + result = store.add_chunk("text") + # Should return the uuid that was generated + assert result is not None + assert isinstance(result, str) + + +@pytest.mark.unit +class TestQdrantStoreDeleteChunk: + def test_delete_chunk_success(self): + store, mock_ds, _ = _make_qdrant_store() + + with patch("application.vectorstore.qdrant.settings") as ms: + ms.QDRANT_COLLECTION_NAME = "test_collection" + result = store.delete_chunk("chunk-id") + + mock_ds.client.delete.assert_called_once_with( + collection_name="test_collection", + points_selector=["chunk-id"], + ) + assert result is True + + def test_delete_chunk_returns_false_on_error(self): + store, mock_ds, _ = _make_qdrant_store() + + with patch("application.vectorstore.qdrant.settings") as ms: + ms.QDRANT_COLLECTION_NAME = "test_collection" + mock_ds.client.delete.side_effect = Exception("fail") + result = store.delete_chunk("bad-id") + + assert result is False diff --git a/tests/vectorstore/test_vector_creator.py b/tests/vectorstore/test_vector_creator.py new file mode 100644 index 00000000..6fccb86b --- /dev/null +++ b/tests/vectorstore/test_vector_creator.py @@ -0,0 +1,39 @@ +from unittest.mock import patch, MagicMock + +import pytest + +from application.vectorstore.vector_creator import VectorCreator + + +@pytest.mark.unit +class TestVectorCreator: + def test_registered_vectorstores(self): + assert "faiss" in VectorCreator.vectorstores + assert "elasticsearch" in VectorCreator.vectorstores + assert "mongodb" in VectorCreator.vectorstores + assert "qdrant" in VectorCreator.vectorstores + assert "milvus" in VectorCreator.vectorstores + assert "pgvector" in VectorCreator.vectorstores + + def test_create_vectorstore_invalid_type(self): + with pytest.raises(ValueError, match="No vectorstore class found for type"): + VectorCreator.create_vectorstore("nonexistent") + + def test_create_vectorstore_case_insensitive(self): + with patch.object( + VectorCreator.vectorstores["faiss"], "__init__", return_value=None + ) as mock_init: + mock_init.return_value = None + VectorCreator.create_vectorstore("FAISS", source_id="test", embeddings_key="key") + mock_init.assert_called_once_with(source_id="test", embeddings_key="key") + + def test_create_vectorstore_passes_args(self): + with patch.object( + VectorCreator.vectorstores["mongodb"], "__init__", return_value=None + ) as mock_init: + VectorCreator.create_vectorstore( + "mongodb", source_id="src1", embeddings_key="ek", database="mydb" + ) + mock_init.assert_called_once_with( + source_id="src1", embeddings_key="ek", database="mydb" + ) From c726eb8ebd6256c2f0744df7f0885f0155c0089d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Mar 2026 18:48:53 +0000 Subject: [PATCH 25/41] fix: ruff --- tests/vectorstore/test_base.py | 3 +-- tests/vectorstore/test_faiss.py | 4 +--- tests/vectorstore/test_lancedb.py | 2 +- tests/vectorstore/test_mongodb.py | 2 +- tests/vectorstore/test_pgvector.py | 2 +- tests/vectorstore/test_vector_creator.py | 2 +- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/vectorstore/test_base.py b/tests/vectorstore/test_base.py index fc8eb544..4e8aa637 100644 --- a/tests/vectorstore/test_base.py +++ b/tests/vectorstore/test_base.py @@ -1,5 +1,4 @@ -import os -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest diff --git a/tests/vectorstore/test_faiss.py b/tests/vectorstore/test_faiss.py index d0413bb1..36ca8542 100644 --- a/tests/vectorstore/test_faiss.py +++ b/tests/vectorstore/test_faiss.py @@ -1,7 +1,5 @@ import io -import os -import tempfile -from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch +from unittest.mock import Mock, patch import pytest diff --git a/tests/vectorstore/test_lancedb.py b/tests/vectorstore/test_lancedb.py index 49507ea0..b60dca38 100644 --- a/tests/vectorstore/test_lancedb.py +++ b/tests/vectorstore/test_lancedb.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest diff --git a/tests/vectorstore/test_mongodb.py b/tests/vectorstore/test_mongodb.py index 7266515a..e93a399f 100644 --- a/tests/vectorstore/test_mongodb.py +++ b/tests/vectorstore/test_mongodb.py @@ -97,7 +97,7 @@ class TestMongoDBVectorStoreAddTexts: inserted_ids=list(range(100)) ) - result = store.add_texts(texts, metadatas) + store.add_texts(texts, metadatas) # Should have been called twice: batch of 100, then batch of 50 assert mock_collection.insert_many.call_count == 2 diff --git a/tests/vectorstore/test_pgvector.py b/tests/vectorstore/test_pgvector.py index bae56c1f..bbec02bf 100644 --- a/tests/vectorstore/test_pgvector.py +++ b/tests/vectorstore/test_pgvector.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import MagicMock, Mock, patch import pytest diff --git a/tests/vectorstore/test_vector_creator.py b/tests/vectorstore/test_vector_creator.py index 6fccb86b..8b5c69cd 100644 --- a/tests/vectorstore/test_vector_creator.py +++ b/tests/vectorstore/test_vector_creator.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest From b254de6ed6e60a2b92f23ea5efb02855329c2845 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 27 Mar 2026 12:03:55 +0000 Subject: [PATCH 26/41] tests: worker coverage --- tests/test_worker.py | 1718 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1718 insertions(+) create mode 100644 tests/test_worker.py diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 00000000..a63e64c6 --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,1718 @@ +import io +import json +import os +import zipfile +from unittest.mock import MagicMock, patch + +import pytest +from bson.objectid import ObjectId + +from application.parser.schema.base import Document as DocSchema +from application.worker import ( + ZipExtractionError, + _apply_display_names_to_structure, + _get_display_name, + _is_path_safe, + _normalize_file_name_map, + _validate_zip_safety, + attachment_worker, + download_file, + extract_zip_recursive, + generate_random_string, + metadata_from_filename, + upload_index, +) + + +def _make_doc(text="content", extra_info=None): + """Create a real Document for tests that go through to_langchain_format.""" + return DocSchema(text=text, extra_info=extra_info or {}) + + +# ────────────────────────────────────────────────────────────────────────────── +# metadata_from_filename +# ────────────────────────────────────────────────────────────────────────────── + + +class TestMetadataFromFilename: + def test_returns_dict_with_title(self): + assert metadata_from_filename("doc.pdf") == {"title": "doc.pdf"} + + def test_empty_string(self): + assert metadata_from_filename("") == {"title": ""} + + def test_path_as_title(self): + assert metadata_from_filename("/a/b/c.txt") == {"title": "/a/b/c.txt"} + + +# ────────────────────────────────────────────────────────────────────────────── +# _normalize_file_name_map +# ────────────────────────────────────────────────────────────────────────────── + + +class TestNormalizeFileNameMap: + def test_none_returns_empty(self): + assert _normalize_file_name_map(None) == {} + + def test_empty_string_returns_empty(self): + assert _normalize_file_name_map("") == {} + + def test_valid_dict_passthrough(self): + m = {"a.txt": "Original A.txt"} + assert _normalize_file_name_map(m) == m + + def test_valid_json_string(self): + m = {"a.txt": "Original A.txt"} + assert _normalize_file_name_map(json.dumps(m)) == m + + def test_invalid_json_string_returns_empty(self): + assert _normalize_file_name_map("{bad json") == {} + + def test_non_dict_json_returns_empty(self): + assert _normalize_file_name_map(json.dumps([1, 2, 3])) == {} + + def test_non_dict_type_returns_empty(self): + assert _normalize_file_name_map(42) == {} + + def test_empty_dict(self): + assert _normalize_file_name_map({}) == {} + + +# ────────────────────────────────────────────────────────────────────────────── +# _get_display_name +# ────────────────────────────────────────────────────────────────────────────── + + +class TestGetDisplayName: + def test_exact_match(self): + m = {"sub/file.txt": "My File"} + assert _get_display_name(m, "sub/file.txt") == "My File" + + def test_basename_fallback(self): + m = {"file.txt": "My File"} + assert _get_display_name(m, "sub/file.txt") == "My File" + + def test_no_match(self): + assert _get_display_name({"other.txt": "X"}, "file.txt") is None + + def test_empty_map(self): + assert _get_display_name({}, "file.txt") is None + + def test_none_map(self): + assert _get_display_name(None, "file.txt") is None + + def test_none_rel_path(self): + assert _get_display_name({"a": "b"}, None) is None + + def test_empty_rel_path(self): + assert _get_display_name({"a": "b"}, "") is None + + +# ────────────────────────────────────────────────────────────────────────────── +# _apply_display_names_to_structure +# ────────────────────────────────────────────────────────────────────────────── + + +class TestApplyDisplayNamesToStructure: + def test_flat_file_node(self): + structure = { + "file.txt": {"type": "text/plain", "size_bytes": 100} + } + m = {"file.txt": "Original Name.txt"} + result = _apply_display_names_to_structure(structure, m) + assert result["file.txt"]["display_name"] == "Original Name.txt" + + def test_nested_directory(self): + structure = { + "sub": { + "file.txt": {"type": "text/plain", "size_bytes": 50} + } + } + m = {"sub/file.txt": "Nested File"} + result = _apply_display_names_to_structure(structure, m) + assert result["sub"]["file.txt"]["display_name"] == "Nested File" + + def test_no_match_leaves_structure_unchanged(self): + structure = { + "file.txt": {"type": "text/plain", "size_bytes": 100} + } + result = _apply_display_names_to_structure(structure, {"other.txt": "X"}) + assert "display_name" not in result["file.txt"] + + def test_empty_map_returns_structure(self): + structure = {"a": {"type": "t", "size_bytes": 1}} + assert _apply_display_names_to_structure(structure, {}) is structure + + def test_none_map_returns_structure(self): + structure = {"a": {"type": "t", "size_bytes": 1}} + assert _apply_display_names_to_structure(structure, None) is structure + + def test_non_dict_structure_returns_as_is(self): + assert _apply_display_names_to_structure("not_a_dict", {"a": "b"}) == "not_a_dict" + + +# ────────────────────────────────────────────────────────────────────────────── +# generate_random_string +# ────────────────────────────────────────────────────────────────────────────── + + +class TestGenerateRandomString: + def test_length(self): + assert len(generate_random_string(10)) == 10 + + def test_zero_length(self): + assert generate_random_string(0) == "" + + def test_all_ascii_letters(self): + import string + result = generate_random_string(100) + assert all(c in string.ascii_letters for c in result) + + def test_deterministic(self): + # Same call should always produce the same string (no randomness) + assert generate_random_string(5) == generate_random_string(5) + + +# ────────────────────────────────────────────────────────────────────────────── +# _is_path_safe +# ────────────────────────────────────────────────────────────────────────────── + + +class TestIsPathSafe: + def test_safe_subpath(self, tmp_path): + assert _is_path_safe(str(tmp_path), str(tmp_path / "sub" / "file.txt")) + + def test_base_equals_target(self, tmp_path): + assert _is_path_safe(str(tmp_path), str(tmp_path)) + + def test_traversal_attack(self, tmp_path): + malicious = str(tmp_path / ".." / "etc" / "passwd") + assert not _is_path_safe(str(tmp_path), malicious) + + def test_sibling_directory(self, tmp_path): + sibling = str(tmp_path.parent / "other_dir" / "file.txt") + assert not _is_path_safe(str(tmp_path), sibling) + + +# ────────────────────────────────────────────────────────────────────────────── +# _validate_zip_safety +# ────────────────────────────────────────────────────────────────────────────── + + +class TestValidateZipSafety: + def _make_zip(self, tmp_path, files=None): + """Helper to create a zip file with given files dict {name: content}.""" + zip_path = str(tmp_path / "test.zip") + files = files or {"hello.txt": b"world"} + with zipfile.ZipFile(zip_path, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return zip_path + + def test_valid_zip(self, tmp_path): + zip_path = self._make_zip(tmp_path) + _validate_zip_safety(zip_path, str(tmp_path / "out")) + + def test_too_many_files(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + for i in range(10001): + zf.writestr(f"file_{i}.txt", "x") + with pytest.raises(ZipExtractionError, match="too many files"): + _validate_zip_safety(zip_path, str(tmp_path / "out")) + + def test_path_traversal(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("safe.txt", "ok") + # Manually inject a traversal path entry + extract_to = str(tmp_path / "out") + os.makedirs(extract_to, exist_ok=True) + with patch("application.worker._is_path_safe", return_value=False): + with pytest.raises(ZipExtractionError, match="path traversal"): + _validate_zip_safety(zip_path, extract_to) + + def test_bad_zip_file(self, tmp_path): + bad_path = str(tmp_path / "bad.zip") + with open(bad_path, "wb") as f: + f.write(b"this is not a zip") + with pytest.raises(ZipExtractionError, match="Invalid or corrupted"): + _validate_zip_safety(bad_path, str(tmp_path / "out")) + + def test_high_compression_ratio(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + # Create a highly compressible file (repeated zeros) + big_content = b"\x00" * (1024 * 1024) # 1MB of zeros + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("big.txt", big_content) + compressed_size = os.path.getsize(zip_path) + ratio = len(big_content) / compressed_size + if ratio > 100: + with pytest.raises(ZipExtractionError, match="compression ratio"): + _validate_zip_safety(zip_path, str(tmp_path / "out")) + else: + # If compression ratio is acceptable, no error + _validate_zip_safety(zip_path, str(tmp_path / "out")) + + +# ────────────────────────────────────────────────────────────────────────────── +# extract_zip_recursive +# ────────────────────────────────────────────────────────────────────────────── + + +class TestExtractZipRecursive: + def test_basic_extraction(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("hello.txt", "world") + extract_to = str(tmp_path / "out") + os.makedirs(extract_to) + extract_zip_recursive(zip_path, extract_to) + assert os.path.exists(os.path.join(extract_to, "hello.txt")) + assert not os.path.exists(zip_path) # zip removed after extraction + + def test_nested_zip_extraction(self, tmp_path): + # Create inner zip + inner_zip_bytes = io.BytesIO() + with zipfile.ZipFile(inner_zip_bytes, "w") as zf: + zf.writestr("inner.txt", "inner content") + inner_zip_bytes.seek(0) + + # Create outer zip containing inner zip + outer_zip_path = str(tmp_path / "outer.zip") + with zipfile.ZipFile(outer_zip_path, "w") as zf: + zf.writestr("inner.zip", inner_zip_bytes.read()) + + extract_to = str(tmp_path / "out") + os.makedirs(extract_to) + extract_zip_recursive(outer_zip_path, extract_to) + assert os.path.exists(os.path.join(extract_to, "inner.txt")) + + def test_max_depth_stops_recursion(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("file.txt", "data") + extract_to = str(tmp_path / "out") + os.makedirs(extract_to) + # current_depth > max_depth should stop immediately + extract_zip_recursive(zip_path, extract_to, current_depth=6, max_depth=5) + # zip file should still exist (not extracted) + assert os.path.exists(zip_path) + + def test_security_failure_removes_zip(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("ok.txt", "data") + extract_to = str(tmp_path / "out") + os.makedirs(extract_to) + with patch( + "application.worker._validate_zip_safety", + side_effect=ZipExtractionError("bad zip"), + ): + extract_zip_recursive(zip_path, extract_to) + assert not os.path.exists(zip_path) + + def test_generic_exception_does_not_crash(self, tmp_path): + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("ok.txt", "data") + extract_to = str(tmp_path / "out") + os.makedirs(extract_to) + with patch( + "application.worker._validate_zip_safety", + side_effect=RuntimeError("oops"), + ): + # Should not raise + extract_zip_recursive(zip_path, extract_to) + + +# ────────────────────────────────────────────────────────────────────────────── +# download_file +# ────────────────────────────────────────────────────────────────────────────── + + +class TestDownloadFile: + @patch("application.worker.requests.get") + def test_successful_download(self, mock_get, tmp_path): + mock_response = MagicMock() + mock_response.content = b"file content" + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + dest = str(tmp_path / "downloaded.txt") + download_file("http://example.com/file", {"key": "val"}, dest) + + mock_get.assert_called_once_with("http://example.com/file", params={"key": "val"}) + with open(dest, "rb") as f: + assert f.read() == b"file content" + + @patch("application.worker.requests.get") + def test_request_exception_raises(self, mock_get): + import requests + mock_get.side_effect = requests.RequestException("timeout") + with pytest.raises(requests.RequestException): + download_file("http://example.com/file", {}, "/tmp/nonexistent") + + +# ────────────────────────────────────────────────────────────────────────────── +# upload_index +# ────────────────────────────────────────────────────────────────────────────── + + +class TestUploadIndex: + @patch("application.worker.requests.post") + @patch("application.worker.settings") + def test_non_faiss_upload(self, mock_settings, mock_post, tmp_path): + mock_settings.VECTOR_STORE = "elasticsearch" + mock_settings.API_URL = "http://api.test" + mock_settings.INTERNAL_KEY = "secret" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + upload_index(str(tmp_path), {"name": "test"}) + + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + assert "upload_index" in args[0] + assert kwargs["headers"]["X-Internal-Key"] == "secret" + + @patch("application.worker.requests.post") + @patch("application.worker.settings") + def test_faiss_upload_with_files(self, mock_settings, mock_post, tmp_path): + mock_settings.VECTOR_STORE = "faiss" + mock_settings.API_URL = "http://api.test" + mock_settings.INTERNAL_KEY = "" + + # Create FAISS index files + faiss_path = tmp_path / "index.faiss" + pkl_path = tmp_path / "index.pkl" + faiss_path.write_bytes(b"faiss data") + pkl_path.write_bytes(b"pkl data") + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + upload_index(str(tmp_path), {"name": "test"}) + + args, kwargs = mock_post.call_args + assert "file_faiss" in kwargs["files"] + assert "file_pkl" in kwargs["files"] + + @patch("application.worker.settings") + def test_faiss_missing_index_file(self, mock_settings, tmp_path): + mock_settings.VECTOR_STORE = "faiss" + mock_settings.API_URL = "http://api.test" + mock_settings.INTERNAL_KEY = "" + with pytest.raises(FileNotFoundError, match="FAISS index file"): + upload_index(str(tmp_path), {"name": "test"}) + + @patch("application.worker.settings") + def test_faiss_missing_pkl_file(self, mock_settings, tmp_path): + mock_settings.VECTOR_STORE = "faiss" + mock_settings.API_URL = "http://api.test" + mock_settings.INTERNAL_KEY = "" + (tmp_path / "index.faiss").write_bytes(b"data") + with pytest.raises(FileNotFoundError, match="FAISS pickle file"): + upload_index(str(tmp_path), {"name": "test"}) + + +# ────────────────────────────────────────────────────────────────────────────── +# run_agent_logic +# ────────────────────────────────────────────────────────────────────────────── + + +class TestRunAgentLogic: + @patch("application.worker.AgentCreator") + @patch("application.worker.RetrieverCreator") + @patch("application.worker.get_prompt", return_value="Test prompt") + @patch("application.worker.db") + def test_successful_run(self, mock_db, mock_get_prompt, mock_ret_creator, mock_agent_creator): + from application.worker import run_agent_logic + + mock_retriever = MagicMock() + mock_retriever.search.return_value = [{"text": "doc1"}] + mock_ret_creator.create_retriever.return_value = mock_retriever + + mock_agent = MagicMock() + mock_agent.gen.return_value = [ + {"answer": "Hello "}, + {"answer": "world"}, + {"sources": [{"title": "doc1"}]}, + {"tool_calls": [{"name": "tool1"}]}, + {"thought": "thinking..."}, + ] + mock_agent_creator.create_agent.return_value = mock_agent + + agent_config = { + "source": {}, + "retriever": "classic", + "chunks": 2, + "prompt_id": "default", + "key": "test-key", + "_id": ObjectId(), + "agent_type": "classic", + "user": "test_user", + } + + with patch("application.core.model_utils.get_api_key_for_provider", return_value="api-key"), \ + patch("application.core.model_utils.get_default_model_id", return_value="gpt-4"), \ + patch("application.core.model_utils.get_provider_from_model_id", return_value="openai"), \ + patch("application.core.model_utils.validate_model_id", return_value=False), \ + patch("application.utils.calculate_doc_token_budget", return_value=2000): + result = run_agent_logic(agent_config, "test input") + + assert result["answer"] == "Hello world" + assert result["sources"] == [{"title": "doc1"}] + assert result["tool_calls"] == [{"name": "tool1"}] + assert result["thought"] == "thinking..." + + @patch("application.worker.db") + @patch("application.worker.get_prompt", side_effect=Exception("DB error")) + def test_exception_propagated(self, mock_prompt, mock_db): + from application.worker import run_agent_logic + + with pytest.raises(Exception, match="DB error"): + run_agent_logic({"source": {}, "key": "k", "user": "u"}, "input") + + @patch("application.worker.AgentCreator") + @patch("application.worker.RetrieverCreator") + @patch("application.worker.get_prompt", return_value="prompt") + @patch("application.worker.db") + def test_with_dbref_source(self, mock_db, mock_get_prompt, mock_ret_creator, mock_agent_creator): + from bson.dbref import DBRef + from application.worker import run_agent_logic + + source_doc = {"_id": ObjectId(), "retriever": "semantic"} + mock_db.dereference.return_value = source_doc + + mock_retriever = MagicMock() + mock_retriever.search.return_value = [] + mock_ret_creator.create_retriever.return_value = mock_retriever + + mock_agent = MagicMock() + mock_agent.gen.return_value = [{"answer": "ok"}] + mock_agent_creator.create_agent.return_value = mock_agent + + agent_config = { + "source": DBRef("sources", ObjectId()), + "key": "test-key", + "_id": ObjectId(), + "user": "test_user", + } + + with patch("application.core.model_utils.get_api_key_for_provider", return_value="k"), \ + patch("application.core.model_utils.get_default_model_id", return_value="gpt-4"), \ + patch("application.core.model_utils.get_provider_from_model_id", return_value="openai"), \ + patch("application.core.model_utils.validate_model_id", return_value=False), \ + patch("application.utils.calculate_doc_token_budget", return_value=2000): + result = run_agent_logic(agent_config, "input") + + assert result["answer"] == "ok" + + @patch("application.worker.AgentCreator") + @patch("application.worker.RetrieverCreator") + @patch("application.worker.get_prompt", return_value="prompt") + @patch("application.worker.db") + def test_retriever_failure_continues(self, mock_db, mock_get_prompt, mock_ret_creator, mock_agent_creator): + from application.worker import run_agent_logic + + mock_retriever = MagicMock() + mock_retriever.search.side_effect = Exception("search failed") + mock_ret_creator.create_retriever.return_value = mock_retriever + + mock_agent = MagicMock() + mock_agent.gen.return_value = [{"answer": "still works"}] + mock_agent_creator.create_agent.return_value = mock_agent + + agent_config = { + "source": {}, + "key": "test-key", + "_id": ObjectId(), + "user": "test_user", + } + + with patch("application.core.model_utils.get_api_key_for_provider", return_value="k"), \ + patch("application.core.model_utils.get_default_model_id", return_value="gpt-4"), \ + patch("application.core.model_utils.get_provider_from_model_id", return_value="openai"), \ + patch("application.core.model_utils.validate_model_id", return_value=False), \ + patch("application.utils.calculate_doc_token_budget", return_value=2000): + result = run_agent_logic(agent_config, "input") + + assert result["answer"] == "still works" + + +# ────────────────────────────────────────────────────────────────────────────── +# ingest_worker +# ────────────────────────────────────────────────────────────────────────────── + + +class TestIngestWorker: + def _make_task(self): + task = MagicMock() + task.update_state = MagicMock() + return task + + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=500) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + def test_single_file_ingest( + self, mock_sc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_worker + + task = self._make_task() + mock_storage = MagicMock() + mock_storage.is_directory.return_value = False + mock_storage.get_file.return_value = io.BytesIO(b"file content") + mock_sc.get_storage.return_value = mock_storage + + doc = _make_doc("test content", {"title": "test.txt"}) + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + result = ingest_worker( + task, "inputs", [".txt"], "job1", + "inputs/user1/job1/test.txt", "test.txt", "user1" + ) + + assert result["name_job"] == "job1" + assert result["filename"] == "test.txt" + assert result["user"] == "user1" + assert result["limited"] is False + mock_embed.assert_called_once() + mock_upload.assert_called_once() + + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + def test_directory_ingest( + self, mock_sc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_worker + + task = self._make_task() + mock_storage = MagicMock() + mock_storage.is_directory.side_effect = lambda p: not p.endswith(".txt") + mock_storage.list_files.return_value = [ + "inputs/user1/job1/a.txt", + "inputs/user1/job1/b.txt", + ] + mock_storage.get_file.return_value = io.BytesIO(b"content") + mock_sc.get_storage.return_value = mock_storage + + doc = _make_doc("test content", {"title": "a.txt"}) + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + result = ingest_worker( + task, "inputs", [".txt"], "job1", + "inputs/user1/job1", "job1", "user1" + ) + + assert result["directory"] == "inputs" + assert mock_storage.list_files.called + + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + def test_zip_file_triggers_extraction( + self, mock_sc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_worker + + task = self._make_task() + mock_storage = MagicMock() + mock_storage.is_directory.return_value = False + + # Create a real zip in memory + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("inner.txt", "content") + zip_buf.seek(0) + mock_storage.get_file.return_value = zip_buf + mock_sc.get_storage.return_value = mock_storage + + doc = _make_doc("inner content") + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + with patch("application.worker.extract_zip_recursive") as mock_extract: + ingest_worker( + task, "inputs", [".txt"], "job1", + "inputs/user1/job1/archive.zip", "archive.zip", "user1" + ) + mock_extract.assert_called_once() + + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + def test_file_name_map_applied( + self, mock_sc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_worker + + task = self._make_task() + mock_storage = MagicMock() + mock_storage.is_directory.return_value = False + mock_storage.get_file.return_value = io.BytesIO(b"content") + mock_sc.get_storage.return_value = mock_storage + + doc = _make_doc("content", {"source": "safe_name.txt", "title": "safe_name.txt"}) + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + ingest_worker( + task, "inputs", [".txt"], "job1", + "inputs/user1/job1/safe_name.txt", "safe_name.txt", "user1", + file_name_map={"safe_name.txt": "Original Name.txt"}, + ) + + assert doc.extra_info["filename"] == "Original Name.txt" + assert doc.extra_info["title"] == "Original Name.txt" + + @patch("application.worker.StorageCreator") + def test_ingest_worker_exception_propagated(self, mock_sc): + from application.worker import ingest_worker + + task = self._make_task() + mock_sc.get_storage.side_effect = Exception("storage error") + + with pytest.raises(Exception, match="storage error"): + ingest_worker( + task, "inputs", [".txt"], "job1", + "inputs/user1/job1/test.txt", "test.txt", "user1" + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# remote_worker +# ────────────────────────────────────────────────────────────────────────────── + + +class TestRemoteWorker: + @patch("application.worker.shutil.rmtree") + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=200) + @patch("application.worker.num_tokens_from_string", return_value=50) + @patch("application.worker.Chunker") + @patch("application.worker.RemoteCreator") + def test_upload_mode( + self, mock_rc, mock_chunker_cls, mock_num_tokens, + mock_count, mock_embed, mock_upload, mock_rmtree, tmp_path + ): + from application.worker import remote_worker + + task = MagicMock() + mock_loader = MagicMock() + doc = _make_doc("content", {"file_path": "test.md", "title": "test"}) + doc.doc_id = "doc1" + mock_loader.load_data.return_value = [doc] + mock_rc.create_loader.return_value = mock_loader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + result = remote_worker( + task, "http://example.com", "job1", "user1", "web", + directory=str(tmp_path), + ) + + assert result["name_job"] == "job1" + assert result["user"] == "user1" + assert result["limited"] is False + mock_upload.assert_called_once() + + @patch("application.worker.shutil.rmtree") + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.num_tokens_from_string", return_value=10) + @patch("application.worker.Chunker") + @patch("application.worker.RemoteCreator") + def test_sync_mode( + self, mock_rc, mock_chunker_cls, mock_num_tokens, + mock_count, mock_embed, mock_upload, mock_rmtree, tmp_path + ): + from application.worker import remote_worker + + task = MagicMock() + mock_loader = MagicMock() + doc = _make_doc("content", {"file_path": "test.md"}) + doc.doc_id = "doc1" + mock_loader.load_data.return_value = [doc] + mock_rc.create_loader.return_value = mock_loader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + doc_id = str(ObjectId()) + result = remote_worker( + task, "http://example.com", "job1", "user1", "web", + directory=str(tmp_path), operation_mode="sync", doc_id=doc_id, + ) + + assert result["name_job"] == "job1" + + @patch("application.worker.shutil.rmtree") + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.num_tokens_from_string", return_value=10) + @patch("application.worker.Chunker") + @patch("application.worker.RemoteCreator") + def test_sync_mode_invalid_doc_id( + self, mock_rc, mock_chunker_cls, mock_num_tokens, + mock_count, mock_embed, mock_upload, mock_rmtree, tmp_path + ): + from application.worker import remote_worker + + task = MagicMock() + mock_loader = MagicMock() + doc = _make_doc("content", {"file_path": "test.md"}) + doc.doc_id = "doc1" + mock_loader.load_data.return_value = [doc] + mock_rc.create_loader.return_value = mock_loader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + with pytest.raises(ValueError, match="doc_id must be provided"): + remote_worker( + task, "http://example.com", "job1", "user1", "web", + directory=str(tmp_path), operation_mode="sync", doc_id="invalid", + ) + + @patch("application.worker.RemoteCreator") + def test_exception_cleans_up(self, mock_rc, tmp_path): + from application.worker import remote_worker + + task = MagicMock() + mock_rc.create_loader.side_effect = Exception("loader error") + + with pytest.raises(Exception, match="loader error"): + remote_worker( + task, "http://example.com", "job1", "user1", "web", + directory=str(tmp_path), + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# sync +# ────────────────────────────────────────────────────────────────────────────── + + +class TestSync: + @patch("application.worker.remote_worker") + def test_successful_sync(self, mock_rw): + from application.worker import sync + + task = MagicMock() + result = sync(task, "data", "job", "user", "web", "daily", "classic", "doc123") + assert result["status"] == "success" + mock_rw.assert_called_once() + + @patch("application.worker.remote_worker", side_effect=Exception("fail")) + def test_sync_error_returns_error_status(self, mock_rw): + from application.worker import sync + + task = MagicMock() + result = sync(task, "data", "job", "user", "web", "daily", "classic") + assert result["status"] == "error" + assert "fail" in result["error"] + + +# ────────────────────────────────────────────────────────────────────────────── +# sync_worker +# ────────────────────────────────────────────────────────────────────────────── + + +class TestSyncWorker: + @patch("application.worker.sync") + @patch("application.worker.sources_collection") + def test_syncs_matching_sources(self, mock_sources, mock_sync): + from application.worker import sync_worker + + mock_sources.find.return_value = [ + { + "name": "src1", + "user": "u1", + "type": "web", + "remote_data": "http://example.com", + "retriever": "classic", + "_id": ObjectId(), + "sync_frequency": "daily", + }, + { + "name": "src2", + "user": "u2", + "type": "web", + "remote_data": "http://other.com", + "retriever": "classic", + "_id": ObjectId(), + "sync_frequency": "weekly", # won't match + }, + ] + mock_sync.return_value = {"status": "success"} + + task = MagicMock() + result = sync_worker(task, "daily") + + assert result["total_sync_count"] == 1 + assert result["sync_success"] == 1 + assert result["sync_failure"] == 0 + + @patch("application.worker.sync") + @patch("application.worker.sources_collection") + def test_counts_failures(self, mock_sources, mock_sync): + from application.worker import sync_worker + + mock_sources.find.return_value = [ + { + "name": "src1", + "user": "u1", + "type": "web", + "remote_data": "data", + "retriever": "classic", + "_id": ObjectId(), + "sync_frequency": "daily", + } + ] + mock_sync.return_value = {"status": "error", "error": "fail"} + + result = sync_worker(MagicMock(), "daily") + assert result["sync_failure"] == 1 + assert result["sync_success"] == 0 + + @patch("application.worker.sources_collection") + def test_no_matching_sources(self, mock_sources): + from application.worker import sync_worker + + mock_sources.find.return_value = [] + result = sync_worker(MagicMock(), "daily") + assert result["total_sync_count"] == 0 + + +# ────────────────────────────────────────────────────────────────────────────── +# attachment_worker +# ────────────────────────────────────────────────────────────────────────────── + + +class TestAttachmentWorker: + def test_processes_and_stores_attachment(self, mock_mongo_db): + task = MagicMock() + mock_storage = MagicMock() + mock_storage.process_file.return_value = MagicMock( + text="extracted text", + extra_info={}, + ) + file_info = { + "filename": "doc.pdf", + "attachment_id": "507f1f77bcf86cd799439011", + "path": "inputs/user1/attachments/507f1f77bcf86cd799439011/doc.pdf", + "metadata": {"storage_type": "local"}, + } + + with patch( + "application.worker.StorageCreator.get_storage", + return_value=mock_storage, + ), patch("application.worker.num_tokens_from_string", return_value=50): + result = attachment_worker(task, file_info, "user1") + + assert result["filename"] == "doc.pdf" + assert result["attachment_id"] == "507f1f77bcf86cd799439011" + assert result["token_count"] == 50 + assert result["mime_type"] == "application/pdf" + + stored = mock_mongo_db["docsgpt"]["attachments"].find_one( + {"_id": ObjectId("507f1f77bcf86cd799439011")} + ) + assert stored is not None + assert stored["content"] == "extracted text" + + def test_truncates_large_content(self, mock_mongo_db): + task = MagicMock() + mock_storage = MagicMock() + mock_storage.process_file.return_value = MagicMock( + text="x" * 300000, + extra_info={}, + ) + file_info = { + "filename": "big.txt", + "attachment_id": "507f1f77bcf86cd799439012", + "path": "inputs/user1/attachments/big.txt", + "metadata": {}, + } + + token_calls = iter([200000, 50000]) + with patch( + "application.worker.StorageCreator.get_storage", + return_value=mock_storage, + ), patch("application.worker.num_tokens_from_string", side_effect=token_calls): + attachment_worker(task, file_info, "user1") + + # Content should have been truncated to 250000 chars + stored = mock_mongo_db["docsgpt"]["attachments"].find_one( + {"_id": ObjectId("507f1f77bcf86cd799439012")} + ) + assert len(stored["content"]) == 250000 + + def test_merges_transcript_metadata(self, mock_mongo_db): + task = MagicMock() + mock_storage = MagicMock() + mock_storage.process_file.return_value = MagicMock( + text="transcript text", + extra_info={ + "transcript_language": "en", + "transcript_duration_s": 10.0, + "transcript_provider": "openai", + "other_key": "ignored", + }, + ) + file_info = { + "filename": "audio.wav", + "attachment_id": "507f1f77bcf86cd799439013", + "path": "inputs/user1/attachments/audio.wav", + "metadata": {"storage_type": "local"}, + } + + with patch( + "application.worker.StorageCreator.get_storage", + return_value=mock_storage, + ), patch("application.worker.num_tokens_from_string", return_value=20): + result = attachment_worker(task, file_info, "user1") + + assert result["metadata"]["transcript_language"] == "en" + assert result["metadata"]["storage_type"] == "local" + # "other_key" should not be in metadata (doesn't start with "transcript_") + assert "other_key" not in result["metadata"] + + def test_exception_propagated(self, mock_mongo_db): + task = MagicMock() + file_info = { + "filename": "bad.txt", + "attachment_id": "507f1f77bcf86cd799439014", + "path": "inputs/user1/attachments/bad.txt", + "metadata": {}, + } + + with patch( + "application.worker.StorageCreator.get_storage", + side_effect=Exception("storage fail"), + ): + with pytest.raises(Exception, match="storage fail"): + attachment_worker(task, file_info, "user1") + + +# ────────────────────────────────────────────────────────────────────────────── +# agent_webhook_worker +# ────────────────────────────────────────────────────────────────────────────── + + +class TestAgentWebhookWorker: + @patch("application.worker.run_agent_logic") + @patch("application.worker.MongoDB") + def test_successful_webhook(self, mock_mongo, mock_run_agent): + from application.worker import agent_webhook_worker + + agent_id = str(ObjectId()) + mock_db = MagicMock() + mock_agents = MagicMock() + mock_agents.find_one.return_value = {"_id": ObjectId(agent_id), "key": "k"} + mock_db.__getitem__ = MagicMock(return_value=mock_agents) + mock_mongo.get_client.return_value = {"docsgpt": mock_db} + + mock_run_agent.return_value = {"answer": "response"} + + task = MagicMock() + result = agent_webhook_worker(task, agent_id, {"query": "hello"}) + + assert result["status"] == "success" + assert result["result"]["answer"] == "response" + + @patch("application.worker.MongoDB") + def test_agent_not_found(self, mock_mongo): + from application.worker import agent_webhook_worker + + agent_id = str(ObjectId()) + mock_db = MagicMock() + mock_agents = MagicMock() + mock_agents.find_one.return_value = None + mock_db.__getitem__ = MagicMock(return_value=mock_agents) + mock_mongo.get_client.return_value = {"docsgpt": mock_db} + + task = MagicMock() + result = agent_webhook_worker(task, agent_id, {"query": "hello"}) + + assert result["status"] == "error" + assert "not found" in result["error"] + + @patch("application.worker.run_agent_logic", side_effect=Exception("logic error")) + @patch("application.worker.MongoDB") + def test_agent_logic_failure(self, mock_mongo, mock_run_agent): + from application.worker import agent_webhook_worker + + agent_id = str(ObjectId()) + mock_db = MagicMock() + mock_agents = MagicMock() + mock_agents.find_one.return_value = {"_id": ObjectId(agent_id), "key": "k"} + mock_db.__getitem__ = MagicMock(return_value=mock_agents) + mock_mongo.get_client.return_value = {"docsgpt": mock_db} + + task = MagicMock() + result = agent_webhook_worker(task, agent_id, {"query": "hello"}) + + assert result["status"] == "error" + + +# ────────────────────────────────────────────────────────────────────────────── +# reingest_source_worker +# ────────────────────────────────────────────────────────────────────────────── + + +class TestReingestSourceWorker: + @patch("application.worker.StorageCreator") + @patch("application.worker.sources_collection") + def test_source_not_found(self, mock_sources, mock_sc): + from application.worker import reingest_source_worker + + mock_sources.find_one.return_value = None + task = MagicMock() + + with pytest.raises(ValueError, match="not found"): + reingest_source_worker(task, str(ObjectId()), "user1") + + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + @patch("application.worker.sources_collection") + def test_no_changes_detected(self, mock_sources, mock_sc, mock_reader_cls): + from application.worker import reingest_source_worker + + source_id = str(ObjectId()) + structure = {"file.txt": {"type": "text/plain", "size_bytes": 100}} + mock_sources.find_one.return_value = { + "_id": ObjectId(source_id), + "user": "user1", + "file_path": "inputs/user1/source1", + "directory_structure": json.dumps(structure), + } + + mock_storage = MagicMock() + mock_storage.is_directory.return_value = True + mock_storage.list_files.return_value = ["inputs/user1/source1/file.txt"] + mock_storage.get_file.return_value = io.BytesIO(b"content") + mock_sc.get_storage.return_value = mock_storage + + mock_reader = MagicMock() + mock_reader.directory_structure = structure + mock_reader.load_data.return_value = [] + mock_reader_cls.return_value = mock_reader + + task = MagicMock() + result = reingest_source_worker(task, source_id, "user1") + + assert result["status"] == "no_changes" + assert result["added_files"] == [] + assert result["removed_files"] == [] + + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + @patch("application.worker.sources_collection") + def test_added_and_removed_files(self, mock_sources, mock_sc, mock_reader_cls, mock_chunker_cls): + from application.worker import reingest_source_worker + + source_id = str(ObjectId()) + old_structure = { + "old_file.txt": {"type": "text/plain", "size_bytes": 100}, + } + new_structure = { + "new_file.txt": {"type": "text/plain", "size_bytes": 200}, + } + + mock_sources.find_one.return_value = { + "_id": ObjectId(source_id), + "user": "user1", + "file_path": "inputs/user1/source1", + "directory_structure": json.dumps(old_structure), + } + + mock_storage = MagicMock() + mock_storage.is_directory.return_value = True + mock_storage.list_files.return_value = ["inputs/user1/source1/new_file.txt"] + mock_storage.get_file.return_value = io.BytesIO(b"content") + mock_sc.get_storage.return_value = mock_storage + + mock_reader = MagicMock() + mock_reader.directory_structure = new_structure + mock_reader.load_data.return_value = [] + mock_reader.file_token_counts = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [] + mock_chunker_cls.return_value = mock_chunker + + mock_vector_store = MagicMock() + mock_vector_store.get_chunks.return_value = [ + {"metadata": {"source": "old_file.txt"}, "doc_id": "chunk1"} + ] + + with patch( + "application.vectorstore.vector_creator.VectorCreator.create_vectorstore", + return_value=mock_vector_store, + ): + task = MagicMock() + result = reingest_source_worker(task, source_id, "user1") + + assert result["status"] == "completed" + assert "old_file.txt" in result["removed_files"] + assert "new_file.txt" in result["added_files"] + assert result["chunks_deleted"] == 1 + + +# ────────────────────────────────────────────────────────────────────────────── +# ingest_connector +# ────────────────────────────────────────────────────────────────────────────── + + +class TestIngestConnector: + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=300) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.ConnectorCreator") + def test_successful_upload( + self, mock_cc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_connector + + task = MagicMock() + + mock_connector = MagicMock() + mock_connector.download_to_directory.return_value = { + "files_downloaded": 2, + } + mock_cc.is_supported.return_value = True + mock_cc.create_connector.return_value = mock_connector + + doc = _make_doc("content", {"source": "/tmp/test/file.txt"}) + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + result = ingest_connector( + task, "job1", "user1", "google_drive", + session_token="token123", + file_ids=["f1"], + ) + + assert result["status"] == "complete" + assert result["user"] == "user1" + mock_upload.assert_called_once() + + @patch("application.worker.ConnectorCreator") + def test_no_session_token(self, mock_cc): + from application.worker import ingest_connector + + task = MagicMock() + with pytest.raises(ValueError, match="requires session_token"): + ingest_connector(task, "job1", "user1", "google_drive") + + @patch("application.worker.ConnectorCreator") + def test_unsupported_connector(self, mock_cc): + from application.worker import ingest_connector + + task = MagicMock() + mock_cc.is_supported.return_value = False + mock_cc.get_supported_connectors.return_value = ["google_drive"] + + with pytest.raises(ValueError, match="Unsupported connector"): + ingest_connector( + task, "job1", "user1", "unknown", + session_token="token", + ) + + @patch("application.worker.ConnectorCreator") + def test_empty_download_result(self, mock_cc): + from application.worker import ingest_connector + + task = MagicMock() + mock_connector = MagicMock() + mock_connector.download_to_directory.return_value = { + "files_downloaded": 0, + "empty_result": True, + } + mock_cc.is_supported.return_value = True + mock_cc.create_connector.return_value = mock_connector + + result = ingest_connector( + task, "job1", "user1", "google_drive", + session_token="token", + ) + + assert result["tokens"] == 0 + + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.ConnectorCreator") + def test_sync_mode( + self, mock_cc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_connector + + task = MagicMock() + mock_connector = MagicMock() + mock_connector.download_to_directory.return_value = {"files_downloaded": 1} + mock_cc.is_supported.return_value = True + mock_cc.create_connector.return_value = mock_connector + + doc = _make_doc("content", {"source": "file.txt"}) + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + doc_id = str(ObjectId()) + result = ingest_connector( + task, "job1", "user1", "google_drive", + session_token="token", + operation_mode="sync", + doc_id=doc_id, + ) + + assert result["status"] == "complete" + + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.ConnectorCreator") + def test_invalid_operation_mode( + self, mock_cc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + from application.worker import ingest_connector + + task = MagicMock() + mock_connector = MagicMock() + mock_connector.download_to_directory.return_value = {"files_downloaded": 1} + mock_cc.is_supported.return_value = True + mock_cc.create_connector.return_value = mock_connector + + doc = _make_doc("content") + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + with pytest.raises(ValueError, match="Invalid operation_mode"): + ingest_connector( + task, "job1", "user1", "google_drive", + session_token="token", + operation_mode="invalid", + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# mcp_oauth +# ────────────────────────────────────────────────────────────────────────────── + + +class TestMcpOauth: + @patch("application.worker.get_redis_instance") + def test_successful_oauth(self, mock_redis_fn): + from application.worker import mcp_oauth + + mock_redis = MagicMock() + mock_redis_fn.return_value = mock_redis + + task = MagicMock() + task.request.id = "task-123" + + mock_mcp_tool = MagicMock() + mock_mcp_tool.get_actions_metadata.return_value = [ + {"name": "tool1", "description": "A tool"} + ] + + async def fake_execute(*args): + return [] + + mock_mcp_tool._client = None + mock_mcp_tool._setup_client = MagicMock() + mock_mcp_tool._execute_with_client = MagicMock( + side_effect=lambda x: fake_execute() + ) + + with patch("application.agents.tools.mcp_tool.MCPTool", return_value=mock_mcp_tool): + result = mcp_oauth(task, {"url": "http://mcp.test"}, "user1") + + assert result["success"] is True + assert result["tools_count"] == 1 + + @patch("application.worker.get_redis_instance") + def test_oauth_discovery_failure(self, mock_redis_fn): + from application.worker import mcp_oauth + + mock_redis = MagicMock() + mock_redis_fn.return_value = mock_redis + + task = MagicMock() + task.request.id = "task-456" + + mock_mcp_tool = MagicMock() + mock_mcp_tool._client = None + mock_mcp_tool._setup_client = MagicMock() + + async def fail_execute(*args): + raise Exception("connection refused") + + mock_mcp_tool._execute_with_client = MagicMock( + side_effect=lambda x: fail_execute() + ) + + with patch("application.agents.tools.mcp_tool.MCPTool", return_value=mock_mcp_tool): + result = mcp_oauth(task, {"url": "http://mcp.test"}, "user1") + + assert result["success"] is False + assert "connection refused" in result["error"] + + +# ────────────────────────────────────────────────────────────────────────────── +# mcp_oauth_status +# ────────────────────────────────────────────────────────────────────────────── + + +class TestMcpOauthStatus: + @patch("application.worker.get_redis_instance") + def test_found_status(self, mock_redis_fn): + from application.worker import mcp_oauth_status + + mock_redis = MagicMock() + status = {"status": "completed", "tools_count": 3} + mock_redis.get.return_value = json.dumps(status) + mock_redis_fn.return_value = mock_redis + + task = MagicMock() + result = mcp_oauth_status(task, "task-123") + + assert result["status"] == "completed" + assert result["tools_count"] == 3 + + @patch("application.worker.get_redis_instance") + def test_not_found(self, mock_redis_fn): + from application.worker import mcp_oauth_status + + mock_redis = MagicMock() + mock_redis.get.return_value = None + mock_redis_fn.return_value = mock_redis + + task = MagicMock() + result = mcp_oauth_status(task, "task-999") + + assert result["status"] == "not_found" + + +# ────────────────────────────────────────────────────────────────────────────── +# Additional coverage for uncovered branches +# ────────────────────────────────────────────────────────────────────────────── + + +class TestValidateZipSafetyUncompressedLimit: + def test_exceeds_uncompressed_size(self, tmp_path): + """Cover line 170: uncompressed size exceeds MAX_UNCOMPRESSED_SIZE.""" + zip_path = str(tmp_path / "test.zip") + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("small.txt", "ok") + with patch("application.worker.MAX_UNCOMPRESSED_SIZE", 1): + with pytest.raises(ZipExtractionError, match="uncompressed size exceeds"): + _validate_zip_safety(zip_path, str(tmp_path / "out")) + + +class TestRunAgentLogicValidModel: + @patch("application.worker.AgentCreator") + @patch("application.worker.RetrieverCreator") + @patch("application.worker.get_prompt", return_value="prompt") + @patch("application.worker.db") + def test_agent_default_model_used_when_valid( + self, mock_db, mock_get_prompt, mock_ret_creator, mock_agent_creator + ): + """Cover line 335: agent has valid default_model_id.""" + from application.worker import run_agent_logic + + mock_retriever = MagicMock() + mock_retriever.search.return_value = [] + mock_ret_creator.create_retriever.return_value = mock_retriever + + mock_agent = MagicMock() + mock_agent.gen.return_value = [{"answer": "ok"}] + mock_agent_creator.create_agent.return_value = mock_agent + + agent_config = { + "source": {}, + "key": "test-key", + "_id": ObjectId(), + "user": "test_user", + "default_model_id": "gpt-4o", + } + + with patch("application.core.model_utils.get_api_key_for_provider", return_value="k"), \ + patch("application.core.model_utils.get_default_model_id", return_value="gpt-4"), \ + patch("application.core.model_utils.get_provider_from_model_id", return_value="openai"), \ + patch("application.core.model_utils.validate_model_id", return_value=True), \ + patch("application.utils.calculate_doc_token_budget", return_value=2000): + result = run_agent_logic(agent_config, "input") + + assert result["answer"] == "ok" + # Verify it used the agent's default model, not the system default + mock_agent_creator.create_agent.assert_called_once() + call_kwargs = mock_agent_creator.create_agent.call_args + assert call_kwargs[1]["model_id"] == "gpt-4o" or call_kwargs.kwargs.get("model_id") == "gpt-4o" + + +class TestIngestWorkerDirectoryDownloadError: + @patch("application.worker.upload_index") + @patch("application.worker.embed_and_store_documents") + @patch("application.worker.count_tokens_docs", return_value=100) + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + def test_directory_file_download_error_skips( + self, mock_sc, mock_reader_cls, mock_chunker_cls, + mock_count, mock_embed, mock_upload + ): + """Cover lines 480-484: error downloading individual file in directory continues.""" + from application.worker import ingest_worker + + task = MagicMock() + mock_storage = MagicMock() + mock_storage.is_directory.side_effect = lambda p: not p.endswith(".txt") + mock_storage.list_files.return_value = [ + "inputs/user1/job1/a.txt", + "inputs/user1/job1/b.txt", + ] + # First file raises error, second succeeds + mock_storage.get_file.side_effect = [ + Exception("download failed"), + io.BytesIO(b"content"), + ] + mock_sc.get_storage.return_value = mock_storage + + doc = _make_doc("content", {"title": "b.txt"}) + mock_reader = MagicMock() + mock_reader.load_data.return_value = [doc] + mock_reader.directory_structure = {} + mock_reader_cls.return_value = mock_reader + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [doc] + mock_chunker_cls.return_value = mock_chunker + + result = ingest_worker( + task, "inputs", [".txt"], "job1", + "inputs/user1/job1", "job1", "user1" + ) + + assert result["name_job"] == "job1" + + +class TestReingestSourceWorkerAddedFiles: + @patch("application.worker.Chunker") + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + @patch("application.worker.sources_collection") + def test_added_files_with_chunks( + self, mock_sources, mock_sc, mock_reader_cls, mock_chunker_cls, tmp_path + ): + """Cover lines 774-850: adding chunks from new files with display names and metadata.""" + from application.worker import reingest_source_worker + + source_id = str(ObjectId()) + old_structure = {} # empty — everything is "new" + new_structure = { + "new_file.txt": {"type": "text/plain", "size_bytes": 200} + } + + mock_sources.find_one.return_value = { + "_id": ObjectId(source_id), + "user": "user1", + "file_path": "inputs/user1/source1", + "directory_structure": json.dumps(old_structure), + "file_name_map": json.dumps({"new_file.txt": "Original Name.txt"}), + } + + mock_storage = MagicMock() + mock_storage.is_directory.return_value = True + mock_storage.list_files.return_value = ["inputs/user1/source1/new_file.txt"] + mock_storage.get_file.return_value = io.BytesIO(b"new content") + mock_sc.get_storage.return_value = mock_storage + + new_doc = _make_doc("new content", {"source": "/tmp/new_file.txt"}) + + # First reader (for directory structure scan) + mock_reader1 = MagicMock() + mock_reader1.directory_structure = new_structure + mock_reader1.load_data.return_value = [] + mock_reader1.file_token_counts = {"new_file.txt": 50} + + # Second reader (for processing new files) + mock_reader2 = MagicMock() + mock_reader2.load_data.return_value = [new_doc] + mock_reader2.file_token_counts = {} + + mock_reader_cls.side_effect = [mock_reader1, mock_reader2] + + mock_chunker = MagicMock() + mock_chunker.chunk.return_value = [new_doc] + mock_chunker_cls.return_value = mock_chunker + + mock_vector_store = MagicMock() + mock_vector_store.get_chunks.return_value = [] + + # We need to ensure the file actually exists in the temp directory + # so os.path.isfile passes. Patch tempfile.TemporaryDirectory to use our tmp_path + temp_dir = str(tmp_path / "workdir") + os.makedirs(temp_dir) + # Create the "new_file.txt" so isfile check passes + (tmp_path / "workdir" / "new_file.txt").write_text("new content") + + with patch( + "application.vectorstore.vector_creator.VectorCreator.create_vectorstore", + return_value=mock_vector_store, + ), patch("tempfile.TemporaryDirectory") as mock_tmp: + mock_tmp.return_value.__enter__ = MagicMock(return_value=temp_dir) + mock_tmp.return_value.__exit__ = MagicMock(return_value=False) + task = MagicMock() + result = reingest_source_worker(task, source_id, "user1") + + assert result["status"] == "completed" + assert result["chunks_added"] == 1 + mock_vector_store.add_chunk.assert_called_once() + + +class TestReingestDirectoryDownloadError: + @patch("application.worker.SimpleDirectoryReader") + @patch("application.worker.StorageCreator") + @patch("application.worker.sources_collection") + def test_file_download_error_in_reingest_skips( + self, mock_sources, mock_sc, mock_reader_cls + ): + """Cover lines 631-645: error downloading file during reingest continues.""" + from application.worker import reingest_source_worker + + source_id = str(ObjectId()) + mock_sources.find_one.return_value = { + "_id": ObjectId(source_id), + "user": "user1", + "file_path": "inputs/user1/source1", + "directory_structure": "{}", + } + + mock_storage = MagicMock() + mock_storage.is_directory.side_effect = lambda p: not p.endswith(".txt") + mock_storage.list_files.return_value = [ + "inputs/user1/source1/fail.txt", + "inputs/user1/source1/ok.txt", + ] + mock_storage.get_file.side_effect = [ + Exception("download error"), + io.BytesIO(b"content"), + ] + mock_sc.get_storage.return_value = mock_storage + + mock_reader = MagicMock() + mock_reader.directory_structure = {} + mock_reader.load_data.return_value = [] + mock_reader_cls.return_value = mock_reader + + task = MagicMock() + result = reingest_source_worker(task, source_id, "user1") + + assert result["status"] == "no_changes" + + +class TestMcpOauthInitError: + @patch("application.worker.get_redis_instance") + def test_init_failure(self, mock_redis_fn): + """Cover lines 1497-1507: outer exception handler in mcp_oauth.""" + from application.worker import mcp_oauth + + mock_redis = MagicMock() + mock_redis_fn.return_value = mock_redis + + task = MagicMock() + task.request.id = "task-789" + + with patch( + "application.agents.tools.mcp_tool.MCPTool", + side_effect=Exception("init crash"), + ): + result = mcp_oauth(task, {"url": "http://mcp.test"}, "user1") + + assert result["success"] is False + assert "init" in result["error"].lower() From 2afdd7f02633e5b95934474cd0b4a07a31dd82d4 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 27 Mar 2026 13:43:09 +0000 Subject: [PATCH 27/41] fix: enable tools in workflow agents --- frontend/src/agents/WorkflowBuilder.tsx | 6 +++++- frontend/src/agents/workflow/WorkflowBuilder.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/agents/WorkflowBuilder.tsx b/frontend/src/agents/WorkflowBuilder.tsx index d3c7c8e1..e67249b9 100644 --- a/frontend/src/agents/WorkflowBuilder.tsx +++ b/frontend/src/agents/WorkflowBuilder.tsx @@ -40,9 +40,12 @@ import { } from '@/components/ui/select'; import { Sheet, SheetContent } from '@/components/ui/sheet'; +import { useSelector } from 'react-redux'; + import modelService from '../api/services/modelService'; import userService from '../api/services/userService'; import ArrowLeft from '../assets/arrow-left.svg'; +import { selectToken } from '../preferences/preferenceSlice'; import { WorkflowNode } from './types/workflow'; import { AgentNode, @@ -77,6 +80,7 @@ interface UserTool { function WorkflowBuilderInner() { const navigate = useNavigate(); + const token = useSelector(selectToken); const { agentId } = useParams<{ agentId?: string }>(); const [searchParams] = useSearchParams(); const folderId = searchParams.get('folder_id'); @@ -304,7 +308,7 @@ function WorkflowBuilderInner() { setAvailableModels(modelService.transformModels(modelsData.models)); } - const toolsResponse = await userService.getUserTools(null); + const toolsResponse = await userService.getUserTools(token); if (toolsResponse.ok) { const toolsData = await toolsResponse.json(); setAvailableTools(toolsData.tools); diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 5e765ea9..9a00c619 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -701,7 +701,7 @@ function WorkflowBuilderInner() { setDefaultAgentModelId(preferredDefaultModel); } - const toolsResponse = await userService.getUserTools(null); + const toolsResponse = await userService.getUserTools(token); if (toolsResponse.ok) { const toolsData = await toolsResponse.json(); setAvailableTools(toolsData.tools); From 84b2e4bab4d397a49b4d69842e6aebc4f6640219 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Mar 2026 10:00:01 +0000 Subject: [PATCH 28/41] fix: end node multi input --- .../src/agents/workflow/WorkflowBuilder.tsx | 54 +-- tests/integration/test_workflows.py | 324 ++++++++++++++++++ 2 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 tests/integration/test_workflows.py diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 9a00c619..7caf9453 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -387,31 +387,39 @@ function WorkflowBuilderInner() { [], ); - const onConnect = useCallback((params: Connection) => { - setEdges((eds) => { - const exists = eds.some( - (e) => - e.source === params.source && - e.sourceHandle === params.sourceHandle && - e.target === params.target && - e.targetHandle === params.targetHandle, - ); - if (exists) return eds; - - const filtered = eds.filter( - (e) => - !( + const onConnect = useCallback( + (params: Connection) => { + setEdges((eds) => { + const exists = eds.some( + (e) => e.source === params.source && - e.sourceHandle === (params.sourceHandle ?? null) - ) && - !( + e.sourceHandle === params.sourceHandle && e.target === params.target && - e.targetHandle === (params.targetHandle ?? null) - ), - ); - return addEdge(params, filtered); - }); - }, []); + e.targetHandle === params.targetHandle, + ); + if (exists) return eds; + + const targetNode = nodes.find((n) => n.id === params.target); + const isEndNode = targetNode?.type === 'end'; + + const filtered = eds.filter( + (e) => + !( + e.source === params.source && + e.sourceHandle === (params.sourceHandle ?? null) + ) && + // End nodes accept multiple incoming edges + (isEndNode || + !( + e.target === params.target && + e.targetHandle === (params.targetHandle ?? null) + )), + ); + return addEdge(params, filtered); + }); + }, + [nodes], + ); const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { setEdges((eds) => eds.filter((e) => e.id !== edge.id)); diff --git a/tests/integration/test_workflows.py b/tests/integration/test_workflows.py new file mode 100644 index 00000000..a28d34d5 --- /dev/null +++ b/tests/integration/test_workflows.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Integration tests for DocsGPT workflow management endpoints. + +Uses Flask test client with real MongoDB (must be running). + +Endpoints tested: +- /api/workflows (POST) - Create workflow +- /api/workflows/ (GET) - Get workflow +- /api/workflows/ (PUT) - Update workflow +- /api/workflows/ (DELETE) - Delete workflow + +Run: + pytest tests/integration/test_workflows.py -v +""" + +import time + +import pytest +from jose import jwt + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def app(): + """Create the real Flask app (connects to real MongoDB).""" + from application.app import app as flask_app + flask_app.config["TESTING"] = True + return flask_app + + +@pytest.fixture(scope="module") +def client(app): + """Flask test client. + + When AUTH_TYPE is set to simple_jwt/session_jwt a Bearer token is + injected; otherwise the backend already returns {"sub": "local"} + for every request so no token is needed. + """ + from application.core.settings import settings + + c = app.test_client() + if settings.AUTH_TYPE in ("simple_jwt", "session_jwt"): + secret = settings.JWT_SECRET_KEY + if not secret: + pytest.skip("JWT_SECRET_KEY not configured") + payload = {"sub": f"test_workflow_integration_{int(time.time())}"} + token = jwt.encode(payload, secret, algorithm="HS256") + c.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {token}" + return c + + +@pytest.fixture(scope="module") +def created_ids(): + """Accumulator for workflow IDs to clean up after all tests.""" + return [] + + +@pytest.fixture(autouse=True, scope="module") +def cleanup(client, created_ids): + """Delete all test-created workflows after the module finishes.""" + yield + for wf_id in created_ids: + try: + client.delete(f"/api/workflows/{wf_id}") + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Payload helpers +# --------------------------------------------------------------------------- + +def simple_workflow(suffix=""): + """Start -> End.""" + return { + "name": f"Simple WF {int(time.time())}{suffix}", + "description": "integration test", + "nodes": [ + {"id": "start_1", "type": "start", "title": "Start", + "position": {"x": 0, "y": 0}, "data": {}}, + {"id": "end_1", "type": "end", "title": "End", + "position": {"x": 400, "y": 0}, "data": {}}, + ], + "edges": [ + {"id": "edge_1", "source": "start_1", "target": "end_1"}, + ], + } + + +def linear_workflow(suffix=""): + """Start -> Agent -> End.""" + return { + "name": f"Linear WF {int(time.time())}{suffix}", + "description": "integration test", + "nodes": [ + {"id": "start_1", "type": "start", "title": "Start", + "position": {"x": 0, "y": 0}, "data": {}}, + {"id": "agent_1", "type": "agent", "title": "Agent", + "position": {"x": 200, "y": 0}, "data": { + "agent_type": "classic", + "system_prompt": "You are helpful.", + "prompt_template": "", + "stream_to_user": False, + }}, + {"id": "end_1", "type": "end", "title": "End", + "position": {"x": 400, "y": 0}, "data": {}}, + ], + "edges": [ + {"id": "edge_1", "source": "start_1", "target": "agent_1"}, + {"id": "edge_2", "source": "agent_1", "target": "end_1"}, + ], + } + + +def multi_input_end_workflow(suffix=""): + """Condition branches into two agents, both converging on one end node. + + Graph: + start -> condition --(case_1)--> agent_a --\ + --(else)----> agent_b ---+--> end + """ + return { + "name": f"Multi-Input End {int(time.time())}{suffix}", + "description": "end node with multiple inputs", + "nodes": [ + {"id": "start_1", "type": "start", "title": "Start", + "position": {"x": 0, "y": 100}, "data": {}}, + {"id": "cond_1", "type": "condition", "title": "Branch", + "position": {"x": 200, "y": 100}, "data": { + "mode": "simple", + "cases": [ + {"name": "Case 1", "expression": "true", + "sourceHandle": "case_1"}, + ], + }}, + {"id": "agent_a", "type": "agent", "title": "Agent A", + "position": {"x": 400, "y": 0}, "data": { + "agent_type": "classic", + "system_prompt": "Branch A", + "prompt_template": "", + "stream_to_user": False, + }}, + {"id": "agent_b", "type": "agent", "title": "Agent B", + "position": {"x": 400, "y": 200}, "data": { + "agent_type": "classic", + "system_prompt": "Branch B", + "prompt_template": "", + "stream_to_user": False, + }}, + {"id": "end_1", "type": "end", "title": "End", + "position": {"x": 600, "y": 100}, "data": {}}, + ], + "edges": [ + {"id": "e1", "source": "start_1", "target": "cond_1"}, + {"id": "e2", "source": "cond_1", "target": "agent_a", + "sourceHandle": "case_1"}, + {"id": "e3", "source": "cond_1", "target": "agent_b", + "sourceHandle": "else"}, + # Both agents feed into the SAME end node + {"id": "e4", "source": "agent_a", "target": "end_1"}, + {"id": "e5", "source": "agent_b", "target": "end_1"}, + ], + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _extract_id(resp): + """Pull workflow id from create/update response.""" + body = resp.get_json() + data = body.get("data") or body + return data.get("id") + + +def _get_graph(client, wf_id): + """Fetch workflow and return (nodes, edges).""" + resp = client.get(f"/api/workflows/{wf_id}") + assert resp.status_code == 200, resp.get_data(as_text=True) + body = resp.get_json() + data = body.get("data") or body + return data.get("nodes", []), data.get("edges", []) + + +# =========================================================================== +# CRUD tests +# =========================================================================== + + +class TestWorkflowCRUD: + + def test_create_simple_workflow(self, client, created_ids): + resp = client.post("/api/workflows", json=simple_workflow()) + assert resp.status_code in (200, 201), resp.get_data(as_text=True) + wf_id = _extract_id(resp) + assert wf_id + created_ids.append(wf_id) + + def test_create_linear_workflow(self, client, created_ids): + resp = client.post("/api/workflows", json=linear_workflow()) + assert resp.status_code in (200, 201), resp.get_data(as_text=True) + wf_id = _extract_id(resp) + assert wf_id + created_ids.append(wf_id) + + def test_get_workflow_returns_nodes_and_edges(self, client, created_ids): + resp = client.post("/api/workflows", json=simple_workflow(" get")) + wf_id = _extract_id(resp) + created_ids.append(wf_id) + + nodes, edges = _get_graph(client, wf_id) + assert len(nodes) == 2 + assert len(edges) == 1 + + def test_update_workflow(self, client, created_ids): + resp = client.post("/api/workflows", json=simple_workflow(" upd")) + wf_id = _extract_id(resp) + created_ids.append(wf_id) + + update_resp = client.put( + f"/api/workflows/{wf_id}", json=linear_workflow(" updated") + ) + assert update_resp.status_code == 200, update_resp.get_data(as_text=True) + + nodes, edges = _get_graph(client, wf_id) + assert len(nodes) == 3 # start, agent, end + assert len(edges) == 2 + + def test_delete_workflow(self, client): + resp = client.post("/api/workflows", json=simple_workflow(" del")) + wf_id = _extract_id(resp) + + del_resp = client.delete(f"/api/workflows/{wf_id}") + assert del_resp.status_code == 200 + + get_resp = client.get(f"/api/workflows/{wf_id}") + assert get_resp.status_code in (400, 404) + + def test_reject_workflow_without_end_node(self, client): + payload = { + "name": "No End", + "nodes": [ + {"id": "s", "type": "start", "title": "Start", + "position": {"x": 0, "y": 0}, "data": {}}, + ], + "edges": [], + } + resp = client.post("/api/workflows", json=payload) + assert resp.status_code == 400, resp.get_data(as_text=True) + + +# =========================================================================== +# Multi-input end node tests +# =========================================================================== + + +class TestMultiInputEndNode: + """Verify that an end node can receive edges from multiple source nodes.""" + + def test_create_multi_input_end_workflow_accepted(self, client, created_ids): + """Backend must accept a workflow where two edges target the same end node.""" + resp = client.post("/api/workflows", json=multi_input_end_workflow()) + assert resp.status_code in (200, 201), resp.get_data(as_text=True) + wf_id = _extract_id(resp) + assert wf_id + created_ids.append(wf_id) + + def test_multi_input_end_all_edges_persisted(self, client, created_ids): + """After round-trip, both edges into the end node must still be present.""" + resp = client.post( + "/api/workflows", json=multi_input_end_workflow(" persist") + ) + assert resp.status_code in (200, 201), resp.get_data(as_text=True) + wf_id = _extract_id(resp) + created_ids.append(wf_id) + + nodes, edges = _get_graph(client, wf_id) + + # Locate end node + end_ids = {n["id"] for n in nodes if n["type"] == "end"} + assert end_ids, "no end node in response" + + # Count edges targeting any end node + edges_to_end = [e for e in edges if e["target"] in end_ids] + assert len(edges_to_end) >= 2, ( + f"Expected >=2 edges to end, got {len(edges_to_end)}: {edges_to_end}" + ) + + def test_multi_input_end_total_edge_count(self, client, created_ids): + """All 5 edges of the multi-input graph must survive persistence.""" + resp = client.post( + "/api/workflows", json=multi_input_end_workflow(" count") + ) + wf_id = _extract_id(resp) + created_ids.append(wf_id) + + _, edges = _get_graph(client, wf_id) + assert len(edges) == 5, f"Expected 5 edges, got {len(edges)}" + + def test_update_to_multi_input_end_preserves_edges(self, client, created_ids): + """Updating a simple workflow to multi-input end keeps all edges.""" + # Create simple + resp = client.post("/api/workflows", json=simple_workflow(" pre")) + wf_id = _extract_id(resp) + created_ids.append(wf_id) + + # Update to multi-input end + update_resp = client.put( + f"/api/workflows/{wf_id}", + json=multi_input_end_workflow(" post"), + ) + assert update_resp.status_code == 200, update_resp.get_data(as_text=True) + + nodes, edges = _get_graph(client, wf_id) + end_ids = {n["id"] for n in nodes if n["type"] == "end"} + edges_to_end = [e for e in edges if e["target"] in end_ids] + assert len(edges_to_end) >= 2, ( + f"Expected >=2 edges to end after update, got {len(edges_to_end)}" + ) From 8dec60ab8bbf449e3618d59614abd2d41c9e5d34 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Mar 2026 11:08:10 +0000 Subject: [PATCH 29/41] Update demo GIF in README Replaced demo video GIF in README with a new example. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6e02cc9a..2480a1f4 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

-video-example-of-docs-gpt +video-example-of-docs-gpt

Key Features: From ddbfd94193a8a60fa139cdce34c11e358dd8a1ac Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Mar 2026 11:09:05 +0000 Subject: [PATCH 30/41] Adjust demo GIF height in README Update the height of the demo GIF in README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2480a1f4..b83f514d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

-video-example-of-docs-gpt +video-example-of-docs-gpt

Key Features: From 47dcbcb74b1e8233ff36580bc4fe7624b9edfa0c Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Mar 2026 12:03:16 +0000 Subject: [PATCH 31/41] fix: tests and sources on workflow agent --- .../src/agents/workflow/WorkflowBuilder.tsx | 40 ++- frontend/src/components/ui/multi-select.tsx | 8 +- pytest.ini | 3 +- tests/integration/test_workflows.py | 253 ++++++++++++++++++ 4 files changed, 296 insertions(+), 8 deletions(-) diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 7caf9453..ad4fec28 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -54,7 +54,10 @@ import { FileUpload } from '../../components/FileUpload'; import AgentDetailsModal from '../../modals/AgentDetailsModal'; import ConfirmationModal from '../../modals/ConfirmationModal'; import { ActiveState } from '../../models/misc'; -import { selectToken } from '../../preferences/preferenceSlice'; +import { + selectSourceDocs, + selectToken, +} from '../../preferences/preferenceSlice'; import { getToolDisplayName } from '../../utils/toolUtils'; import { Agent } from '../types'; import { ConditionCase, WorkflowNode } from '../types/workflow'; @@ -300,6 +303,7 @@ function createWorkflowPayload( function WorkflowBuilderInner() { const navigate = useNavigate(); const token = useSelector(selectToken); + const sourceDocs = useSelector(selectSourceDocs); const { agentId } = useParams<{ agentId?: string }>(); const [searchParams] = useSearchParams(); const folderId = searchParams.get('folder_id'); @@ -341,6 +345,14 @@ function WorkflowBuilderInner() { const [availableModels, setAvailableModels] = useState([]); const [defaultAgentModelId, setDefaultAgentModelId] = useState(''); const [availableTools, setAvailableTools] = useState([]); + const sourceOptions = useMemo( + () => + (sourceDocs ?? []).map((doc) => ({ + value: doc.id ?? 'default', + label: doc.name, + })), + [sourceDocs], + ); const [agentJsonSchemaDrafts, setAgentJsonSchemaDrafts] = useState< Record >({}); @@ -1279,8 +1291,8 @@ function WorkflowBuilderInner() { const handlePrimaryAction = useCallback(() => { if (isPrimaryActionDisabled) return; - void persistWorkflow(!canManageAgent); - }, [isPrimaryActionDisabled, persistWorkflow, canManageAgent]); + void persistWorkflow(false); + }, [isPrimaryActionDisabled, persistWorkflow]); const agentForDetails = useMemo( () => ({ @@ -1918,6 +1930,28 @@ function WorkflowBuilderInner() { emptyText="No tools available" />

+
+ + + handleUpdateNodeData({ + config: { + ...(selectedNode.data.config || {}), + sources: newSources, + }, + }) + } + placeholder="Select sources..." + searchPlaceholder="Search sources..." + emptyText="No sources available" + /> +