diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f63691a1d6..1bb887227f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -846,24 +846,35 @@ jobs: run: | echo "Listing downloaded files:" find reports-download -type f - cp reports-download/.coverage* . 2>/dev/null || echo "No coverage files found" - coverage combine - coverage report + cp reports-download/.coverage* $GITHUB_WORKSPACE/ 2>/dev/null || echo "No coverage files found" + cd $GITHUB_WORKSPACE + coverage combine || echo "::warning::No coverage data to combine" + coverage report || echo "::warning::No coverage report generated" + ls -la .coverage* || true + + - name: Verify .coverage file exists + run: | + if [ ! -f $GITHUB_WORKSPACE/.coverage ]; then + echo "::error::No .coverage file found after combining coverage!" + exit 1 + fi - name: Upload combined coverage report uses: actions/upload-artifact@v4 with: name: backend-coverage-report - path: .coverage + path: ${{ github.workspace }}/.coverage + include-hidden-files: true retention-days: 30 overwrite: true - name: Comment coverage report on PR - uses: py-cov-action/python-coverage-comment-action@v3 if: github.event_name == 'pull_request' + uses: py-cov-action/python-coverage-comment-action@v3.38 with: GITHUB_TOKEN: ${{ github.token }} MERGE_COVERAGE_FILES: false + COVERAGE_PATH: ${{ github.workspace }} # ========================================================================== # Build and publish stage - builds production grade images and publishes diff --git a/backend/.flake8 b/backend/.flake8 index 560241e7c0..b847dcc730 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -2,12 +2,12 @@ extend-ignore = E203, W503, F541, E501 max-doc-length = 88 per-file-ignores = - tests/*: F841 - ../premium/backend/tests/*: F841 - ../enterprise/backend/tests/*: F841 - src/baserow/contrib/database/migrations/*: X1 - src/baserow/core/migrations/*: X1 - src/baserow/core/psycopg.py: BRP001 + tests/*: F841, BAI001 + ../premium/backend/tests/*: F841, BAI001 + ../enterprise/backend/tests/*: F841, BAI001 + src/baserow/contrib/database/migrations/*: BDC001 + src/baserow/core/migrations/*: BDC001 + src/baserow/core/psycopg.py: BPG001 exclude = .git, __pycache__, @@ -16,6 +16,7 @@ exclude = [flake8:local-plugins] extension = - X1 = flake8_baserow:DocstringPlugin - BRP001 = flake8_baserow:BaserowPsycopgChecker + BDC001 = flake8_baserow:DocstringPlugin + BPG001 = flake8_baserow:BaserowPsycopgChecker + BAI001 = flake8_baserow:BaserowAIImportsChecker paths = ./flake8_plugins diff --git a/backend/flake8_plugins/__init__.py b/backend/flake8_plugins/__init__.py index 5f48e1dd2f..119c6443b7 100644 --- a/backend/flake8_plugins/__init__.py +++ b/backend/flake8_plugins/__init__.py @@ -1 +1 @@ -from .flake8_baserow import DocstringPlugin, BaserowPsycopgChecker +from .flake8_baserow import DocstringPlugin, BaserowPsycopgChecker, BaserowAIImportsChecker diff --git a/backend/flake8_plugins/flake8_baserow/__init__.py b/backend/flake8_plugins/flake8_baserow/__init__.py index 1caa7e9514..1766a8d398 100644 --- a/backend/flake8_plugins/flake8_baserow/__init__.py +++ b/backend/flake8_plugins/flake8_baserow/__init__.py @@ -1,4 +1,5 @@ from .docstring import Plugin as DocstringPlugin from .psycopg import BaserowPsycopgChecker +from .ai_imports import BaserowAIImportsChecker -__all__ = ["DocstringPlugin", "BaserowPsycopgChecker"] +__all__ = ["DocstringPlugin", "BaserowPsycopgChecker", "BaserowAIImportsChecker"] diff --git a/backend/flake8_plugins/flake8_baserow/ai_imports.py b/backend/flake8_plugins/flake8_baserow/ai_imports.py new file mode 100644 index 0000000000..8d47707eff --- /dev/null +++ b/backend/flake8_plugins/flake8_baserow/ai_imports.py @@ -0,0 +1,86 @@ +import ast +from typing import Iterator, Tuple, Any + + +class BaserowAIImportsChecker: + """ + Flake8 plugin to ensure dspy and litellm are only imported locally within + functions/methods, not at module level. + """ + + name = "flake8-baserow-ai-imports" + version = "0.1.0" + + def __init__(self, tree: ast.AST, filename: str): + self.tree = tree + self.filename = filename + + def run(self) -> Iterator[Tuple[int, int, str, Any]]: + """Check for global imports of dspy and litellm.""" + for node in ast.walk(self.tree): + # Check if this is a module-level import (not inside a function/method) + if self._is_global_import(node): + if isinstance(node, ast.Import): + for alias in node.names: + if self._is_ai_module(alias.name): + yield ( + node.lineno, + node.col_offset, + f"BAI001 {alias.name} must be imported locally within functions/methods, not globally", + type(self), + ) + elif isinstance(node, ast.ImportFrom): + if node.module and self._is_ai_module(node.module): + yield ( + node.lineno, + node.col_offset, + f"BAI001 {node.module} must be imported locally within functions/methods, not globally", + type(self), + ) + + def _is_ai_module(self, module_name: str) -> bool: + """Check if the module is dspy or litellm (including submodules).""" + if not module_name: + return False + return ( + module_name == "dspy" + or module_name.startswith("dspy.") + or module_name == "litellm" + or module_name.startswith("litellm.") + ) + + def _is_global_import(self, node: ast.AST) -> bool: + """ + Check if an import node is at global scope. + Returns True if the import is not nested inside a function or method. + """ + if not isinstance(node, (ast.Import, ast.ImportFrom)): + return False + + # Walk up the AST to find if this import is inside a function/method + # We need to check the parent nodes, but ast.walk doesn't provide parent info + # So we'll traverse the tree differently + return self._check_node_is_global(self.tree, node) + + def _check_node_is_global( + self, root: ast.AST, target: ast.AST, in_function: bool = False + ) -> bool: + """ + Recursively check if target node is at global scope. + Returns True if the target is found at global scope (not in a function). + """ + if root is target: + return not in_function + + # Check if we're entering a function/method + new_in_function = in_function or isinstance( + root, (ast.FunctionDef, ast.AsyncFunctionDef) + ) + + # Recursively check all child nodes + for child in ast.iter_child_nodes(root): + result = self._check_node_is_global(child, target, new_in_function) + if result is not None: + return result + + return None diff --git a/backend/flake8_plugins/flake8_baserow/docstring.py b/backend/flake8_plugins/flake8_baserow/docstring.py index 189cf71083..342468b0b2 100644 --- a/backend/flake8_plugins/flake8_baserow/docstring.py +++ b/backend/flake8_plugins/flake8_baserow/docstring.py @@ -18,7 +18,7 @@ DocstringType = Union[ast.Constant, ast.Str] -ERR_MSG = "X1 - Baserow plugin: missing empty line after docstring" +ERR_MSG = "BDC001 - Baserow plugin: missing empty line after docstring" class Token: diff --git a/backend/flake8_plugins/flake8_baserow/psycopg.py b/backend/flake8_plugins/flake8_baserow/psycopg.py index 25e57a1a3e..08cadbdceb 100644 --- a/backend/flake8_plugins/flake8_baserow/psycopg.py +++ b/backend/flake8_plugins/flake8_baserow/psycopg.py @@ -1,9 +1,10 @@ import ast from typing import Iterator, Tuple, Any + class BaserowPsycopgChecker: - name = 'flake8-baserow-psycopg' - version = '0.1.0' + name = "flake8-baserow-psycopg" + version = "0.1.0" def __init__(self, tree: ast.AST, filename: str): self.tree = tree @@ -13,18 +14,18 @@ def run(self) -> Iterator[Tuple[int, int, str, Any]]: for node in ast.walk(self.tree): if isinstance(node, ast.Import): for alias in node.names: - if alias.name in ('psycopg', 'psycopg2'): + if alias.name in ("psycopg", "psycopg2"): yield ( node.lineno, node.col_offset, - 'BRP001 Import psycopg/psycopg2 from baserow.core.psycopg instead', - type(self) + "BPG001 Import psycopg/psycopg2 from baserow.core.psycopg instead", + type(self), ) elif isinstance(node, ast.ImportFrom): - if node.module in ('psycopg', 'psycopg2'): + if node.module in ("psycopg", "psycopg2"): yield ( node.lineno, node.col_offset, - 'BRP001 Import psycopg/psycopg2 from baserow.core.psycopg instead', - type(self) - ) \ No newline at end of file + "BPG001 Import psycopg/psycopg2 from baserow.core.psycopg instead", + type(self), + ) diff --git a/backend/flake8_plugins/tests/test_flake8_baserow_ai_imports.py b/backend/flake8_plugins/tests/test_flake8_baserow_ai_imports.py new file mode 100644 index 0000000000..e36106ce7b --- /dev/null +++ b/backend/flake8_plugins/tests/test_flake8_baserow_ai_imports.py @@ -0,0 +1,212 @@ +import ast +from flake8_baserow.ai_imports import BaserowAIImportsChecker + + +def run_checker(code: str): + """Helper to run the checker on code and return errors.""" + tree = ast.parse(code) + checker = BaserowAIImportsChecker(tree, "test.py") + return list(checker.run()) + + +def test_global_dspy_import(): + """Test that global dspy imports are flagged.""" + code = """ +import dspy +""" + errors = run_checker(code) + assert len(errors) == 1 + assert "BAI001" in errors[0][2] + assert "dspy" in errors[0][2] + + +def test_global_litellm_import(): + """Test that global litellm imports are flagged.""" + code = """ +import litellm +""" + errors = run_checker(code) + assert len(errors) == 1 + assert "BAI001" in errors[0][2] + assert "litellm" in errors[0][2] + + +def test_global_dspy_from_import(): + """Test that global 'from dspy import' statements are flagged.""" + code = """ +from dspy import ChainOfThought +from dspy.predict import Predict +""" + errors = run_checker(code) + assert len(errors) == 2 + assert all("BAI001" in error[2] for error in errors) + + +def test_global_litellm_from_import(): + """Test that global 'from litellm import' statements are flagged.""" + code = """ +from litellm import completion +from litellm.utils import get_llm_provider +""" + errors = run_checker(code) + assert len(errors) == 2 + assert all("BAI001" in error[2] for error in errors) + + +def test_local_import_in_function(): + """Test that local imports within functions are allowed.""" + code = """ +def my_function(): + import dspy + import litellm + from dspy import ChainOfThought + from litellm import completion + return dspy, litellm +""" + errors = run_checker(code) + assert len(errors) == 0 + + +def test_local_import_in_method(): + """Test that local imports within class methods are allowed.""" + code = """ +class MyClass: + def my_method(self): + import dspy + from litellm import completion + return dspy.ChainOfThought() +""" + errors = run_checker(code) + assert len(errors) == 0 + + +def test_local_import_in_async_function(): + """Test that local imports within async functions are allowed.""" + code = """ +async def my_async_function(): + import dspy + from litellm import acompletion + return await acompletion() +""" + errors = run_checker(code) + assert len(errors) == 0 + + +def test_mixed_global_and_local_imports(): + """Test that global imports are flagged while local imports are not.""" + code = """ +import dspy # This should be flagged + +def my_function(): + import litellm # This should be OK + return litellm.completion() + +from dspy import ChainOfThought # This should be flagged +""" + errors = run_checker(code) + assert len(errors) == 2 + assert all("BAI001" in error[2] for error in errors) + + +def test_nested_function_imports(): + """Test that imports in nested functions are allowed.""" + code = """ +def outer_function(): + def inner_function(): + import dspy + from litellm import completion + return dspy, completion + return inner_function() +""" + errors = run_checker(code) + assert len(errors) == 0 + + +def test_other_imports_not_affected(): + """Test that other imports are not flagged.""" + code = """ +import os +import sys +from typing import List +from baserow.core.models import User +""" + errors = run_checker(code) + assert len(errors) == 0 + + +def test_multiple_global_imports(): + """Test multiple global AI imports.""" + code = """ +import dspy +import litellm +from dspy import ChainOfThought +from litellm import completion +import os # This should not be flagged +""" + errors = run_checker(code) + assert len(errors) == 4 + assert all("BAI001" in error[2] for error in errors) + + +def test_import_with_alias(): + """Test that imports with aliases are also caught.""" + code = """ +import dspy as d +import litellm as llm + +def my_function(): + import dspy as local_d + return local_d +""" + errors = run_checker(code) + assert len(errors) == 2 + assert all("BAI001" in error[2] for error in errors) + + +def test_submodule_imports(): + """Test that submodule imports are caught at global scope.""" + code = """ +from dspy.teleprompt import BootstrapFewShot +from litellm.utils import token_counter + +def my_function(): + from dspy.predict import Predict + from litellm.integrations import log_event + return Predict, log_event +""" + errors = run_checker(code) + assert len(errors) == 2 + assert all("BAI001" in error[2] for error in errors) + # Verify the errors are for the global imports + assert errors[0][0] == 2 # Line number of first import + assert errors[1][0] == 3 # Line number of second import + + +def test_class_method_and_staticmethod(): + """Test that imports in classmethods and staticmethods are allowed.""" + code = """ +class MyClass: + @classmethod + def my_classmethod(cls): + import dspy + return dspy + + @staticmethod + def my_staticmethod(): + from litellm import completion + return completion +""" + errors = run_checker(code) + assert len(errors) == 0 + + +def test_lambda_not_considered_function(): + """Test that imports in lambdas (which aren't supported anyway) at module level are flagged.""" + code = """ +# Note: This is contrived since you can't actually have imports in lambdas, +# but this tests that lambda doesn't count as a function scope +import dspy +""" + errors = run_checker(code) + assert len(errors) == 1 + assert "BAI001" in errors[0][2] diff --git a/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py b/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py index bb904137e4..725224739b 100644 --- a/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py +++ b/backend/flake8_plugins/tests/test_flake8_baserow_psycopg.py @@ -4,35 +4,38 @@ def run_checker(code: str): tree = ast.parse(code) - checker = BaserowPsycopgChecker(tree, 'test.py') + checker = BaserowPsycopgChecker(tree, "test.py") return list(checker.run()) + def test_direct_import(): - code = ''' + code = """ import psycopg import psycopg2 from psycopg import connect from psycopg2 import connect as pg_connect - ''' + """ errors = run_checker(code) assert len(errors) == 4 - assert all(error[2].startswith('BRP001') for error in errors) + assert all(error[2].startswith("BPG001") for error in errors) + def test_allowed_import(): - code = ''' + code = """ from baserow.core.psycopg import connect from baserow.core.psycopg import psycopg2 - ''' + """ errors = run_checker(code) assert len(errors) == 0 + def test_mixed_imports(): - code = ''' + code = """ import psycopg from baserow.core.psycopg import connect from psycopg2 import connect as pg_connect - ''' + """ errors = run_checker(code) assert len(errors) == 2 - assert errors[0][2].startswith('BRP001') - assert errors[1][2].startswith('BRP001') \ No newline at end of file + assert errors[0][2].startswith("BPG001") + assert errors[1][2].startswith("BPG001") diff --git a/backend/src/baserow/contrib/database/fields/utils/duration.py b/backend/src/baserow/contrib/database/fields/utils/duration.py index 3f6971d099..1cdf4c1de3 100644 --- a/backend/src/baserow/contrib/database/fields/utils/duration.py +++ b/backend/src/baserow/contrib/database/fields/utils/duration.py @@ -708,7 +708,7 @@ def text_value_sql_to_duration(field: "DurationField") -> str: if is_psycopg3: - from psycopg.types.datetime import IntervalLoader # noqa: BRP001 + from psycopg.types.datetime import IntervalLoader # noqa: BPG001 from baserow.core.psycopg import psycopg diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_utils.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_utils.py index 3070a152b8..1b02d0d318 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/test_utils.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_utils.py @@ -391,7 +391,16 @@ def test_prepare_file_for_db_with_existing_file(data_fixture): @pytest.mark.django_db +@responses.activate def test_prepare_file_for_db_with_mix(data_fixture, fake): + picsum_url = "https://picsum.photos/300/200" + responses.add( + responses.GET, + picsum_url, + status=200, + body=fake.image((300, 200)), + ) + user = data_fixture.create_user() user_file = data_fixture.create_user_file( original_name=f"a.txt", @@ -407,7 +416,7 @@ def test_prepare_file_for_db_with_mix(data_fixture, fake): { "__file__": True, "name": "filename", - "url": "https://picsum.photos/300/200", + "url": picsum_url, }, { "__file__": True, @@ -432,7 +441,7 @@ def test_prepare_file_for_db_with_mix(data_fixture, fake): "image_height": 200, "image_width": 300, "is_image": True, - "mime_type": "image/jpeg", + "mime_type": "image/png", "name": AnyStr(), "size": AnyInt(), "uploaded_at": AnyStr(), diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index 8404f5cbfd..b58fc27d49 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -308,6 +308,7 @@ def ready(self): CreateTablesToolType, CreateViewFiltersToolType, CreateViewsToolType, + GenerateDatabaseFormulaToolType, GetRowsToolsToolType, GetTablesSchemaToolType, ListDatabasesToolType, @@ -330,13 +331,11 @@ def ready(self): assistant_tool_registry.register(CreateTablesToolType()) assistant_tool_registry.register(GetTablesSchemaToolType()) assistant_tool_registry.register(CreateFieldsToolType()) - + assistant_tool_registry.register(GenerateDatabaseFormulaToolType()) assistant_tool_registry.register(ListRowsToolType()) assistant_tool_registry.register(GetRowsToolsToolType()) - assistant_tool_registry.register(ListViewsToolType()) assistant_tool_registry.register(CreateViewsToolType()) - assistant_tool_registry.register(CreateViewFiltersToolType()) # The signals must always be imported last because they use the registries diff --git a/enterprise/backend/src/baserow_enterprise/assistant/assistant.py b/enterprise/backend/src/baserow_enterprise/assistant/assistant.py index 10f73c44d4..0ba3756afa 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/assistant.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/assistant.py @@ -1,10 +1,14 @@ +from dataclasses import dataclass from functools import lru_cache -from typing import Any, AsyncGenerator, TypedDict +from typing import Any, AsyncGenerator, Callable, TypedDict from django.conf import settings +from django.utils import translation from baserow.api.sessions import get_client_undo_redo_action_group_id from baserow_enterprise.assistant.exceptions import AssistantModelNotSupportedError +from baserow_enterprise.assistant.tools.navigation.types import AnyNavigationRequestType +from baserow_enterprise.assistant.tools.navigation.utils import unsafe_navigate_to from baserow_enterprise.assistant.tools.registries import assistant_tool_registry from .adapter import get_chat_adapter @@ -21,6 +25,12 @@ ) +@dataclass +class ToolHelpers: + update_status: Callable[[str], None] + navigate_to: Callable[["AnyNavigationRequestType"], str] + + class AssistantMessagePair(TypedDict): question: str answer: str @@ -140,8 +150,9 @@ def _init_lm_client(self): def _init_assistant(self): from .react import ReAct # local import to save memory when not used + tool_helpers = self.get_tool_helpers() tools = assistant_tool_registry.list_all_usable_tools( - self._user, self._workspace + self._user, self._workspace, tool_helpers ) self._assistant = ReAct(get_chat_signature(), tools=tools) self.history = None @@ -278,6 +289,28 @@ def check_llm_ready_or_raise(self): f"The model '{lm.model}' is not supported or accessible: {e}" ) + def get_tool_helpers(self) -> ToolHelpers: + from dspy.dsp.utils.settings import settings as dspy_settings + from dspy.streaming.messages import sync_send_to_stream + + def update_status_localized(status: str): + """ + Sends a localized message to the frontend to update the assistant status. + + :param status: The status message to send. + """ + + with translation.override(self._user.profile.language): + stream = dspy_settings.send_stream + + if stream is not None: + sync_send_to_stream(stream, AiThinkingMessage(content=status)) + + return ToolHelpers( + update_status=update_status_localized, + navigate_to=unsafe_navigate_to, + ) + async def astream_messages( self, human_message: HumanMessage ) -> AsyncGenerator[AssistantMessageUnion, None]: diff --git a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py index f4f8762e75..837b551abc 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py @@ -51,7 +51,7 @@ ASSISTANT_SYSTEM_PROMPT = ( """ -You are Baserow Assistant, an AI expert for Baserow (open-source no-code platform). +You are Kuma, an AI expert for Baserow (open-source no-code platform). ## YOUR KNOWLEDGE 1. **Core concepts** (below) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/react.py b/enterprise/backend/src/baserow_enterprise/assistant/react.py index e2c50f8e9c..08ecef15de 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/react.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/react.py @@ -1,11 +1,13 @@ from typing import Any, Callable, Literal -import dspy -from dspy.adapters.types.tool import Tool -from dspy.predict.react import _fmt_exc -from dspy.primitives.module import Module -from dspy.signatures.signature import ensure_signature -from litellm import ContextWindowExceededError +# This file is only imported when the assistant/react.py module is used, +# so the flake8 plugin will not complain about global imports of dspy and litell +import dspy # noqa: BAI001 +from dspy.adapters.types.tool import Tool # noqa: BAI001 +from dspy.predict.react import _fmt_exc # noqa: BAI001 +from dspy.primitives.module import Module # noqa: BAI001 +from dspy.signatures.signature import ensure_signature # noqa: BAI001 +from litellm import ContextWindowExceededError # noqa: BAI001 from loguru import logger from .types import ToolsUpgradeResponse @@ -74,6 +76,9 @@ def _build_instructions(self) -> list[str]: "After each tool call, you receive a resulting observation, which gets appended to your trajectory.\n", "When writing next_thought, you may reason about the current situation and plan for future steps.", "When selecting the next_tool_name and its next_tool_args, the tool must be one of:\n", + "Always DO the task with tools, never EXPLAIN how to do it. Return instructions only when you lack the necessary tools to complete the request.\n", + "Never assume a tool cannot be used based on your prior knowledge. If a tool exists that can help you, you MUST use it.\n", + "If you create new resources outside of your current visible context, like tables, views, fields or rows, you can navigate to them using the navigation tool.\n", ] ) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py index 87b306626d..f6938f2cbd 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py @@ -1,14 +1,21 @@ -from typing import Any, Callable, Literal, Tuple +from typing import TYPE_CHECKING, Any, Callable, Literal, Tuple from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.utils.translation import gettext as _ +from baserow_premium.prompts import get_formula_docs from loguru import logger from pydantic import create_model -from baserow.contrib.database.fields.actions import UpdateFieldActionType +from baserow.contrib.database.api.formula.serializers import TypeFormulaResultSerializer +from baserow.contrib.database.fields.actions import ( + CreateFieldActionType, + DeleteFieldActionType, + UpdateFieldActionType, +) +from baserow.contrib.database.fields.models import FormulaField from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.models import Database from baserow.contrib.database.table.actions import CreateTableActionType @@ -21,7 +28,7 @@ from baserow.core.actions import CreateApplicationActionType from baserow.core.models import Workspace from baserow.core.service import CoreService -from baserow_enterprise.assistant.tools.registries import AssistantToolType, ToolHelpers +from baserow_enterprise.assistant.tools.registries import AssistantToolType from baserow_enterprise.assistant.types import ( TableNavigationType, ToolsUpgradeResponse, @@ -43,9 +50,12 @@ view_item_registry, ) +if TYPE_CHECKING: + from baserow_enterprise.assistant.assistant import ToolHelpers + def get_list_databases_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[], list[DatabaseItem]]: """ Returns a function that lists all the databases the user has access to in the @@ -84,13 +94,13 @@ class ListDatabasesToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_list_databases_tool(user, workspace, tool_helpers) def get_list_tables_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[int], list[str]]: """ Returns a function that lists all the tables in a given database the user has @@ -153,13 +163,13 @@ class ListTablesToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_list_tables_tool(user, workspace, tool_helpers) def get_tables_schema_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[int], list[str]]: """ Returns a function that lists all the fields in a given table the user has @@ -209,13 +219,13 @@ class GetTablesSchemaToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_tables_schema_tool(user, workspace, tool_helpers) def get_create_database_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[str], dict[str, Any]]: """ Returns a function that creates a database in the current workspace. @@ -257,13 +267,13 @@ class CreateDatabaseToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_create_database_tool(user, workspace, tool_helpers) def get_create_tables_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[list[TableItemCreate]], list[dict[str, Any]]]: """ Returns a function that creates a set of tables in a given database the user has @@ -388,7 +398,7 @@ class CreateTablesToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_create_tables_tool(user, workspace, tool_helpers) @@ -396,7 +406,7 @@ def get_tool( def get_create_fields_tool( user: AbstractUser, workspace: Workspace, - tool_helpers: ToolHelpers, + tool_helpers: "ToolHelpers", ) -> Callable[[int, list[AnyFieldItemCreate]], list[dict[str, Any]]]: """ Returns a function that creates fields in a given table the user has access to @@ -435,13 +445,13 @@ class CreateFieldsToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_create_fields_tool(user, workspace, tool_helpers) def get_list_rows_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[int, int, int, list[int] | None], list[dict[str, Any]]]: """ Returns a function that lists rows in a given table the user has access to in the @@ -494,7 +504,7 @@ class ListRowsToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_list_rows_tool(user, workspace, tool_helpers) @@ -502,7 +512,7 @@ def get_tool( def get_rows_meta_tool( user: AbstractUser, workspace: Workspace, - tool_helpers: ToolHelpers, + tool_helpers: "ToolHelpers", ) -> Callable[[int, list[dict[str, Any]]], list[Any]]: """ Returns a meta-tool that, given a table ID, returns an observation that says that @@ -565,13 +575,13 @@ class GetRowsToolsToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_rows_meta_tool(user, workspace, tool_helpers) def get_list_views_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[int], list[dict[str, Any]]]: """ Returns a function that lists all the views in a given table the user has @@ -618,13 +628,13 @@ class ListViewsToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_list_views_tool(user, workspace, tool_helpers) def get_create_views_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[int, list[str]], list[str]]: """ Returns a function that creates views in a given table the user has access to @@ -690,13 +700,13 @@ class CreateViewsToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_create_views_tool(user, workspace, tool_helpers) def get_create_view_filters_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[int, list[str]], list[str]]: """ Returns a function that creates views in a given table the user has access to @@ -764,6 +774,190 @@ class CreateViewFiltersToolType(AssistantToolType): @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_create_view_filters_tool(user, workspace, tool_helpers) + + +def get_formula_type_tool( + user: AbstractUser, workspace: Workspace +) -> Callable[[str], str]: + """ + Returns a function that returns the type of a formula. + """ + + def get_formula_type(table_id: int, field_name: str, formula: str) -> str: + """ + Returns the type of a formula. Raises an exception if the formula is not valid. + **ALWAYS** call this to validate a formula is valid before returning it. + """ + + nonlocal user, workspace + + table = utils.filter_tables(user, workspace).get(id=table_id) + field = FormulaField(formula=formula, table=table, name=field_name, order=0) + field.recalculate_internal_fields(raise_if_invalid=True) + + result = TypeFormulaResultSerializer(field).data + if result["error"]: + raise Exception(f"Invalid formula: {result['error']}") + + return result["formula_type"] + + return get_formula_type + + +def get_generate_database_formula_tool( + user: AbstractUser, + workspace: Workspace, + tool_helpers: "ToolHelpers", +) -> Callable[[str, int], dict[str, str]]: + """ + Returns a function that generates a formula for a given field in a table. + """ + + import dspy # local import to save memory when not used + + class FormulaGenerationSignature(dspy.Signature): + """ + Generates a Baserow formula based on the provided description and table schema. + """ + + description: str = dspy.InputField( + desc="A brief description of what the formula should do." + ) + tables_schema: dict = dspy.InputField( + desc="The schema of all the tables in the database." + ) + formula_documentation: str = dspy.InputField( + desc="Documentation about Baserow formulas and their syntax." + ) + table_id: int = dspy.OutputField( + desc=( + "The ID of the table the formula is intended for. " + "Should be the same as current_table_id, unless the formula can " + "only be created in a different table." + ) + ) + field_name: str = dspy.OutputField( + desc="The name of the formula field to be created. For a new field, it must be unique in the table." + ) + formula: str = dspy.OutputField( + desc="The generated formula. Must be a valid Baserow formula." + ) + formula_type: str = dspy.OutputField( + desc="The type of the generated formula. Must be one of: text, long_text, " + "number, boolean, date, link_row, single_select, multiple_select, duration, array." + ) + is_formula_valid: bool = dspy.OutputField( + desc="Whether the generated formula is valid or not." + ) + error_message: str = dspy.OutputField( + desc="If the formula is not valid, an error message explaining why." + ) + + def generate_database_formula( + database_id: int, + description: str, + save_to_field: bool = True, + ) -> dict[str, str]: + """ + Generate a database formula for a formula field. + + - table_id: The database ID where the formula field is located. + - description: A brief description of what the formula should do. + - save_to_field: Whether to save the generated formula to a field with the given + name (default: True). If False, the formula will be generated but not saved + into a field. + """ + + nonlocal user, workspace, tool_helpers + + database_tables = utils.filter_tables(user, workspace).filter( + database_id=database_id + ) + database_tables_schema = utils.get_tables_schema(database_tables, True) + + tool_helpers.update_status(_("Generating formula...")) + + formula_docs = get_formula_docs() + + formula_generator = dspy.ReAct( + FormulaGenerationSignature, + tools=[get_formula_type_tool(user, workspace)], + max_iters=10, + ) + result = formula_generator( + description=description, + tables_schema={"tables": database_tables_schema}, + formula_documentation=formula_docs, + ) + + if not result.is_formula_valid: + raise Exception(f"Error generating formula: {result.error_message}") + + table = next((t for t in database_tables if t.id == result.table_id), None) + if table is None: + raise Exception( + "The generated formula is intended for a different table " + f"than the current one. Table with ID {result.table_id} not found." + ) + + data = { + "formula": result.formula, + "formula_type": result.formula_type, + } + field_name = result.field_name + + if save_to_field: + field = table.field_set.filter(name=field_name).first() + if field: + field = field.specific + + with transaction.atomic(): + # Trash any existing non-formula field so it can be replaced, allowing + # the user to easily restore the original field if needed. + if field and field_type_registry.get_by_model(field).type != "formula": + DeleteFieldActionType.do(user, field) + field = None + + if field is None: + CreateFieldActionType.do( + user, + table, + type_name="formula", + name=field_name, + formula=result.formula, + ) + operation = "field created" + else: + # Only update the formula of an existing formula field. + UpdateFieldActionType.do( + user, + field, + formula=result.formula, + ) + operation = "field updated" + + data.update( + { + "table_id": table.id, + "table_name": table.name, + "field_name": result.field_name, + "operation": operation, + } + ) + + return data + + return generate_database_formula + + +class GenerateDatabaseFormulaToolType(AssistantToolType): + type = "generate_database_formula" + + @classmethod + def get_tool( + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" + ) -> Callable[[Any], Any]: + return get_generate_database_formula_tool(user, workspace, tool_helpers) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/fields.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/fields.py index 347e942629..e1654d0d97 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/fields.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/fields.py @@ -8,7 +8,9 @@ from baserow.contrib.database.fields.models import DateField from baserow.contrib.database.fields.models import Field as BaserowField from baserow.contrib.database.fields.models import ( + FormulaField, LinkRowField, + LookupField, MultipleSelectField, NumberField, RatingField, @@ -442,6 +444,86 @@ class FileFieldItem(BaseFileFieldItem, FieldItem): pass +class FormulaFieldItemCreate(FieldItemCreate): + type: Literal["formula"] = Field(..., description="Formula field.") + formula: str = Field( + ..., + description="The formula to use in the field. It needs to be generated via the appropriate tool or use '' as placeholder.", + ) + + def to_django_orm_kwargs(self, table: Table) -> dict[str, any]: + return { + "name": self.name, + "formula": self.formula, + } + + +class FormulaFieldItem(FormulaFieldItemCreate, FieldItem): + formula_type: str = Field(..., description="The type of the formula.") + array_formula_type: str | None = Field( + ..., + description=("If the formula type is 'array', the type of the array items."), + ) + + @classmethod + def from_django_orm(cls, orm_field: FormulaField) -> "FormulaFieldItem": + field = orm_field.specific + return cls( + id=field.id, + name=field.name, + type="formula", + formula=field.formula, + formula_type=field.formula_type, + array_formula_type=field.array_formula_type, + ) + + +class LookupFieldItemCreate(FieldItemCreate): + type: Literal["lookup"] = Field(..., description="Lookup field.") + through_field: int | str = Field( + ..., description="The ID of the link row field to lookup through." + ) + target_field: int | str = Field( + ..., description="The ID of the field to lookup on the linked table." + ) + + def to_django_orm_kwargs(self, table: Table) -> dict[str, any]: + data = {"name": self.name} + if isinstance(self.through_field, str): + data["through_field_name"] = self.through_field + else: + data["through_field_id"] = self.through_field + + if isinstance(self.target_field, str): + data["target_field_name"] = self.target_field + else: + data["target_field_id"] = self.target_field + + return data + + +class LookupFieldItem(LookupFieldItemCreate, FieldItem): + through_field_name: str = Field( + ..., description="The name of the link row field to lookup through." + ) + target_field_name: str = Field( + ..., description="The name of the field to lookup on the linked table." + ) + + @classmethod + def from_django_orm(cls, orm_field: LookupField) -> "LookupFieldItem": + field = orm_field.specific + return cls( + id=field.id, + name=field.name, + type="lookup", + through_field=field.through_field_id, + target_field=field.target_field_id, + through_field_name=field.through_field_name, + target_field_name=field.target_field_name, + ) + + AnyFieldItemCreate = Annotated[ TextFieldItemCreate | LongTextFieldItemCreate @@ -452,7 +534,9 @@ class FileFieldItem(BaseFileFieldItem, FieldItem): | LinkRowFieldItemCreate | SingleSelectFieldItemCreate | MultipleSelectFieldItemCreate - | FileFieldItemCreate, + | FileFieldItemCreate + | FormulaFieldItemCreate + | LookupFieldItemCreate, Field(discriminator="type"), ] @@ -467,6 +551,8 @@ class FileFieldItem(BaseFileFieldItem, FieldItem): | SingleSelectFieldItem | MultipleSelectFieldItem | FileFieldItem + | FormulaFieldItem + | LookupFieldItem | FieldItem ) @@ -483,6 +569,8 @@ class FieldItemsRegistry: "single_select": SingleSelectFieldItem, "multiple_select": MultipleSelectFieldItem, "file": FileFieldItem, + "formula": FormulaFieldItem, + "lookup": LookupFieldItem, } def from_django_orm(self, orm_field: Type[BaserowField]) -> FieldItem: diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/utils.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/utils.py index 42fc3d8242..94cd199cfe 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/utils.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/utils.py @@ -1,10 +1,10 @@ from dataclasses import dataclass from itertools import groupby -from typing import Any, Callable, Literal, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Type, Union from django.core.exceptions import ValidationError from django.db import transaction -from django.db.models import Q +from django.db.models import Q, QuerySet from django.utils.translation import gettext as _ from pydantic import ConfigDict, Field, create_model @@ -32,7 +32,6 @@ BaseTableItem, TableItem, ) -from baserow_enterprise.assistant.tools.registries import ToolHelpers from .types import ( AnyFieldItem, @@ -43,10 +42,13 @@ field_item_registry, ) +if TYPE_CHECKING: + from baserow_enterprise.assistant.assistant import ToolHelpers + NoChange = Literal["__NO_CHANGE__"] -def filter_tables(user, workspace: Workspace) -> list[Table]: +def filter_tables(user, workspace: Workspace) -> QuerySet[Table]: return TableHandler().list_workspace_tables(user, workspace) @@ -109,7 +111,7 @@ def create_fields( user, table: Table, field_items: list[AnyFieldItemCreate], - tool_helpers: ToolHelpers, + tool_helpers: "ToolHelpers", ) -> list[AnyFieldItem]: created_fields = [] for field_item in field_items: @@ -381,7 +383,7 @@ def get_view(user, view_id: int): def get_table_rows_tools( - user, workspace: Workspace, tool_helpers: ToolHelpers, table: Table + user, workspace: Workspace, tool_helpers: "ToolHelpers", table: Table ): import dspy # local import to save memory when not used from dspy.adapters.types.tool import _resolve_json_schema_reference diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/navigation/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/navigation/tools.py index 975c0d67ba..a9ad456ac7 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/navigation/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/navigation/tools.py @@ -1,16 +1,19 @@ -from typing import Callable +from typing import TYPE_CHECKING, Callable from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext as _ from baserow.core.models import Workspace -from baserow_enterprise.assistant.tools.registries import AssistantToolType, ToolHelpers +from baserow_enterprise.assistant.tools.registries import AssistantToolType from .types import AnyNavigationRequestType +if TYPE_CHECKING: + from baserow_enterprise.assistant.assistant import ToolHelpers + def get_navigation_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[AnyNavigationRequestType], str]: """ Returns a function that provides navigation instructions to the user based on @@ -24,6 +27,8 @@ def navigate(request: AnyNavigationRequestType) -> str: Use when: - the user asks to open, go, to be brought to something - the user asks to see something from their workspace + - if something new has been created in a previously existing database or table, + like a view, a field or some rows """ nonlocal user, workspace diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/registries.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/registries.py index c3a2783de8..18c9f79396 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/registries.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/registries.py @@ -1,8 +1,6 @@ -from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable from django.contrib.auth.models import AbstractUser -from django.utils import translation from baserow.core.exceptions import ( InstanceTypeAlreadyRegistered, @@ -10,19 +8,9 @@ ) from baserow.core.models import Workspace from baserow.core.registries import Instance, Registry -from baserow_enterprise.assistant.tools.navigation.utils import unsafe_navigate_to -from baserow_enterprise.assistant.types import AiThinkingMessage if TYPE_CHECKING: - from baserow_enterprise.assistant.tools.navigation.types import ( - AnyNavigationRequestType, - ) - - -@dataclass -class ToolHelpers: - update_status: Callable[[str], None] - navigate_to: Callable[["AnyNavigationRequestType"], str] + from baserow_enterprise.assistant.assistant import ToolHelpers class AssistantToolType(Instance): @@ -82,7 +70,7 @@ def on_tool_end( @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: """ Returns the actual tool function to be called to pass to the dspy react agent. @@ -111,31 +99,8 @@ class AssistantToolRegistry(Registry[AssistantToolType]): already_registered_exception_class = AssistantToolAlreadyRegistered def list_all_usable_tools( - self, user: AbstractUser, workspace: Workspace + self, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> list[AssistantToolType]: - def update_status_localized(status: str): - """ - Sends a localized message to the frontend to update the assistant status. - - :param status: The status message to send. - """ - - from dspy.dsp.utils.settings import settings - from dspy.streaming.messages import sync_send_to_stream - - nonlocal user - - with translation.override(user.profile.language): - stream = settings.send_stream - - if stream is not None: - sync_send_to_stream(stream, AiThinkingMessage(content=status)) - - tool_helpers = ToolHelpers( - update_status=update_status_localized, - navigate_to=unsafe_navigate_to, - ) - return [ tool_type.get_tool(user, workspace, tool_helpers) for tool_type in self.get_all() diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/search_docs/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/search_docs/tools.py index d30c41cb92..d0c540fec8 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/search_docs/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/search_docs/tools.py @@ -1,13 +1,16 @@ -from typing import Any, Callable, TypedDict +from typing import TYPE_CHECKING, Any, Callable, TypedDict from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext as _ from baserow.core.models import Workspace -from baserow_enterprise.assistant.tools.registries import AssistantToolType, ToolHelpers +from baserow_enterprise.assistant.tools.registries import AssistantToolType from .handler import KnowledgeBaseHandler +if TYPE_CHECKING: + from baserow_enterprise.assistant.assistant import ToolHelpers + MAX_SOURCES = 3 @@ -31,7 +34,7 @@ class SearchDocsToolOutput(TypedDict): def get_search_docs_tool( - user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[str], SearchDocsToolOutput]: """ Returns a function that searches the Baserow documentation for a given query. @@ -84,6 +87,6 @@ def can_use( @classmethod def get_tool( - cls, user: AbstractUser, workspace: Workspace, tool_helpers: ToolHelpers + cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers" ) -> Callable[[Any], Any]: return get_search_docs_tool(user, workspace, tool_helpers) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_table_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_table_tools.py index 50cdc238cc..e7869280fe 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_table_tools.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_table_tools.py @@ -1,9 +1,14 @@ +from unittest.mock import MagicMock, patch + import pytest +from baserow.contrib.database.fields.models import FormulaField +from baserow.contrib.database.formula.registries import formula_function_registry from baserow.contrib.database.table.models import Table from baserow.test_utils.helpers import AnyInt from baserow_enterprise.assistant.tools.database.tools import ( get_create_tables_tool, + get_generate_database_formula_tool, get_list_tables_tool, ) from baserow_enterprise.assistant.tools.database.types import ( @@ -323,3 +328,323 @@ def test_create_complex_table_tool(data_fixture): option.pop("id") assert field_item[key] == value + + +@pytest.mark.django_db +def test_generate_database_formula_no_save(data_fixture): + """Test formula generation without saving to a field.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + data_fixture.create_text_field(table=table, name="text_field", primary=True) + + # Mock the dspy.ReAct to return a valid formula + mock_prediction = MagicMock() + mock_prediction.is_formula_valid = True + mock_prediction.formula = "'ok'" + mock_prediction.formula_type = "text" + mock_prediction.field_name = "test_formula" + mock_prediction.table_id = table.id + mock_prediction.error_message = "" + + with patch("dspy.ReAct") as mock_react: + mock_react.return_value.return_value = mock_prediction + + tool = get_generate_database_formula_tool(user, workspace, fake_tool_helpers) + result = tool( + database_id=database.id, + description="Return a simple text", + save_to_field=False, + ) + + # Verify formula is returned + assert result["formula"] == "'ok'" + assert result["formula_type"] == "text" + + # Verify no field was created + assert not table.field_set.filter(name="test_formula").exists() + + +@pytest.mark.django_db +def test_generate_database_formula_create_new_field(data_fixture): + """Test formula generation creates a new field when none exists.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + data_fixture.create_text_field(table=table, name="text_field", primary=True) + + # Mock the dspy.ReAct to return a valid formula + mock_prediction = MagicMock() + mock_prediction.is_formula_valid = True + mock_prediction.formula = "'ok'" + mock_prediction.formula_type = "text" + mock_prediction.field_name = "test_formula" + mock_prediction.table_id = table.id + mock_prediction.error_message = "" + + with patch("dspy.ReAct") as mock_react: + mock_react.return_value.return_value = mock_prediction + + tool = get_generate_database_formula_tool(user, workspace, fake_tool_helpers) + result = tool( + database_id=database.id, + description="Return a simple text", + save_to_field=True, + ) + + # Verify formula is returned + assert result["formula"] == "'ok'" + assert result["formula_type"] == "text" + assert result["table_id"] == table.id + assert result["table_name"] == "Test Table" + assert result["field_name"] == "test_formula" + assert result["operation"] == "field created" + + # Verify field was created + assert table.field_set.filter(name="test_formula").exists() + field = table.field_set.get(name="test_formula") + assert isinstance(field.specific, FormulaField) + assert field.specific.formula == "'ok'" + + +@pytest.mark.django_db +def test_generate_database_formula_update_existing_formula_field(data_fixture): + """Test formula generation updates an existing formula field.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + data_fixture.create_text_field(table=table, name="text_field", primary=True) + + # Create existing formula field + existing_field = data_fixture.create_formula_field( + table=table, name="test_formula", formula="'old'" + ) + existing_field_id = existing_field.id + + # Mock the dspy.ReAct to return a new formula + mock_prediction = MagicMock() + mock_prediction.is_formula_valid = True + mock_prediction.formula = "'new'" + mock_prediction.formula_type = "text" + mock_prediction.field_name = "test_formula" + mock_prediction.table_id = table.id + mock_prediction.error_message = "" + + with patch("dspy.ReAct") as mock_react: + mock_react.return_value.return_value = mock_prediction + + tool = get_generate_database_formula_tool(user, workspace, fake_tool_helpers) + result = tool( + database_id=database.id, + description="Return updated text", + save_to_field=True, + ) + + # Verify formula is returned + assert result["formula"] == "'new'" + assert result["formula_type"] == "text" + assert result["table_id"] == table.id + assert result["table_name"] == "Test Table" + assert result["field_name"] == "test_formula" + assert result["operation"] == "field updated" + + # Verify field was updated (same ID, new formula) + field = table.field_set.get(name="test_formula") + assert field.id == existing_field_id # Same field, not recreated + assert isinstance(field.specific, FormulaField) + assert field.specific.formula == "'new'" + + +@pytest.mark.django_db +def test_generate_database_formula_replace_non_formula_field(data_fixture): + """Test formula generation replaces a non-formula field.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + data_fixture.create_text_field(table=table, name="text_field", primary=True) + + # Create existing text field with same name + existing_text_field = data_fixture.create_text_field( + table=table, name="test_formula" + ) + existing_field_id = existing_text_field.id + + # Mock the dspy.ReAct to return a valid formula + mock_prediction = MagicMock() + mock_prediction.is_formula_valid = True + mock_prediction.formula = "'ok'" + mock_prediction.formula_type = "text" + mock_prediction.field_name = "test_formula" + mock_prediction.table_id = table.id + mock_prediction.error_message = "" + + with patch("dspy.ReAct") as mock_react: + mock_react.return_value.return_value = mock_prediction + + tool = get_generate_database_formula_tool(user, workspace, fake_tool_helpers) + result = tool( + database_id=database.id, + description="Return a simple text", + save_to_field=True, + ) + + # Verify formula is returned + assert result["formula"] == "'ok'" + assert result["formula_type"] == "text" + assert result["table_id"] == table.id + assert result["table_name"] == "Test Table" + assert result["field_name"] == "test_formula" + assert result["operation"] == "field created" + + # Verify new formula field was created + field = table.field_set.get(name="test_formula", trashed=False) + assert field.id != existing_field_id # Different field ID (old one trashed) + assert isinstance(field.specific, FormulaField) + assert field.specific.formula == "'ok'" + + # Verify old field was trashed + from baserow.contrib.database.fields.models import Field + + old_field = Field.objects_and_trash.get(id=existing_field_id) + assert old_field.trashed is True + + +@pytest.mark.django_db +def test_generate_database_formula_invalid_formula(data_fixture): + """Test error handling when formula generation fails.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + data_fixture.create_text_field(table=table, name="text_field", primary=True) + + # Mock the dspy.ReAct to return an invalid formula + mock_prediction = MagicMock() + mock_prediction.is_formula_valid = False + mock_prediction.formula = "" + mock_prediction.formula_type = "" + mock_prediction.field_name = "test_formula" + mock_prediction.table_id = table.id + mock_prediction.error_message = "Formula syntax error: invalid expression" + + with patch("dspy.ReAct") as mock_react: + mock_react.return_value.return_value = mock_prediction + + tool = get_generate_database_formula_tool(user, workspace, fake_tool_helpers) + + # Verify exception is raised + with pytest.raises(Exception) as exc_info: + tool( + database_id=database.id, + description="Invalid formula test", + save_to_field=True, + ) + + assert "Error generating formula:" in str(exc_info.value) + assert "Formula syntax error: invalid expression" in str(exc_info.value) + + # Verify no field was created + assert not table.field_set.filter(name="test_formula").exists() + + +@pytest.mark.django_db +def test_generate_database_formula_documentation_completeness(data_fixture): + """Test that formula documentation contains all required functions.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + data_fixture.create_text_field(table=table, name="text_field", primary=True) + + # Mock the dspy.ReAct to capture the formula_documentation argument + mock_prediction = MagicMock() + mock_prediction.is_formula_valid = True + mock_prediction.formula = "'ok'" + mock_prediction.formula_type = "text" + mock_prediction.field_name = "test_formula" + mock_prediction.table_id = table.id + mock_prediction.error_message = "" + + captured_formula_docs = None + + class MockReAct: + def __init__(self, signature, tools=None, max_iters=10): + nonlocal captured_formula_docs + # Don't capture anything here - wait for the call + self.mock_instance = MagicMock(return_value=mock_prediction) + + def __call__(self, **kwargs): + nonlocal captured_formula_docs + captured_formula_docs = kwargs.get("formula_documentation") + return mock_prediction + + with patch("dspy.ReAct", MockReAct): + tool = get_generate_database_formula_tool(user, workspace, fake_tool_helpers) + tool( + database_id=database.id, + description="Test documentation", + save_to_field=False, + ) + + # Verify formula_documentation was provided + assert captured_formula_docs is not None + assert len(captured_formula_docs) > 0 + + # Known exceptions (internal functions not documented) + formula_exceptions = [ + "tovarchar", + "error_to_nan", + "bc_to_null", + "error_to_null", + "array_agg", + "array_agg_unnesting", + "multiple_select_options_agg", + "get_single_select_value", + "multiple_select_count", + "string_agg_multiple_select_values", + "jsonb_extract_path_text", + "array_agg_no_nesting", + "string_agg_many_to_many_values", + "many_to_many_agg", + "many_to_many_count", + ] + + missing_functions = [] + present_functions = [] + + # Sanity check: baseline count of registered formula functions (snapshot 2025-10-17) + assert len(formula_function_registry.registry.keys()) > 110 + + for function_name in formula_function_registry.registry.keys(): + if function_name in formula_exceptions: + continue + + if function_name not in captured_formula_docs: + missing_functions.append(function_name) + else: + present_functions.append(function_name) + + if missing_functions: + pytest.fail( + f"The following functions are missing from formula_documentation:\n" + f"{', '.join(missing_functions)}\n\n" + f"Present functions: {len(present_functions)}\n" + f"Missing functions: {len(missing_functions)}" + ) + + # Verify at least some expected functions are present + expected_common_functions = ["concat", "field", "if", "upper", "lower"] + for func in expected_common_functions: + assert ( + func in captured_formula_docs + ), f"Expected function '{func}' not found in documentation" diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/utils.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/utils.py index d6d975e160..eebb6175ab 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/utils.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/utils.py @@ -1,3 +1,3 @@ -from baserow_enterprise.assistant.tools.registries import ToolHelpers +from baserow_enterprise.assistant.assistant import ToolHelpers fake_tool_helpers = ToolHelpers(lambda x: None, lambda x: None) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/images/kuma.svg b/enterprise/web-frontend/modules/baserow_enterprise/assets/images/kuma.svg new file mode 100644 index 0000000000..6bb29eed6c --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/assets/images/kuma.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/assistant.scss b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/assistant.scss index a0d4f976a1..45500abf8c 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/assistant.scss +++ b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/assistant.scss @@ -91,25 +91,36 @@ max-width: 680px; margin: 0 auto; width: 100%; + padding: 0 20px; } -.assistant__welcome-icon { +.assistant__welcome-kuma { + position: relative; + display: block; width: 80px; - height: 80px; - background: linear-gradient(135deg, $palette-blue-500 0%, lighten($palette-blue-500, 10%) 100%); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 24px; - box-shadow: 0 4px 12px rgba($palette-blue-500, 0.2); + height: 94px; + margin: 0 auto 18px; - i { - font-size: 36px; - color: $white; + &::after { + content: ''; + width: 24px; + height: 6px; + background-color: $palette-neutral-100; + border-radius: 100%; + transform: translateX(-50%); + + @include absolute(auto, auto, 0, 50%); } } +.assistant__welcome-video { + display: block; + width: 80px; + height: 80px; + outline: 0; + margin: 0 auto; +} + .assistant__welcome-title { font-size: 20px; font-weight: 600; @@ -119,7 +130,7 @@ .assistant__welcome-subtitle { font-size: 13px; - color: $palette-neutral-1200; + color: $palette-neutral-900; line-height: 1.5; margin: 0 0 32px; } diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 b/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 new file mode 100644 index 0000000000..cde840e5b5 Binary files /dev/null and b/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 differ diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue index 2471d8f858..d9d31252e9 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue @@ -26,6 +26,10 @@ {{ $t('assistantSidebarItem.title') }} +
-
- +
+

@@ -16,6 +32,9 @@ diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index 232091a31f..bdf72c9702 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -322,7 +322,7 @@ "dateDependencyContent": "You can define a dependency between two dates and a duration as start/end date and duration. With date dependency, if one value change, other values will be adjusted accordingly." }, "assistantSidebarItem": { - "title": "AI assistant" + "title": "Kuma AI" }, "assistantPanel": { "title": "AI Assistant", @@ -332,8 +332,8 @@ }, "assistantWelcomeMessage": { "greet": "Hey {name}", - "question": "how can I help?", - "subtitle": "I'm here to support you in handling your data." + "question": "my name is Kuma!", + "subtitle": "I’ll help you bear the load so it never feels heavy. Choose from the prompts below or just tell me what you need!" }, "assistantInputMessage": { "statusWaiting": "Assistant is ready to help", diff --git a/premium/backend/src/baserow_premium/fields/handler.py b/premium/backend/src/baserow_premium/fields/handler.py index bdc86c255f..3096c514a8 100644 --- a/premium/backend/src/baserow_premium/fields/handler.py +++ b/premium/backend/src/baserow_premium/fields/handler.py @@ -2,6 +2,7 @@ from typing import Optional from baserow_premium.prompts import get_generate_formula_prompt +from langchain_core.exceptions import OutputParserException from langchain_core.output_parsers import JsonOutputParser from langchain_core.prompts import PromptTemplate @@ -68,6 +69,9 @@ def generate_formula_with_ai( workspace=table.database.workspace, temperature=ai_temperature, ) - response_json = output_parser.parse(response) - - return response_json["formula"] + try: + return output_parser.parse(response)["formula"] + except (OutputParserException, TypeError) as e: + raise OutputParserException( + "The model didn't respond with the correct output. " "Please try again." + ) from e diff --git a/premium/backend/src/baserow_premium/prompts/__init__.py b/premium/backend/src/baserow_premium/prompts/__init__.py index 7f9bdd2216..f410af8a77 100644 --- a/premium/backend/src/baserow_premium/prompts/__init__.py +++ b/premium/backend/src/baserow_premium/prompts/__init__.py @@ -1,7 +1,33 @@ from functools import cache from importlib.resources import read_text +INSTRUCTIONS = """ +In the JSON below, you will fine the fields of the table where the formula is created. +When referencing a field using the `field` function, you're only allowed to reference these fields, the ones that are in the table. Field names can't be made up. +Below an array of the fields in the table in JSON format, where each item represents a field with some additional options. + +``` +{table_schema_json} +``` + +You're a Baserow formula generator, and you're only responding with the correct formula. +The formula you're generating can only contain function and operators available to the Baserow formula, not any other formula language. +It can only reference fields in the JSON described above, not other fields. + +Generate a Baserow formula based on the following input: "{user_prompt}". +""" + @cache def get_generate_formula_prompt(): - return read_text("baserow_premium.prompts", "generate_formula.prompt") + return "------------------".join( + [ + get_formula_docs(), + INSTRUCTIONS, + ] + ) + + +@cache +def get_formula_docs(): + return read_text("baserow_premium.prompts", "formula_docs.md") diff --git a/premium/backend/src/baserow_premium/prompts/formula_docs.md b/premium/backend/src/baserow_premium/prompts/formula_docs.md new file mode 100644 index 0000000000..606fdf3d9d --- /dev/null +++ b/premium/backend/src/baserow_premium/prompts/formula_docs.md @@ -0,0 +1,213 @@ +# Baserow Formula Language Documentation + +## Introduction + +A Baserow Formula field lets you create a field whose contents are calculated based on a Baserow Formula you've provided. A Baserow Formula is simply some text written in a particular way such that Baserow can understand it, for example the text `1+1` is a Baserow formula which will calculate the result `2` for every row. + +### Simple Formula Example + +Imagine you have a table with a normal text field called `text field` with 3 rows containing the text `one`, `two` and `three` respectively. If you then create a formula field with the formula `concat('Number', field('text field'))` the resulting table would look like: + +| text field | formula field | +| ---------- | ------------- | +| one | Number one | +| two | Number two | +| three | Number three | + +### Breaking Down a Simple Formula + +Let's split apart the formula `concat('Number', field('text field'))` to understand what is going on: + +- `concat`: Concat is one of many formula functions you can use. It will join together all the inputs you give to it into one single piece of text. +- `(`: To give inputs to a formula function you first have to write an opening parenthesis indicating the inputs will follow. +- `'Number'`: This is the first input we are giving to concat and it is literally just the text Number. When writing literal pieces of text in a formula you need to surround them with quotes. +- `,`: As we are giving multiple inputs to concat we need to separate each input with a comma. +- `field('text field')`: This is the second and final input we are giving to concat. We could keep on adding however many inputs as we wanted however as long as each was separated by a comma. This second input is a reference to the field in the same table with the name text field. For each cell in the formula field this reference will be replaced by whatever the value in the text field field is for that row. +- `)`: Finally, we need to tell Baserow we've finished giving inputs to the concat function, we do this with a matching closing parenthesis. + +### What is a Formula Function? + +A function in a formula takes a number of inputs depending on the type of the function. It does some calculation using those inputs and produces an output. Functions also sometimes only take specific types of inputs. For example the `datetime_format` only accepts two inputs, the first must be a date (either a field reference to a date field or a sub formula which calculates a date) and the second must be some text. + +All the available functions for you to use are shown in the expanded formula edit box which appears when you click on the formula whilst editing a formula field. + +## Working with Different Data Types + +### Using Numbers in Formulas + +Formulas can be used to do numerical calculations. The standard maths operators exist like `+`, `-`, `*` and `/`. You can use whole numbers or decimal numbers directly in your formula like so: `(field('number field') + 10.005)/10` + +### Using Dates + +Use the `todate` function to create a constant date inside a formula like so: `todate('2020-01-01 10:20:30', 'YYYY-MM-DD HH:MI:SS')`. The first argument is the date you want in text form and the second is the format of the date text. + +### Using Date Intervals + +Subtracting two dates returns a duration representing the difference in time between the two dates: `field('date a') - field('date b')`. The `date_interval` function lets you create intervals inside the formula to work with. + +Multiplying a duration and a number the result will be a duration where the number of seconds are multiplied for the number argument. + +Need to calculate a new date based on a date/time interval? Use the `date_interval` function like so: `field('my date column') - date_interval('1 year')` + +### Conditional Calculations + +If you need to do a calculation conditionally then the `if` function and comparison operators will let you do this. For example the following formula calculates whether a date field is the first day of a month: `if(day(field('some date')) = 1, true, false)`. + +You can compare fields and sub-formulas using the `>`, `>=`, `<=`, `<`, `=` and `!=` operators. + +### Working with Arrays and Computed Fields + +Formula functions, for example, `isblank()` or `when_empty()` work with simple values like text, number, or date fields. Computed fields like Link-to-table, look-up, and rollup fields can contain multiple items which makes them arrays or lists. + +To create formulas to make a Boolean test on data in field C, taking data from field A if it's TRUE, otherwise taking data from field B if it's FALSE, you need to convert any array to text using the `join()` function. For example: `if(isblank(join(field('Organization'),'')), field('Notes'), field('Name'))`. + +Using `join()` to convert the list to text handles the empty scenario correctly. This formula checks if the Organization field (a link-to-table field) has a value. If it's true, it shows the content of the Name field; otherwise, it displays the content of the Notes field. + +## Function Reference + +### Text Functions + +| Functions | Details | Syntax | Examples | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------- | +| concat | Returns its arguments joined together as a single piece of text. | concat(any, any, ...) | concat('A', 1, 1=2) = 'A1false' | +| upper | Returns its argument in upper case. | upper(text) | upper('a') = 'A' | +| lower | Returns its argument in lower case. | lower(text) | lower('A') = 'a' | +| trim | Removes all whitespace from the left and right sides of the input. | trim(text) | trim(" abc ") = "abc" | +| totext | Converts the input to text. | totext(any) | totext(10) = '10' | +| t | Returns the arguments value if it is text, but otherwise ''. | t(any) | t(10) | +| length | Returns the number of characters in the first argument provided. | length(text) | length("abc") = 3 | +| reverse | Returns the reversed text of the provided first argument. | reverse(text) | reverse("abc") = "cba" | +| left | Extracts the left most characters from the first input, stops when it has extracted the number of characters specified by the second input. | left(text, number) | left("abcd", 2) = "ab" | +| right | Extracts the right most characters from the first input, stops when it has extracted the number of characters specified by the second input. | right(text, number) | right("abcd", 2) = "cd" | +| search | Returns a positive integer starting from 1 for the first occurrence of the second argument inside the first, or 0 if no occurrence is found. | search(text, text) | search("test a b c test", "test") = 1 search("none", "test") = 0 | +| contains | Returns true if the first piece of text contains at least once the second. | contains(text,text) | contains("test", "e") = true | +| replace | Replaces all instances of the second argument in the first argument with the third argument. | replace(text, text, text) | replace("test a b c test", "test", "1") = "1 a b c 1" | +| regex_replace | Replaces any text in the first input which matches the regex specified by the second input with the text in the third input. | regex_replace(text, regex text, replacement text) | regex_replace("abc", "a", "1") = "1bc" | +| split_part | Extracts a segment from a delimited string based on a delimiter and index (numeric indicator indicating which element from string should be returned) | split_part(text, delimiter, position) | split_part('John, Jane, Michael', ', ', 2) = 'Jane' | +| encode_uri | Returns a encoded URI string from the argument provided. | encode_uri(text) | encode_uri('http://example.com/wiki/Señor') = 'http://example.com/wiki/Se%c3%b1or' | +| encode_uri_component | Returns a encoded URI string component from the argument provided. | encode_uri_component(text) | encode_uri_component('Hello World') = 'Hello%20World' | +| tourl | Converts the input to url. | tourl(any) | tourl('www.baserow.io') = 'www.baserow.io' | + +### Number Functions + +| Functions | Details | Syntax | Examples | +| --------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------- | ----------------------- | +| tonumber | Converts the input to a number if possible. | tonumber(text) | tonumber('10') = 10 | +| abs | Returns the absolute value for the argument number provided. | abs(number) | abs(1.49) = 1.49 | +| ceil | Returns the smallest integer that is greater than or equal the argument number provided. | ceil(number) | ceil(1.49) = 2 | +| floor | Returns the largest integer that is less than or equal the argument number provided. | floor(number) | floor(1.49) = 1 | +| round | Returns first argument rounded to the number of digits specified by the second argument. | round(number, number) | round(1.12345,2) = 1.12 | +| trunc | Returns only the first argument converted into an integer by truncating any decimal places. | trunc(number) | trunc(1.49) = 1 | +| sqrt | Returns the square root of the argument provided. | sqrt(number) | sqrt(9) = 3 | +| power | Returns the result of the first argument raised to the second argument exponent. | power(number, number) | power(3, 2) = 9 | +| exp | Returns the result of the constant e ≈ 2.718 raised to the argument number provided. | exp(number) | exp(1.000) = 2.718 | +| ln | Natural logarithm function: returns the exponent to which the constant e ≈ 2.718 must be raised to produce the argument. | ln(number) | ln(2.718) = 1.000 | +| log | Logarithm function: returns the exponent to which the first argument must be raised to produce the second argument. | log(number, number) | log(3, 9) = 2 | +| mod | Returns the remainder of the division between the first argument and the second argument. | mod(number, number) | mod(5, 2) = 1 | +| sign | Returns 1 if the argument is a positive number, -1 if the argument is a negative one, 0 otherwise. | sign(number) | sign(2.1234) = 1 | +| even | Returns true if the argument provided is an even number, false otherwise. | even(number) | even(2) = true | +| odd | Returns true if the argument provided is an odd number, false otherwise. | odd(number) | odd(2) = false | +| greatest | Returns the greatest value of the two inputs. | greatest(number, number) | greatest(1,2) = 2 | +| least | Returns the smallest of the two inputs. | least(number, number) | least(1,2) = 1 | +| is_nan | Returns true if the argument is 'NaN', returns false otherwise. | is_nan(number) | is_nan(1 / 0) = true | +| when_nan | Returns the first argument if it's not 'NaN'. Returns the second argument if the first argument is 'NaN' | when_nan(number, fallback) | when_nan(1 / 0, 4) = 4 | + +### Arithmetic Operators + +| Functions | Details | Syntax | Examples | +| ------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| add `+` | Returns its two arguments added together. | number + number text + text date + duration duration + duration duration + date add(number, number) | 1+1 = 2 'a' + 'b' = 'ab' | +| minus `-` | Returns its two arguments subtracted. | number - number minus(number, number) date - date date - duration duration - duration | 3-1 = 2 | +| multiply `*` | Returns its two arguments multiplied together. | multiply(number, number) multiply(duration, number) | 2*5 = 10 date_interval('1 second') * 60 = date_interval('1 minute') | +| divide `/` | Returns its two arguments divided, the first divided by the second. | number / number duration / number divide(number, number) | 10/2 = 5 date_interval('1 minute') / 60 = date_interval('1 second') | + +### Date and Time Functions + +Build more powerful formulas around dates in Baserow. The `today()` and `now()` functions update every 10 minutes. + +The `today()` function is useful for calculating intervals or when you need to have the current date displayed on a table. The `now()` function is useful when you need to display the current date and time on your table or calculate a value based on the current date and time, and have that value updated each time you open your database. + +| Functions | Details | Syntax | Examples | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------- | +| now | Returns the current date and time in utc. | now() | now() > todate("2021-12-12 13:00:00", "YYYY-MM-DD HH24:MI:SS") | +| today | Returns the current date in utc. | today() | today() > todate("2021-12-12", "YYYY-MM-DD") | +| todate | Returns the first argument converted into a date given a date format string as the second argument. | todate(text, text) | todate('20210101', 'YYYYMMDD') | +| todate_tz | Returns the first argument converted into a date given a date format string as the second argument and the timezone provided as third argument. | todate_tz(text, text, text) | todate_tz("2021-12-12 13:00:00", "YYYY-MM-DD HH24:MI:SS", "America/New_York") | +| datetime_format | Converts the date to text given a way of formatting the date. | datetime_format(date, text) | datetime_format(field('date field'), 'YYYY') | +| datetime_format_tz | Returns the first argument converted into a date given a date format string as the second argument and the timezone provided as third argument. | datetime_format_tz(date, text, text) | datetime_format_tz(field('date field'), 'YYYY', 'Europe/Rome') | +| year | Returns the number of years in the provided date. | year(date) | year(field("my date")) | +| month | Returns the number of months in the provided date. | month(date) | month(todate("2021-12-12", "YYYY-MM-DD")) = 12 | +| day | Returns the day of the month as a number between 1 to 31 from the argument. | day(date) | day(todate('20210101', 'YYYYMMDD')) = 1 | +| second | Returns the number of seconds in the provided date. | second(date) | second(field("dates")) == 2 | +| date_diff | Given a date unit to measure in as the first argument ('year', 'month', 'week', 'day', 'hour', 'minute', 'seconds') calculates and returns the number of units from the second argument to the third. | date_diff(text, date, date) | date_diff('yy', todate('2000-01-01', 'YYYY-MM-DD'), todate('2020-01-01', 'YYYY-MM-DD')) = 20 | +| date_interval | Returns the date interval corresponding to the provided argument. | date_interval(text) | date_interval('1 year') date_interval('2 seconds') | +| toduration | Converts the number of seconds provided into a duration. | toduration(number) | toduration(3600) = date_interval('1 hour') | +| toseconds | Converts the duration provided into the corresponding number of seconds. | toseconds(duration) | toseconds(date_interval('1 hour')) == 3600 | + +### Boolean Functions + +| Functions | Details | Syntax | Examples | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------- | +| if | If the first argument is true then returns the second argument, otherwise returns the third. | if(bool, any, any) | if(field('text field') = 'on', 'it is on', 'it is off') | +| equal `=` | Returns if its two arguments have the same value. | any = any equal(any, any) | 1=1 'a' = 'a' | +| not_equal `!=` | Returns if its two arguments have different values. | any != any not_equal(any, any) | 1!=2 'a' != 'b' | +| greater_than `>` | Returns true if the first argument greater than the second, otherwise false. | any > any | 1 > 2 = false if(field('a') > field('b'), 'a is bigger', 'b is bigger or equal') | +| greater_than_or_equal `>=` | Returns true if the first argument is greater than or equal to the second, otherwise false. | any >= any | 1 >= 1 = true if(field('a') >= field('b'), 'a is bigger or equal', 'b is smaller') | +| less_than `<` | Returns true if the first argument less than the second, otherwise false. | any < any | 2 < 1 = false if(field('a') < field('b'), 'a is smaller', 'b is bigger or equal') | +| less_than_or_equal `<=` | Returns true if the first argument less than or equal to the second, otherwise false. | any <= any | 1 <= 1 = true if(field('a') <= field('b'), 'a smaller', 'b is greater than or equal') | +| and | Returns the logical and of the first and second argument, so if they are both true then the result is true, otherwise it is false. | and(boolean, boolean) | and(true, false) = false and(true, true) = true and(field('first test'), field('second test')) | +| or | Returns the logical or of the first and second argument, so if either are true then the result is true, otherwise it is false. | or(boolean, boolean) | or(true, false) = true or(true, true) = true or(field('first test'), field('second test')) | +| not | Returns false if the argument is true and true if the argument is false. | not(boolean) | not(true) = false not(10=2) = true | +| isblank | Returns true if the argument is empty or blank, false otherwise. | isblank(any) | isblank('10') | +| is_null | Returns true if the argument is null, false otherwise | is_null(any) | is_null('10') | + +### Aggregate Functions + +These functions work with arrays and lookup values to perform calculations across multiple values. + +| Functions | Details | Syntax | Examples | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| sum | Sums all of the values and returns the result. | sum(numbers from lookup() or field()) | sum(lookup("link field", "number field")) sum(lookup("link field", "duration field")) sum(field("lookup field")) sum(field("link field with number primary field")) | +| avg | Averages all of the values and returns the result. | avg(numbers from lookup() or field()) | avg(lookup("link field", "number field")) avg(lookup("link field", "duration field")) avg(field("lookup field")) avg(field("link field with number primary field")) | +| count | Returns the number of items in its first argument. | count(array) | count(field('my link row field')) | +| min | Returns the smallest number from all the looked up values provided. | min(numbers from a lookup() or field()) | min(lookup("link field", "number field")) min(lookup("link field", "duration field")) min(field("lookup field")) min(field("link field with text primary field")) | +| max | Returns the largest number from all the looked up values provided. | max(numbers from a lookup() or field()) | max(lookup("link field", "number field")) max(lookup("link field", "duration field")) max(field("lookup field")) max(field("link field with text primary field")) | +| stddev_sample | Calculates the sample standard deviation of the values and returns the result. The sample deviation should be used when the provided values are only for a sample or subset of values for an underlying population. | stddev_sample(numbers from lookup() or field()) | stddev_sample(lookup("link field", "number field")) stddev_sample(lookup("link field", "duration field")) stddev_sample(field("lookup field")) stddev_sample(field("link field with number primary field")) | +| stddev_pop | Calculates the population standard deviation of the values and returns the result. The population standard deviation should be used when the provided values contain a value for every single piece of data in the population. | stddev_pop(numbers from lookup() or field()) | stddev_pop(lookup("link field", "number field")) stddev_pop(lookup("link field", "duration field")) stddev_pop(field("lookup field")) stddev_pop(field("link field with number primary field")) | +| variance_sample | Calculates the sample variance of the values and returns the result. The sample variance should be used when the provided values are only for a sample or subset of values for an underlying population. | variance_sample(numbers from lookup() or field()) | variance_sample(lookup("link field", "number field")) variance_sample(field("lookup field")) variance_sample(field("link field with number primary field")) | +| variance_pop | Calculates the population variance of the values and returns the result. The population variance should be used when the provided values contain a value for every single piece of data in the population. | variance_pop(numbers from lookup() or field()) | variance_pop(lookup("link field", "number field")) variance_pop(field("lookup field")) variance_pop(field("link field with number primary field")) | +| join | Concats all of the values from the first input together using the values from the second input. | join(text from lookup() or field(), text) | join(lookup("link field", "number field"), "\_") join(field("lookup field"), field("different lookup field")) join(field("link field with text primary field"), ",") | +| filter | Filters down an expression involving a lookup/link field reference or a lookup function call. | filter(an expression involving lookup() or field(a link/lookup field), boolean) | sum(filter(lookup("link field", "number field"), lookup("link field", "number field") > 10)) filter(field("lookup field"), contains(field("lookup field"), "a")) filter(field("link field") + "a", length(field("link field")) > 10") | +| every | Returns true if every one of the provided looked up values is true, false otherwise. | every(boolean values from a lookup() or field()) | every(field("my lookup") = "test") | +| any | Returns true if any one of the provided looked up values is true, false if they are all false. | any(boolean values from a lookup() or field()) | any(field("my lookup") = "test") | + +### Special Functions + +| Functions | Details | Syntax | Examples | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| field | Returns the field named by the single text argument. | field('a field name') | field('my text field') = 'flag' | +| lookup | Looks up the values from a field in another table for rows in a link row field. The first argument should be the name of a link row field in the current table and the second should be the name of a field in the linked table. | lookup('a link row field name', 'field name in other the table') | lookup('link row field', 'first name') = lookup('link row field', 'last name') | +| row_id | Returns the rows unique identifying number. | row_id() | concat("Row ", row_id()) | +| when_empty | If the first input is calculated to be empty the second input will be returned instead, otherwise if the first input is not empty the first will be returned. | when_empty(any, same type as the first) | when_empty(field("a"), "default") | +| has_option | Returns true if the first argument is a multiple select field or a lookup to a single select field and the second argument is one of the options. | has_option(multiple select, text); has_option(lookup(link row, single select), text) | has_option(field('multiple select'), 'option_a'); has_option(lookup(field('link row'), field('single select')), 'option_a') | + +### URL Functions + +| Functions | Details | Syntax | Examples | +| -------------- | ---------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | +| link | Creates a hyperlink using the URI provided in the first argument. | link(text) | link('http://your-text-here.com') | +| button | Creates a button using the URI (first argument) and label (second argument). | button(text, text) | button('http://your-text-here.com', 'your-label') | +| get_link_url | Gets the url from a formula using the link or button functions. | get_link_url(link) | get_link_url(field('formula link field')) = 'http://your-text-here.com' | +| get_link_label | Gets the label from a formula using the link or button functions. | get_link_label(button) | get_link_label(field('formula button field')) = 'your-label' | + +### File Functions + +| Functions | Details | Syntax | Examples | +| --------------------- | ---------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------- | +| get_file_count | Returns the number of files in a file field. | get_file_count(a file field) | get_file_count(field("File field")) | +| get_file_size | Returns the file size from a single file returned from the index function. | get_file_size(a file) | get_file_size(index(field("File field"), 0)) | +| get_file_visible_name | Returns the visible file name from a single file returned from the index function. | get_file_visible_name(a file) | get_file_visible_name(index(field("File field"), 0)) | +| get_file_mime_type | Returns the file mime type from a single file returned from the index function. | get_file_mime_type(a file) | get_file_mime_type(index(field("File field"), 0)) | +| get_image_width | Returns the image width from a single file returned from the index function. | get_image_width(a file) | get_image_width(index(field("File field"), 0)) | +| get_image_height | Returns the image height from a single file returned from the index function. | get_image_height(a file) | get_image_height(index(field("File field"), 0)) | +| is_image | Returns if the single file returned from the index function is an image or not. | is_image(a file) | is_image(index(field("File field"), 0)) | diff --git a/premium/backend/src/baserow_premium/prompts/generate_formula.prompt b/premium/backend/src/baserow_premium/prompts/generate_formula.prompt deleted file mode 100644 index d98c43c461..0000000000 --- a/premium/backend/src/baserow_premium/prompts/generate_formula.prompt +++ /dev/null @@ -1,233 +0,0 @@ -You're a Baserow formula generator, and will only respond with a Baserow formula. Below you will find the documentation of the Baserow formula language. - -URL functions - -The markdown table below contains the URL related formula functions. - -``` -| Functions | Details | Syntax | Examples | -| --------- | ------- | ------ | -------- | -| button | Creates a button using the URI (first argument) and label (second argument). | button(text, text) | button('http://your-text-here.com', 'your-label') | -| get_link_label | Gets the label from a formula using the link or button functions. | get_link_label(button) | get_link_label(field('formula button field')) = 'your-label' | -| get_link_url | Gets the url from a formula using the link or button functions. | get_link_url(link) | get_link_url(field('formula link field')) = 'http://your-text-here.com' | -| link | Creates a hyperlink using the URI provided in the first argument. | link(text) | link('http://your-text-here.com') | -``` - -Formula functions - - -The markdown tables below contains the formula related functions. - -``` -|Functions | Details | Syntax | Examples | -| --- | --- | --- | --- | -| variance sample | Calculates the sample variance of the values and returns the result. The sample variance should be used when the provided values are only for a sample or subset of values for an underlying population. | variance_sample(numbers from lookup() or field()) | variance_sample(lookup("link field", "number field")) variance_sample(field("lookup field")) variance_sample(field("link field with number primary field")) | -| variance pop | Calculates the population variance of the values and returns the result. The population variance should be used when the provided values contain a value for every single piece of data in the population. | variance_pop(numbers from lookup() or field()) | variance_pop(lookup("link field", "number field")) variance_pop(field("lookup field")) variance_pop(field("link field with number primary field")) | -| sum | Sums all of the values and returns the result. | sum(numbers from lookup() or field()) | sum(lookup("link field", "number field")) sum(lookup("link field", "duration field")) sum(field("lookup field")) sum(field("link field with number primary field")) | -| stddev sample | Calculates the sample standard deviation of the values and returns the result. The sample deviation should be used when the provided values are only for a sample or subset of values for an underlying population. | stddev_sample(numbers from lookup() or field()) | stddev_sample(lookup("link field", "number field")) stddev_sample(lookup("link field", "duration field")) stddev_sample(field("lookup field")) stddev_sample(field("link field with number primary field")) | -| stddev pop | Calculates the population standard deviation of the values and returns the result. The population standard deviation should be used when the provided values contain a value for every single piece of data in the population. | stddev_pop(numbers from lookup() or field()) | stddev_pop(lookup("link field", "number field")) stddev_pop(lookup("link field", "duration field")) . stddev_pop(field("lookup field")) stddev_pop(field("link field with number primary field")) | -| min | Returns the smallest number from all the looked up values provided. | min(numbers from a lookup() or field()) | min(lookup("link field", "number field")) min(lookup("link field", "duration field")) . min(field("lookup field")) min(field("link field with text primary field")) | -| max | Returns the largest number from all the looked up values provided. | max(numbers from a lookup() or field()) | max(lookup("link field", "number field")) max(lookup("link field", "duration field")) max(field("lookup field")) max(field("link field with text primary field")) | -| join | Concats all of the values from the first input together using the values from the second input. | join(text from lookup() or field(), text) | join(lookup("link field", "number field"), "_") join(field("lookup field"), field("different lookup field")) join(field("link field with text primary field"), ",") | -| filter | Filters down an expression involving a lookup/link field reference or a lookup function call. | filter(an expression involving lookup() or field(a link/lookup field), boolean) | sum(filter(lookup("link field", "number field"), lookup("link field", "number field") > 10)) filter(field("lookup field"), contains(field("lookup field"), "a")) filter(field("link field") + "a", length(field("link field")) > 10") | -| every | Returns true if every one of the provided looked up values is true, false otherwise. | every(boolean values from a lookup() or field()) | every(field("my lookup") = "test") | -| count | Returns the number of items in its first argument. | count(array) | count(field('my link row field')) | -| avg | Averages all of the values and returns the result. | avg(numbers from lookup() or field()) | avg(lookup("link field", "number field")) avg(lookup("link field", "duration field")) avg(field("lookup field")) avg(field("link field with number primary field")) | -| any | Returns true if any one of the provided looked up values is true, false if they are all false. | any(boolean values from a lookup() or field()) | any(field("my lookup") = "test") | -``` - -``` -|Functions | Details | Syntax | Examples | -| --- | --- | --- | --- | -| when empty | If the first input is calculated to be empty the second input will be returned instead, otherwise if the first input is not empty the first will be returned. | when_empty(any, same type as the first) | when_empty(field("a"), "default") | -| row id | Returns the rows unique identifying number. | row_id() | concat("Row ", row_id()) | -| minus `-` | Returns its two arguments subtracted. | number - number minus(number, number) date - date date - duration duration - duration | 3-1 = 2 | -| lookup | Looks up the values from a field in another table for rows in a link row field. The first argument should be the name of a link row field in the current table and the second should be the name of a field in the linked table. | lookup('a link row field name', 'field name in other the table') | lookup('link row field', 'first name') = lookup('link row field', 'last name') | -| field | Returns the field named by the single text argument. | field('a field name') | field('my text field') = 'flag' | -| add `+` | Returns its two arguments added together. | number + number text + text date + duration duration + duration duration + date add(number, number) | 1+1 = 2 'a' + 'b' = 'ab' | -| date interval | Returns the date interval corresponding to the provided argument. | date_interval(text) | date_interval('1 year') date_interval('2 seconds') | -``` - -Date and time functions - -Build more powerful formulas around dates in Baserow. The `today()` and `now()` functions update every 10 minutes. - -The `today()` function is useful for calculating intervals or when you need to have the current date displayed on a table. The `now()` function is useful when you need to display the current date and time on your table or calculate a value based on the current date and time, and have that value updated each time you open your database. - -Functions | Details | Syntax | Examples | -| --- | --- | --- | --- | -| year | Returns the number of years in the provided date. | year(date) | year(field("my date")) | -| now | Returns the current date and time in utc. | now() | now() > todate("2021-12-12 13:00:00", "YYYY-MM-DD HH24:MI:SS") | -| todate | Returns the first argument converted into a date given a date format string as the second argument. | todate(text, text) | todate('20210101', 'YYYYMMDD') | -| todate_tz | Returns the first argument converted into a date given a date format string as the second argument and the [timezone][5] provided as third argument. | todate_tz(text, text, text) | now() > todate("2021-12-12 13:00:00", "YYYY-MM-DD HH24:MI:SS") | -| second | Returns the number of seconds in the provided date. | second(date) | second(field("dates")) == 2 | -| month | Returns the number of months in the provided date. | month(date) | month(todate("2021-12-12", "YYYY-MM-DD")) = 12 | -| today | Returns the current date in utc. | today() | today() > todate("2021-12-12", "YYYY-MM-DD") | -| day | Returns the day of the month as a number between 1 to 31 from the argument. | day(date) | day(todate('20210101', 'YYYYMMDD')) = 1 | -| datetime_format | Converts the date to text given a way of formatting the date. | datetime_format(date, text) | datetime_format(field('date field'), 'YYYY') | -| date_diff | Given a date unit to measure in as the first argument ('year', 'month', 'week', 'day', 'hour', 'minute', 'seconds') calculates and returns the number of units from the second argument to the third. | date_diff(text, date, date) | date_diff('yy', todate('2000-01-01', 'YYYY-MM-DD'), todate('2020-01-01', 'YYYY-MM-DD')) = 20 | -| datetime_format_tz| Returns the first argument converted into a date given a date format string as the second argument and the timezone provided as third argument. | datetime_format_tz(date, text, text) | datetime_format(field('date field'), 'YYYY', 'Europe/Rome')| -| toduration | Converts the number of seconds provided into a duration. | toduration(number) | toduration(3600) = date_interval('1 hour') | -| toseconds | Converts the duration provided into the corresponding number of seconds. | toseconds(duration) | toseconds(date_interval('1 hour')) == 3600 | - -Boolean functions - -The markdown table below contains the boolean functions. - -``` -|Functions | Details | Syntax | Examples | -| --- | --- | --- | --- | -| or | Returns the logical or of the first and second argument, so if either are true then the result is true, otherwise it is false. | or(boolean, boolean) | or(true, false) = true and(true, true) = true or(field('first test'), field('second test')) | -| not_equal `!=` | Returns if its two arguments have different values. | any != any not_equal(any, any) | 1!=2 'a' != 'b’ | -| not | Returns false if the argument is true and true if the argument is false. | not(boolean) | not(true) = false not(10=2) = true | -| less_than_or_equal `<=` | Returns true if the first argument less than or equal to the second, otherwise false. | any <= any | 1 <= 1 = true if(field('a') <= field('b'), 'a smaller', 'b is greater than or equal') | -| less_than `<` | Returns true if the first argument less than the second, otherwise false. | any < any | 2 < 1 = false if(field('a') < field('b'), 'a is smaller', 'b is bigger or equal') | -| isblank | Returns true if the argument is empty or blank, false otherwise. | isblank(any) | isblank('10') | -| if | If the first argument is true then returns the second argument, otherwise returns the third. | if(bool, any, any) | if(field('text field') = 'on', 'it is on', 'it is off') | -| greater_than_or_equal `>=` | Returns true if the first argument is greater than or equal to the second, otherwise false. | any >= any | 1 >= 1 = true if(field('a') >= field('b'), 'a is bigger or equal', 'b is smaller') | -| greater_than `>` | Returns true if the first argument greater than the second, otherwise false. | any > any | 1 > 2 = false if(field('a') > field('b'), 'a is bigger', 'b is bigger or equal') | -| equal `=` | Returns if its two arguments have the same value. | any = any equal(any, any) | 1=1 'a' = 'a' | -| and | Returns the logical and of the first and second argument, so if they are bothtrue then the result is true, otherwise it is false. | and(boolean, boolean) | and(true, false) = false and(true, true) = true and(field('first test'), field('second test')) | -| is_null | Returns true if the argument is null, false otherwise | is_null(any) | is_null('10') | -| is_image | Returns if the single file returned from the index function is an image or not. | is_image(a file) | is_image(index(field("File field"), 0)) | -``` - -Number functions - -The markdown table below contains the number functions. - -``` -| Functions | Details | Syntax | Examples | -| --- | --- | --- | --- | -| tonumber | Converts the input to a number if possible. | tonumber(text) | tonumber('10') = 10 | -| sqrt | Returns the square root of the argument provided. | sqrt(number) | sqrt(9) = 3 | -| least | Returns the smallest of the two inputs. | least(number, number) | least(1,2) = 1 | -| greatest | Returns the greatest value of the two inputs. | greatest(number, number) | greatest(1,2) = 2 | -| divide `/` | Returns its two arguments divided, the first divided by the second. | number / number duration / number divide(number, number) | 10/2 = 5 date_interval('1 minute') / 60 = date_interval('1 second') | -| abs | Returns the absolute value for the argument number provided. | abs(number) | abs(1.49) = 1.49 | -| ceil | Returns the smallest integer that is greater than or equal the argument number provided. | ceil(number) | ceil(1.49) = 2 | -| even | Returns true if the argument provided is an even number, false otherwise. | even(number) | even(2) = true | -| exp | Returns the result of the constant e ≈ 2.718 raised to the argument number provided. | exp(number) | exp(1.000) = 2.718 | -| floor | Returns the largest integer that is less than or equal the argument number provided. | floor(number) | floor(1.49) = 1 | -| is_nan | Returns true if the argument is 'NaN', returns false otherwise. | is_nan(number) | is_nan(1 / 0) = true | -| ln | Natural logarithm function: returns the exponent to which the constant e ≈ 2.718 must be raised to produce the argument. | ln(number) | ln(2.718) = 1.000 | -| log | Logarithm function: returns the exponent to which the first argument must be raised to produce the second argument. | log(number, number) | log(3, 9) = 2 | -| mod | Returns the remainder of the division between the first argument and the second argument. | mod(number, number) | mod(5, 2) = 1 | -| multiply `*` | Returns its two arguments multiplied together. | multiply(number, number) multiply(duration, number) | 2*5 = 10 date_interval('1 second') * 60 = date_interval('1 minute') | -| odd | Returns true if the argument provided is an odd number, false otherwise. | odd(number) | odd(2) = false | -| power | Returns the result of the first argument raised to the second argument exponent. | power(number, number) | power(3, 2) = 9 | -| round | Returns first argument rounded to the number of digits specified by the second argument. | round(number, number) | round(1.12345,2) = 1.12 | -| sign | Returns 1 if the argument is a positive number, -1 if the argument is a negative one, 0 otherwise. | sign(number) | sign(2.1234) = 1 | -| trunc | Returns only the first argument converted into an integer by truncating any decimal places. | trunc(number) | trunc(1.49) = 1 | -| when nan | Returns the first argument if it's not 'NaN'. Returns the second argument if the first argument is 'NaN' | when_nan(number, fallback) | when_nan(1 / 0, 4) = 4 | -| get_file_size | Returns the file size from a single file returned from the index function. | get_file_size(a file) | get_file_size(index(field("File field"), 0)) -| get_image_width | Returns the image width from a single file returned from the index function. | get_image_width(a file) | get_image_width(index(field("File field"), 0)) | -| get_image_height | Returns the image height from a single file returned from the index function. | get_image_height(a file) | get_image_height(index(field("File field"), 0)) | -| get_file_count | Creates a button using the URI (first argument) and label (second argument). | get_file_count(a file field) | get_file_count(field("File field")) | -``` - -Text functions - -The markdown table below contains the text related functions. - -``` -|Functions | Details | Syntax | Examples | -| --- | --- | --- | --- | -| upper | Returns its argument in upper case. | upper(text) | upper('a') = 'A' | -| trim | Removes all whitespace from the left and right sides of the input. | trim(text) | trim(" abc ") = "abc" | -| totext | Converts the input to text. | totext(any) | totext(10) = '10' | -| t | Returns the arguments value if it is text, but otherwise ''. | t(any) | t(10) | -| search | Returns a positive integer starting from 1 for the first occurrence of the second argument inside the first, or 0 if no occurrence is found. | search(text, text) | search("test a b c test", "test") = 1 search("none", "test") = 0 | -| right | Extracts the right most characters from the first input, stops when it has extracted the number of characters specified by the second input. | right(text, number) | right("abcd", 2) = "cd" | -| reverse | Returns the reversed text of the provided first argument. | reverse(text) | reverse("abc") = "cba" | -| replace | Replaces all instances of the second argument in the first argument with the third argument. | replace(text, text, text) | replace("test a b c test", "test", "1") = "1 a b c 1" | -| regex_replace | Replaces any text in the first input which matches the regex specified by the second input with the text in the third input. | regex_replace(text, regex text, replacement text) | regex_replace("abc", "a", "1") = "1bc" | -| lower | Returns its argument in lower case. | lower(text) | lower('A') = 'a' | -| length | Returns the number of characters in the first argument provided. | length(text) | length("abc") = 3 | -| left | Extracts the left most characters from the first input, stops when it has extracted the number of characters specified by the second input. | left(text, number) | left("abcd", 2) = "ab" | -| contains | Returns true if the first piece of text contains at least once the second. | contains(text,text) | contains("test", "e") = true | -| concat | Returns its arguments joined together as a single piece of text. | concat(any, any, ...) | concat('A', 1, 1=2) = 'A1false' | -| encode_uri | Returns a encoded URI string from the argument provided. | encode_uri(text) | encode_uri('http://example.com/wiki/Señor') = 'http://example.com/wiki/Se%c3%b1or' | -| encode_uri_component | Returns a encoded URI string component from the argument provided. | encode_uri_component(text) | encode_uri_component('Hello World') = 'Hello%20World' | -| split_part | Extracts a segment from a delimited string based on a delimiter and index (numeric indicator indicating which element from string should be returned) | split_part(text, delimiter, position) | split_part('John, Jane, Michael', ', ', 2) = 'Jane' | -| has_option | Returns true if the first argument is a multiple select field or a lookup to a single select field and the second argument is one of the options. | has_option(multiple select, text); has_option(lookup(link row, single select), text) | has_option(field('multiple select'), 'option_a'); has_option(lookup(field('link row'), field('single select')), 'option_a') | -| get_file_visible_name | Returns the visible file name from a single file returned from the index function. | get_file_visible_name(a file) | get_file_visible_name(index(field("File field"), 0)) | -| get_file_mime_type | Returns the file mime type from a single file returned from the index function. | get_file_mime_type(a file) | get_file_mime_type(index(field("File field"), 0)) | -| tourl | Converts the input to url. | tourl(any) | tourl('www.baserow.io') = 'www.baserow.io' | -``` - -Boolean functions not working well with fields as arguments - -Formula functions, for example, isblank(), or when_empty work with simple values like text, number, or date fields. Computed fields like Link-to-table, look-up, and rollup fields can contain multiple items which makes them arrays or lists. - -To create formulas to make a Boolean test on data in field C, taking data from field A if it’s TRUE, otherwise taking data from field B if it’s FALSE, you need to convert any array to text using the join() function. For example: `if(isblank(join(field('Organization'),'')), field('Notes'), field('Name'))`. - -Using join() to convert the list to text, handles the empty scenario correctly. This formula checks if the Organization field (a link-to-table field) has a value. If it’s true, it shows the content of the Name field; otherwise, it displays the content of the Notes field. - -What a Baserow Formula Field is - -A Baserow Formula field lets you create a field whose contents are calculated based on a Baserow Formula you’ve provided. A Baserow Formula is simply some text written in a particular way such that Baserow can understand it, for example the text 1+1 is a Baserow formula which will calculate the result 2 for every row. - -A Simple Formula Example - -Imagine you have a table with a normal text field called text field with 3 rows containing the text one,two and three respectively. If you then create a formula field with the formula concat('Number', field('text field')) the resulting table would look like: - -``` -|text field|formula field| -|----------|-------------| -|one|Number one| -|two|Number two| -|three|Number three| -``` - -Breaking down a simple formula - -Let’s split apart the formula concat('Number', field('text field')) to understand what is going on: - -* `concat`: Concat is one of many formula functions you can use. It will join together all the inputs you give to it into one single piece of text. -* `(`: To give inputs to a formula function you first have to write an opening parenthesis indicating the inputs will follow. -* `Number`: This is the first input we are giving to concat and it is literally just the text Number. When writing literal pieces of text in a formula you need to surround them with quotes. -* `,`: As we are giving multiple inputs to concat we need to separate each input with a comma. -* `field('text field')`: This is the second and final input we are giving to concat. We could keep on adding however many inputs as we wanted however as long as each was separated by a comma. This second input is a reference to the field in the same table with the name text field. For each cell in the formula field this reference will be replaced by whatever the value in the text field field is for that row. -* `)`: Finally, we need to tell Baserow we’ve finished giving inputs to the concat function, we do this with a matching closing parenthesis. - -What is a formula function? - -A function in a formula takes a number of inputs depending on the type of the function. It does some calculation using those inputs and produces an output. Functions also sometimes only take specific types of inputs. For example the datetime_format only accepts two inputs, the first must be a date (either a field reference to a date field Or a sub formula which calculates a date) and the second must be some text. - -All the available functions for you to use are shown in the expanded formula edit box which appears when you click on the formula whilst editing a formula field. -Using numbers in formulas - -Formulas can be used to do numerical calculations. The standard maths operators exist like +,-,* and /. You can use whole numbers or decimal numbers directly in your formula like so (field('number field') + 10.005)/10 - -Conditional calculations - -If you need to do a calculation conditionally then the if function and comparison operators will let you do this. For example the following formula calculates whether a date field is the first day of a month, IF(day(field('some date')) = 1, true, false). - -You can compare fields and sub-formulas using the >, >= <=, <, = and != operators. - -Using Dates - -Use the todate function to create a constant date inside a formula like so: todate('2020-01-01 10:20:30', 'YYYY-MM-DD HH:MI:SS'). The first argument is the date you want in text form and the second is the format of the date text. - -Using Date intervals - -Subtracting two dates returns a duration representing the difference in time between the two dates: field('date a') - field('date b'). The date_interval function lets you create intervals inside the formula to work with. - -Multiplying a duration and a number the result will be a duration where the number of seconds are multiplied for the number argument. - -Need to calculate a new date based on a date/time interval? Use the date_interval function like so: field('my date column') - date_interval('1 year') - -This is the end of the formula documentation and explanation - --------------------------------------- - -In the JSON below, you will fine the fields of the table where the formula is created. When referencing a field using the `field` function, you're only allowed to reference these fields, the ones that are in the table. Field names can't be made up. Below an array of the fields in the table in JSON format, where each item represents a field with some additional options. - -``` -{table_schema_json} -``` - -You're a Baserow formula generator, and you're only responding with the correct formula. The formula you're generating can only contain function and operators available to the Baserow formula, not any other formula language. It can only reference fields in the JSON described above, not other fields. - -Generate a Baserow formula based on the following input: "{user_prompt}". diff --git a/premium/backend/tests/baserow_premium_tests/api/fields/test_generate_formula_prompt.py b/premium/backend/tests/baserow_premium_tests/api/fields/test_generate_formula_prompt.py index 791bd8abc9..806b4ffc25 100644 --- a/premium/backend/tests/baserow_premium_tests/api/fields/test_generate_formula_prompt.py +++ b/premium/backend/tests/baserow_premium_tests/api/fields/test_generate_formula_prompt.py @@ -1,10 +1,10 @@ -from baserow_premium.prompts import get_generate_formula_prompt +from baserow_premium.prompts import get_formula_docs from baserow.contrib.database.formula.registries import formula_function_registry def test_if_prompt_contains_all_formula_functions(): - prompt = get_generate_formula_prompt() + prompt = get_formula_docs() # These functions are for internal usage, and are not in the web-frontend # documentation. diff --git a/web-frontend/modules/core/assets/scss/components/tree.scss b/web-frontend/modules/core/assets/scss/components/tree.scss index f23734c563..e7d375c05f 100644 --- a/web-frontend/modules/core/assets/scss/components/tree.scss +++ b/web-frontend/modules/core/assets/scss/components/tree.scss @@ -139,6 +139,11 @@ } } +.tree__icon-right { + margin-left: auto; + font-size: 14px; +} + %tree-sub-size { line-height: 32px; height: 32px; diff --git a/web-frontend/modules/core/assets/scss/variables.scss b/web-frontend/modules/core/assets/scss/variables.scss index 12f09a6a33..b3202f0bac 100644 --- a/web-frontend/modules/core/assets/scss/variables.scss +++ b/web-frontend/modules/core/assets/scss/variables.scss @@ -9,9 +9,9 @@ $grid-width: 20px; $z-index-layout-col-1: 3 !default; $z-index-layout-col-2: 2 !default; $z-index-layout-col-3: 1 !default; -$z-index-layout-resize: 4 !default; -$z-index-layout-col-3-1: 6 !default; -$z-index-layout-col-3-2: 5 !default; +$z-index-layout-col-3-1: 5 !default; +$z-index-layout-col-3-2: 4 !default; +$z-index-layout-resize: 6 !default; // The z-index of modal and context must always be the same because they can be nested // inside each other. The order in html decided which must be shown over the other. diff --git a/web-frontend/modules/core/components/HorizontalResize.vue b/web-frontend/modules/core/components/HorizontalResize.vue index f5d7d51eed..06d1b87c29 100644 --- a/web-frontend/modules/core/components/HorizontalResize.vue +++ b/web-frontend/modules/core/components/HorizontalResize.vue @@ -25,6 +25,11 @@ export default { required: false, default: false, }, + right: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -53,7 +58,12 @@ export default { move(event) { event.preventDefault() const difference = event.clientX - this.mouseStart - let newWidth = Math.max(this.startWidth + difference, this.min) + let newWidth = 0 + if (this.right) { + newWidth = Math.max(this.startWidth - difference, this.min) + } else { + newWidth = Math.max(this.startWidth + difference, this.min) + } if (this.max) { newWidth = Math.min(newWidth, this.max) } diff --git a/web-frontend/modules/core/components/sidebar/Sidebar.vue b/web-frontend/modules/core/components/sidebar/Sidebar.vue index 675d393465..be7412790b 100644 --- a/web-frontend/modules/core/components/sidebar/Sidebar.vue +++ b/web-frontend/modules/core/components/sidebar/Sidebar.vue @@ -60,6 +60,7 @@ v-show="!collapsed" v-if="hasSelectedWorkspace" :selected-workspace="selectedWorkspace" + :right-sidebar-open="rightSidebarOpen" @open-workspace-search="$emit('open-workspace-search')" > @@ -128,6 +129,11 @@ export default { required: false, default: 240, }, + rightSidebarOpen: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { diff --git a/web-frontend/modules/core/components/sidebar/SidebarMenu.vue b/web-frontend/modules/core/components/sidebar/SidebarMenu.vue index 9db6832f20..54cbe9b439 100644 --- a/web-frontend/modules/core/components/sidebar/SidebarMenu.vue +++ b/web-frontend/modules/core/components/sidebar/SidebarMenu.vue @@ -128,6 +128,7 @@ v-for="(component, index) in sidebarWorkspaceComponents" :key="'sidebarWorkspaceComponents' + index" :workspace="selectedWorkspace" + :right-sidebar-open="rightSidebarOpen" >