From 9b8fdfa82e49db03cfadf717671ccd70f13ddd2a Mon Sep 17 00:00:00 2001 From: alamin-br Date: Fri, 17 Jul 2026 16:56:52 +0200 Subject: [PATCH 1/2] Show an error and disable generation for AI fields with a broken prompt (#5716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add AI prompt validation helper * feat: expose computed AI field prompt error in serializer * test: AI prompt error clears on fix and survives broken import * feat: reject AI generate when prompt is broken Re-validate the AI field prompt in AsyncGenerateAIFieldValuesView so a direct API call returns 400 instead of starting a job when the prompt is unparseable or references a missing field. * feat: disable grid AI generate when prompt is broken * feat: disable row-edit AI generate when prompt is broken * chore: add changelog for AI broken-prompt fix * fix: disable AI generate button on non-selected broken-prompt cells * fix: show broken-prompt tooltip on wrapper so it appears while button is disabled * fix: actually disable the grid cell Generate button on a broken prompt The functional (non-selected) grid cell used a tag="a" Button, which ignores the disabled prop, so the Generate button never disabled on a broken prompt. Use a real button so :disabled applies, move the tooltip to the wrapper, and add a regression test. * fix: update AI field error live when a referenced field changes The prompt error is computed on serialize, but the open grid never learned about it when a referenced field was deleted/restored because the AI field wasn't reported as a dependant. Hook field_dependency_updated so the AI field is included in related_fields (re-serialized with the fresh error) without touching its stored cell values. * fix: tolerate an unparseable AI prompt in dependency extraction and import Saving an invalid prompt is still rejected, but a broken prompt can also arrive through an import (or references can break later). Guard the prompt parsing in get_field_dependencies and after_import_serialized so such a field imports fine and surfaces the problem via its computed error instead of raising. * fix: block saving an AI field with an invalid prompt The prompt input only emits parseable formulas, so typing an invalid one and saving silently kept the last valid prompt — the field saved fine and looked healthy. The input now reports validity changes and the AI field form shows an error and refuses to submit while the prompt is invalid. * refactor: pass table_id to get_ai_prompt_error to avoid fetching the table The error property only needs the table PK for the field-existence lookup, so dereferencing self.table added a pointless query per serialization. * fix: re-report the AI field when a trashed referenced field is restored Deleting a referenced field dropped the AI field's dependency edge entirely (get_field_dependencies skipped missing ids), so restoring the field had no edge to walk and the grid kept showing the stale error until a reload. A trashed referenced field is now declared as a broken reference by name, like formula fields do, so the restore re-links it and the AI field is re-serialized with its error cleared. * fix: scope trashed broken-reference lookup to the AI field's table Keeps get_field_dependencies consistent with get_ai_prompt_error, which only considers same-table references, so a trashed field from another table can't leave a by-name broken reference in this table's graph. Also restores the delete test's cell-value assertion that was accidentally moved into the restore test, and drops the duplicate there. * fix: reject broken AI prompts on every generation entry point The generic /jobs/ endpoint used by the bulk generation modal skipped the prompt validation, double-quoted get("fields.field_X") references escaped the extractor, dependency discovery accepted cross-table field ids, and every error access repeated the field query. Validation now lives in the job type's prepare_values, the extractor strips both quote styles, the dependency lookup is table scoped, and the validated field ids are cached per request and invalidated on schema changes. * fix: disable AI generation shortcuts and context menus when the prompt is broken The Enter key on an empty cell and the field/cell context menu items could still trigger generation with a broken prompt. The shared generate actions now guard themselves and both menu items are disabled with an explanatory tooltip. * test: assert cross-table prompt references create no field dependencies --- ...lds_with_a_broken_prompt_and_block_ge.json | 9 + .../src/baserow_premium/api/fields/errors.py | 7 + .../src/baserow_premium/api/fields/views.py | 6 + premium/backend/src/baserow_premium/apps.py | 1 + .../src/baserow_premium/fields/exceptions.py | 7 + .../src/baserow_premium/fields/field_types.py | 80 +++++- .../src/baserow_premium/fields/job_types.py | 11 +- .../src/baserow_premium/fields/models.py | 9 + .../src/baserow_premium/fields/receivers.py | 11 + .../src/baserow_premium/fields/tasks.py | 20 +- .../src/baserow_premium/fields/visitors.py | 56 ++++- .../api/fields/test_ai_field_views.py | 68 +++++ .../fields/test_ai_field_type.py | 233 ++++++++++++++++++ .../fields/test_ai_field_visitors.py | 77 +++++- .../test_generate_ai_values_job_type.py | 47 +++- .../components/field/FieldAISubForm.vue | 20 +- .../field/GenerateAIValuesContextItem.vue | 9 +- .../components/row/RowEditFieldAI.vue | 15 +- .../grid/fields/FunctionalGridViewFieldAI.vue | 8 +- .../views/grid/fields/GridViewFieldAI.vue | 14 +- ...idViewFieldAIGenerateValuesContextItem.vue | 9 +- .../modules/baserow_premium/locales/en.json | 9 +- .../modules/baserow_premium/mixins/fieldAI.js | 9 + .../baserow_premium/mixins/gridFieldAI.js | 9 + .../components/field/fieldAISubForm.spec.js | 65 +++++ .../field/generateAIValuesContextItem.spec.js | 67 +++++ .../components/row/rowEditFieldAI.spec.js | 59 +++++ .../fields/functionalGridViewFieldAI.spec.js | 63 +++++ .../views/grid/fields/gridViewFieldAI.spec.js | 76 ++++++ ...ewFieldAIGenerateValuesContextItem.spec.js | 64 +++++ .../components/formula/FormulaInputField.vue | 7 +- web-frontend/modules/database/locales/en.json | 1 + .../core/formula/FormulaInputField.spec.js | 18 ++ 33 files changed, 1111 insertions(+), 53 deletions(-) create mode 100644 changelog/entries/unreleased/bug/show_an_error_on_ai_fields_with_a_broken_prompt_and_block_ge.json create mode 100644 premium/backend/src/baserow_premium/api/fields/errors.py create mode 100644 premium/backend/src/baserow_premium/fields/receivers.py create mode 100644 premium/web-frontend/test/unit/premium/components/field/fieldAISubForm.spec.js create mode 100644 premium/web-frontend/test/unit/premium/components/field/generateAIValuesContextItem.spec.js create mode 100644 premium/web-frontend/test/unit/premium/components/row/rowEditFieldAI.spec.js create mode 100644 premium/web-frontend/test/unit/premium/components/views/grid/fields/functionalGridViewFieldAI.spec.js create mode 100644 premium/web-frontend/test/unit/premium/components/views/grid/fields/gridViewFieldAI.spec.js create mode 100644 premium/web-frontend/test/unit/premium/components/views/grid/fields/gridViewFieldAIGenerateValuesContextItem.spec.js diff --git a/changelog/entries/unreleased/bug/show_an_error_on_ai_fields_with_a_broken_prompt_and_block_ge.json b/changelog/entries/unreleased/bug/show_an_error_on_ai_fields_with_a_broken_prompt_and_block_ge.json new file mode 100644 index 0000000000..c6d81509d4 --- /dev/null +++ b/changelog/entries/unreleased/bug/show_an_error_on_ai_fields_with_a_broken_prompt_and_block_ge.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Show an error on AI fields with a broken prompt and block generating until fixed", + "issue_origin": "github", + "issue_number": 3088, + "domain": "database", + "bullet_points": [], + "created_at": "2026-07-13" +} diff --git a/premium/backend/src/baserow_premium/api/fields/errors.py b/premium/backend/src/baserow_premium/api/fields/errors.py new file mode 100644 index 0000000000..b4a215c6cd --- /dev/null +++ b/premium/backend/src/baserow_premium/api/fields/errors.py @@ -0,0 +1,7 @@ +from rest_framework.status import HTTP_400_BAD_REQUEST + +ERROR_AI_FIELD_PROMPT_INVALID = ( + "ERROR_AI_FIELD_PROMPT_INVALID", + HTTP_400_BAD_REQUEST, + "The AI field's prompt is broken: {e}", +) diff --git a/premium/backend/src/baserow_premium/api/fields/views.py b/premium/backend/src/baserow_premium/api/fields/views.py index 6e19d495c9..7a39d8ae31 100644 --- a/premium/backend/src/baserow_premium/api/fields/views.py +++ b/premium/backend/src/baserow_premium/api/fields/views.py @@ -42,7 +42,9 @@ from baserow.core.handler import CoreHandler from baserow.core.jobs.handler import JobHandler from baserow.core.jobs.registries import job_type_registry +from baserow_premium.api.fields.errors import ERROR_AI_FIELD_PROMPT_INVALID from baserow_premium.fields.actions import GenerateFormulaWithAIActionType +from baserow_premium.fields.exceptions import AIFieldPromptInvalidError from baserow_premium.fields.job_types import GenerateAIValuesJobType from baserow_premium.fields.models import AIField from baserow_premium.license.features import PREMIUM @@ -84,6 +86,7 @@ class AsyncGenerateAIFieldValuesView(APIView): [ "ERROR_GENERATIVE_AI_DOES_NOT_EXIST", "ERROR_MODEL_DOES_NOT_BELONG_TO_TYPE", + "ERROR_AI_FIELD_PROMPT_INVALID", ] ), 404: get_error_schema( @@ -103,6 +106,7 @@ class AsyncGenerateAIFieldValuesView(APIView): GenerativeAITypeDoesNotExist: ERROR_GENERATIVE_AI_DOES_NOT_EXIST, ModelDoesNotBelongToType: ERROR_MODEL_DOES_NOT_BELONG_TO_TYPE, ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, + AIFieldPromptInvalidError: ERROR_AI_FIELD_PROMPT_INVALID, } ) @validate_body(GenerateAIFieldValueViewSerializer, return_validated=True) @@ -127,6 +131,8 @@ def post(self, request: Request, field_id: int, data) -> Response: context=ai_field.table, ) + # An invalid prompt raises `AIFieldPromptInvalidError` from the job type's + # `prepare_values`, mapped to a 400 by `map_exceptions` above. job = JobHandler().create_and_start_job( request.user, GenerateAIValuesJobType.type, diff --git a/premium/backend/src/baserow_premium/apps.py b/premium/backend/src/baserow_premium/apps.py index 2dd1002855..b3a596d4cd 100644 --- a/premium/backend/src/baserow_premium/apps.py +++ b/premium/backend/src/baserow_premium/apps.py @@ -8,6 +8,7 @@ class BaserowPremiumConfig(AppConfig): def ready(self): # noinspection PyUnresolvedReferences + import baserow_premium.fields.receivers # noqa: F401 import baserow_premium.license.receivers # noqa: F401 import baserow_premium.row_comments.receivers # noqa: F401 from baserow.core.registries import application_type_registry diff --git a/premium/backend/src/baserow_premium/fields/exceptions.py b/premium/backend/src/baserow_premium/fields/exceptions.py index 5748feec7f..932ef8bcba 100644 --- a/premium/backend/src/baserow_premium/fields/exceptions.py +++ b/premium/backend/src/baserow_premium/fields/exceptions.py @@ -10,3 +10,10 @@ class AIFieldEmptyPromptError(Exception): Raised when the resolved prompt for an AI field is empty, meaning there is nothing to send to the model. """ + + +class AIFieldPromptInvalidError(Exception): + """ + Raised when an AI field's prompt formula is broken (unparseable or references a + field that no longer exists), so values cannot be generated. + """ diff --git a/premium/backend/src/baserow_premium/fields/field_types.py b/premium/backend/src/baserow_premium/fields/field_types.py index fe83ccf0e1..d82943d00d 100644 --- a/premium/backend/src/baserow_premium/fields/field_types.py +++ b/premium/backend/src/baserow_premium/fields/field_types.py @@ -29,6 +29,7 @@ from baserow.contrib.database.fields.models import Field, LinkRowField from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.formula import BaserowFormulaType +from baserow.core.formula.parser.exceptions import BaserowFormulaException from baserow.core.formula.serializers import FormulaSerializerField from baserow.core.generative_ai.exceptions import ( GenerativeAITypeDoesNotExist, @@ -82,6 +83,7 @@ class AIFieldType(CollationSortMixin, SelectOptionBaseFieldType): "ai_prompt", "ai_file_field_id", "ai_auto_update", + "error", ] serializer_field_overrides = { "ai_output_type": serializers.ChoiceField( @@ -116,6 +118,12 @@ class AIFieldType(CollationSortMixin, SelectOptionBaseFieldType): help_text="If set, AI field will be recalculated if a value of a " "referenced field has been changed.", ), + "error": serializers.CharField( + required=False, + read_only=True, + allow_null=True, + help_text="The error message if the field's prompt is broken, else null.", + ), **SelectOptionBaseFieldType.serializer_field_overrides, } api_exceptions_map = { @@ -352,14 +360,31 @@ def _validate_field_kwargs( def get_field_dependencies( self, field_instance: AIField, field_cache: "FieldCache" ) -> FieldDependencies: - field_ids = set( - extract_field_id_dependencies(field_instance.ai_prompt["formula"]) - ) + try: + field_ids = set( + extract_field_id_dependencies(field_instance.ai_prompt["formula"]) + ) + except BaserowFormulaException: + # An unparseable prompt (e.g. from an import) is surfaced via the + # field's `error`; it simply has no field dependencies. + field_ids = set() if field_instance.ai_file_field_id is not None: field_ids.add(field_instance.ai_file_field_id) + # Scoped to the field's table, matching `get_ai_prompt_error`; a prompt + # can only reference fields in the same table. existing_field_ids = set( - Field.objects.filter(id__in=field_ids).values_list("id", flat=True) + Field.objects.filter( + id__in=field_ids, table_id=field_instance.table_id + ).values_list("id", flat=True) ) + # A trashed referenced field is declared as a broken reference (by name, + # like formula fields) so the edge survives and restoring the field + # re-links it and re-reports this field's error to the client. + trashed_names = Field.objects_and_trash.filter( + id__in=field_ids - existing_field_ids, + table_id=field_instance.table_id, + trashed=True, + ).values_list("name", flat=True) return [ FieldDependency( dependency_id=field_id, @@ -368,8 +393,46 @@ def get_field_dependencies( ) for field_id in field_ids if field_id in existing_field_ids + ] + [ + FieldDependency( + broken_reference_field_name=name, + dependant=field_instance, + via=None, + ) + for name in trashed_names ] + def field_dependency_updated( + self, + field, + updated_field, + updated_old_field, + update_collector, + field_cache, + via_path_to_starting_table=None, + ): + # When a referenced field is created, updated, deleted or restored, the + # prompt's validity (and thus the computed `error`) can change. Mark the + # field as changed so it is re-serialized and pushed to the client, + # without touching its stored cell values (a None update statement is + # the collector's "changed without a cell update"). Not + # `add_field_which_has_changed`: that only reports via the + # additional-signals path, which skips the starting table, and the AI + # field usually lives in the same table as the changed dependency. + # (`field_dependency_created` and `field_dependency_deleted` both + # delegate here in the base type.) + update_collector.add_field_with_pending_update_statement( + field, None, via_path_to_starting_table + ) + super().field_dependency_updated( + field, + updated_field, + updated_old_field, + update_collector, + field_cache, + via_path_to_starting_table, + ) + def _handle_dependent_rows_change( self, field: AIField, @@ -572,10 +635,11 @@ def after_import_serialized( field.ai_prompt, id_mapping["database_fields"] ) save = True - except KeyError: - # Raised when the field ID is not found in the mapping. If that's the - # case, we leave the field ID references broken so that the import - # can still succeed. + except (KeyError, BaserowFormulaException): + # KeyError: a referenced field ID isn't in the mapping. + # BaserowFormulaException: the prompt can't be parsed. + # In both cases we leave the prompt as-is so the import can still + # succeed; the broken state is surfaced via the field's `error`. pass if save: diff --git a/premium/backend/src/baserow_premium/fields/job_types.py b/premium/backend/src/baserow_premium/fields/job_types.py index 38e25b8964..f211d63b88 100644 --- a/premium/backend/src/baserow_premium/fields/job_types.py +++ b/premium/backend/src/baserow_premium/fields/job_types.py @@ -34,10 +34,12 @@ from baserow.core.jobs.models import JobQuerySet from baserow.core.jobs.registries import JobType from baserow.core.utils import ChildProgressBuilder +from baserow_premium.api.fields.errors import ERROR_AI_FIELD_PROMPT_INVALID -from .exceptions import AIFieldEmptyPromptError +from .exceptions import AIFieldEmptyPromptError, AIFieldPromptInvalidError from .handler import AIFieldHandler from .models import AIField, AIFieldScheduledUpdate, GenerateAIValuesJob +from .visitors import get_ai_prompt_error class AIValueUpdate(NamedTuple): @@ -57,6 +59,7 @@ class GenerateAIValuesJobType(JobType): WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST, ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST, + AIFieldPromptInvalidError: ERROR_AI_FIELD_PROMPT_INVALID, } serializer_field_names = [ "field_id", @@ -190,6 +193,12 @@ def prepare_values(self, values, user): AIFieldHandler.get_valid_model_type_or_raise(ai_field) + # Validate here rather than in the API views so every entry point + # (dedicated endpoint, generic /jobs/ endpoint) shares the invariant. + prompt_error = get_ai_prompt_error(ai_field.ai_prompt, ai_field.table_id) + if prompt_error: + raise AIFieldPromptInvalidError(prompt_error) + if unsaved_job.mode == GenerateAIValuesJob.MODES.AUTO_UPDATE: if not AIFieldScheduledUpdate.objects.filter(field_id=ai_field.id).exists(): raise ValueError("No rows scheduled for AI field auto update.") diff --git a/premium/backend/src/baserow_premium/fields/models.py b/premium/backend/src/baserow_premium/fields/models.py index 372b64d97c..ba5a2f4b98 100644 --- a/premium/backend/src/baserow_premium/fields/models.py +++ b/premium/backend/src/baserow_premium/fields/models.py @@ -14,6 +14,7 @@ from .ai_field_output_types import TextAIFieldOutputType from .registries import ai_field_output_registry +from .visitors import get_ai_prompt_error User = get_user_model() @@ -65,6 +66,14 @@ def ai_max_concurrent_generations(self) -> int: return settings.BASEROW_AI_FIELD_MAX_CONCURRENT_GENERATIONS + @property + def error(self): + # Computed (not stored) prompt error so the field header can show it and + # generation can be blocked. Recomputed whenever the field is serialized. + if not self.table_id: + return None + return get_ai_prompt_error(self.ai_prompt, self.table_id) + class GenerateAIValuesJob(JobWithUserIpAddress, JobWithUndoRedoIds, Job): class MODES(StrEnum): diff --git a/premium/backend/src/baserow_premium/fields/receivers.py b/premium/backend/src/baserow_premium/fields/receivers.py new file mode 100644 index 0000000000..ecb19155dd --- /dev/null +++ b/premium/backend/src/baserow_premium/fields/receivers.py @@ -0,0 +1,11 @@ +from django.dispatch import receiver + +from baserow.contrib.database.table.signals import table_schema_changed +from baserow.core.cache import local_cache + + +@receiver(table_schema_changed) +def invalidate_ai_prompt_field_ids_cache(sender, table_id, **kwargs): + # Invalidate the cached field ids used by AI prompt validation (see + # visitors.get_table_field_ids) when a field is added/updated/trashed/restored. + local_cache.delete(f"ai_prompt_table_field_ids_{table_id}") diff --git a/premium/backend/src/baserow_premium/fields/tasks.py b/premium/backend/src/baserow_premium/fields/tasks.py index b794a48048..957bae9b1d 100644 --- a/premium/backend/src/baserow_premium/fields/tasks.py +++ b/premium/backend/src/baserow_premium/fields/tasks.py @@ -8,6 +8,7 @@ from baserow.celery_singleton_backend import SingletonAutoRescheduleFlag from baserow.config.celery import app from baserow.core.jobs.handler import JobHandler +from baserow_premium.fields.exceptions import AIFieldPromptInvalidError from baserow_premium.fields.job_types import GenerateAIValuesJobType from baserow_premium.fields.models import AIField, AIFieldScheduledUpdate from baserow_premium.license.features import PREMIUM @@ -90,13 +91,18 @@ def generate_scheduled_ai_field_generation(field_id: int): # Synchronously run the job while keeping the singleton lock, to avoid # multiple concurrent job runs for the same field. - jh.create_and_start_job( - user, - GenerateAIValuesJobType.type, - field_id=field_id, - is_auto_update=True, - sync=True, - ) + try: + jh.create_and_start_job( + user, + GenerateAIValuesJobType.type, + field_id=field_id, + is_auto_update=True, + sync=True, + ) + except AIFieldPromptInvalidError: + # The prompt is broken, so there's nothing to generate. The scheduled rows + # are kept so they can be processed once the prompt is fixed. + return if flag.is_set(): _schedule_generate_ai_value_generation(field_id) diff --git a/premium/backend/src/baserow_premium/fields/visitors.py b/premium/backend/src/baserow_premium/fields/visitors.py index 4fc1d72bbc..9b8588503c 100644 --- a/premium/backend/src/baserow_premium/fields/visitors.py +++ b/premium/backend/src/baserow_premium/fields/visitors.py @@ -1,13 +1,17 @@ import re -from typing import Dict, Union +from typing import Dict, Optional, Union from baserow.contrib.database.fields.utils import get_field_id_from_field_key +from baserow.core.cache import local_cache from baserow.core.formula import ( BaserowFormula, BaserowFormulaObject, BaserowFormulaVisitor, ) -from baserow.core.formula.parser.exceptions import FieldByIdReferencesAreDeprecated +from baserow.core.formula.parser.exceptions import ( + BaserowFormulaException, + FieldByIdReferencesAreDeprecated, +) from baserow.core.formula.parser.parser import get_parse_tree_for_formula from baserow.core.utils import to_path @@ -167,9 +171,10 @@ def visitFunctionCall(self, ctx: BaserowFormula.FunctionCallContext): args = [expr.accept(self) for expr in function_argument_expressions] - # Detect get("'fields.field_XXX'") references + # Detect get('fields.field_XXX') references. The parser accepts both + # single and double quoted string literals, so strip either style. if function_name == "get" and args and args[0]: - if field_id_match := FIELD_ID_RE.match(args[0].strip("'")): + if field_id_match := FIELD_ID_RE.match(args[0].strip("'\"")): self.field_ids.add(int(field_id_match.group(1))) def visitLeftWhitespaceOrComments( @@ -198,3 +203,46 @@ def extract_field_id_dependencies( visitor = AIFieldIDExtractingVisitor() visitor.visit(tree) return visitor.field_ids + + +def get_table_field_ids(table_id: int) -> set[int]: + """ + Returns the ids of the non-trashed fields in the given table, cached per + request/task so validating many AI fields doesn't repeat the query. The cache + is invalidated by `table_schema_changed` (see receivers.py). + """ + + from baserow.contrib.database.fields.models import Field + + return local_cache.get( + f"ai_prompt_table_field_ids_{table_id}", + lambda: set( + Field.objects.filter(table_id=table_id, trashed=False).values_list( + "id", flat=True + ) + ), + ) + + +def get_ai_prompt_error( + prompt: Union[str, BaserowFormulaObject], table_id: int +) -> Optional[str]: + """ + Validates an AI field prompt formula. Returns an error message when the prompt + cannot be parsed or references a field that does not exist (non-trashed) in the + given table. Returns None for an empty or valid prompt. + """ + + formula_str = prompt if isinstance(prompt, str) else prompt["formula"] + if not formula_str: + return None + + try: + referenced_ids = extract_field_id_dependencies(formula_str) + except BaserowFormulaException: + return "The prompt formula could not be parsed." + + if referenced_ids and referenced_ids - get_table_field_ids(table_id): + return "The prompt references a field that no longer exists." + + return None diff --git a/premium/backend/tests/baserow_premium_tests/api/fields/test_ai_field_views.py b/premium/backend/tests/baserow_premium_tests/api/fields/test_ai_field_views.py index 2ef11ac725..26f067916b 100644 --- a/premium/backend/tests/baserow_premium_tests/api/fields/test_ai_field_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/fields/test_ai_field_views.py @@ -479,3 +479,71 @@ def test_list_jobs_filter_by_type_and_field_id(premium_data_fixture, api_client) assert response_data["jobs"][0]["id"] == job_2_id assert response_data["jobs"][0]["type"] == "generate_ai_values" assert response_data["jobs"][0]["field_id"] == field_2.id + + +@pytest.mark.django_db +@pytest.mark.field_ai +@override_settings(DEBUG=True) +def test_generate_ai_field_value_rejected_when_prompt_broken( + premium_data_fixture, api_client +): + premium_data_fixture.register_fake_generate_ai_type() + user, token = premium_data_fixture.create_user_and_token( + has_active_premium_license=True + ) + + database = premium_data_fixture.create_database_application( + user=user, name="database" + ) + table = premium_data_fixture.create_database_table(name="table", database=database) + field = premium_data_fixture.create_ai_field( + table=table, + name="ai", + ai_prompt={"version": 1, "formula": "get('fields.field_999999')"}, + ) + + rows = RowHandler().create_rows(user, table, rows_values=[{}]).created_rows + + response = api_client.post( + reverse( + "api:premium:fields:async_generate_ai_field_values", + kwargs={"field_id": field.id}, + ), + {"row_ids": [rows[0].id]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_AI_FIELD_PROMPT_INVALID" + + +@pytest.mark.django_db +@pytest.mark.field_ai +@override_settings(DEBUG=True) +def test_bulk_generate_ai_values_job_rejected_when_prompt_broken( + premium_data_fixture, api_client +): + premium_data_fixture.register_fake_generate_ai_type() + user, token = premium_data_fixture.create_user_and_token( + has_active_premium_license=True + ) + + database = premium_data_fixture.create_database_application( + user=user, name="database" + ) + table = premium_data_fixture.create_database_table(name="table", database=database) + field = premium_data_fixture.create_ai_field( + table=table, + name="ai", + ai_prompt={"version": 1, "formula": "get('fields.field_999999')"}, + ) + + # The whole-view/table bulk modal posts directly to the generic jobs endpoint. + response = api_client.post( + reverse("api:jobs:list"), + {"type": "generate_ai_values", "field_id": field.id}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_AI_FIELD_PROMPT_INVALID" diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py index 0aca241cfb..02ca119269 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py @@ -10,6 +10,7 @@ from baserow.contrib.database.application_types import DatabaseApplicationType from baserow.contrib.database.fields.dependencies.models import FieldDependency +from baserow.contrib.database.fields.field_cache import FieldCache from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.models import FileField from baserow.contrib.database.fields.registries import field_type_registry @@ -22,6 +23,7 @@ from baserow.core.cache import local_cache from baserow.core.db import specific_iterator from baserow.core.registries import ImportExportConfig +from baserow.core.trash.handler import TrashHandler from baserow_premium.fields.field_types import AIFieldType from baserow_premium.fields.models import AIField @@ -425,6 +427,7 @@ def test_create_ai_field_type_via_api_invalid_formula(premium_data_fixture, api_ format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) + # An unparseable prompt is rejected on save. assert response.status_code == HTTP_400_BAD_REQUEST response_json = response.json() assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION" @@ -1303,6 +1306,33 @@ def test_create_ai_field_with_references(premium_data_fixture): ) +@pytest.mark.django_db +@pytest.mark.field_ai +def test_ai_field_ignores_cross_table_references_in_dependencies( + premium_data_fixture, +): + premium_data_fixture.register_fake_generate_ai_type() + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + other_table = premium_data_fixture.create_database_table(user=user) + other_field = premium_data_fixture.create_text_field(table=other_table) + + ai_field = FieldHandler().create_field( + user=user, + table=table, + type_name="ai", + name="ai", + ai_generative_ai_type="test_generative_ai", + ai_generative_ai_model="test_1", + ai_prompt=f"get('fields.field_{other_field.id}')", + ) + + # A reference to another table's field is invalid, so it must not create a + # dependency edge that would let that field's changes touch this one. + assert ai_field.error is not None + assert not FieldDependency.objects.filter(dependant_id=ai_field.id).exists() + + @pytest.mark.django_db @pytest.mark.field_ai def test_create_ai_field_auto_update_user(premium_data_fixture): @@ -1733,3 +1763,206 @@ def test_import_ai_field_disables_auto_update(premium_data_fixture): imported_field = AIField.objects.get(id=imported_field.id) assert imported_field.ai_auto_update is False assert imported_field.ai_auto_update_user_id is None + + +@pytest.mark.field_ai +@pytest.mark.django_db +def test_ai_field_error_property_detects_broken_prompt(premium_data_fixture): + premium_data_fixture.register_fake_generate_ai_type() + table = premium_data_fixture.create_database_table() + broken = premium_data_fixture.create_ai_field( + table=table, + name="AI", + ai_prompt={"version": 1, "formula": "get('fields.field_999999')"}, + ) + assert broken.error is not None + + text_field = premium_data_fixture.create_text_field(table=table) + valid = premium_data_fixture.create_ai_field( + table=table, + name="AI2", + ai_prompt={ + "version": 1, + "formula": f"get('fields.field_{text_field.id}')", + }, + ) + assert valid.error is None + + +@pytest.mark.field_ai +@pytest.mark.django_db +def test_ai_field_api_serializes_error(api_client, premium_data_fixture): + premium_data_fixture.register_fake_generate_ai_type() + user, token = premium_data_fixture.create_user_and_token( + has_active_premium_license=True + ) + table = premium_data_fixture.create_database_table(user=user) + field = premium_data_fixture.create_ai_field( + table=table, + name="AI", + ai_prompt={"version": 1, "formula": "get('fields.field_999999')"}, + ) + response = api_client.get( + reverse("api:database:fields:item", kwargs={"field_id": field.id}), + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()["error"] is not None + + +@pytest.mark.field_ai +@pytest.mark.django_db +def test_ai_field_error_clears_when_prompt_fixed(premium_data_fixture): + table = premium_data_fixture.create_database_table() + text_field = premium_data_fixture.create_text_field(table=table) + field = premium_data_fixture.create_ai_field( + table=table, + name="AI", + ai_prompt={"version": 1, "formula": "get('fields.field_999999')"}, + ) + assert field.error is not None + + field.ai_prompt = { + "version": 1, + "formula": f"get('fields.field_{text_field.id}')", + } + field.save() + field.refresh_from_db() + + assert field.error is None + + +@pytest.mark.field_ai +@pytest.mark.django_db +def test_ai_field_import_with_broken_reference_records_error(premium_data_fixture): + table = premium_data_fixture.create_database_table() + field = premium_data_fixture.create_ai_field( + table=table, + name="AI", + ai_prompt={"version": 1, "formula": "get('fields.field_424242')"}, + ) + field_type = field_type_registry.get_by_model(field) + exported = field_type.export_serialized(field) + + # id_mapping without the referenced field id -> after_import_serialized swallows + # the KeyError and leaves the broken reference, so import must not raise. + id_mapping = {"database_fields": {}} + imported_field = field_type.import_serialized( + table, + exported, + ImportExportConfig(include_permission_data=False), + id_mapping, + deferred_fk_update_collector=DeferredForeignKeyUpdater(), + ) + field_type.after_import_serialized(imported_field, FieldCache(), id_mapping) + + imported_field = AIField.objects.get(id=imported_field.id) + assert imported_field.error is not None + + +@pytest.mark.field_ai +@pytest.mark.django_db +def test_ai_field_import_with_unparseable_prompt_records_error(premium_data_fixture): + table = premium_data_fixture.create_database_table() + field = premium_data_fixture.create_ai_field( + table=table, + name="AI", + ai_prompt={"version": 1, "formula": "get('fields.field_1') x hello"}, + ) + field_type = field_type_registry.get_by_model(field) + exported = field_type.export_serialized(field) + + # An unparseable prompt (e.g. from an old or hand-edited export) must not + # break the import; the field simply ends up broken. + id_mapping = {"database_fields": {}} + imported_field = field_type.import_serialized( + table, + exported, + ImportExportConfig(include_permission_data=False), + id_mapping, + deferred_fk_update_collector=DeferredForeignKeyUpdater(), + ) + field_type.after_import_serialized(imported_field, FieldCache(), id_mapping) + + imported_field = AIField.objects.get(id=imported_field.id) + assert imported_field.error is not None + + +@pytest.mark.field_ai +@pytest.mark.django_db +def test_deleting_referenced_field_marks_ai_field_as_updated(premium_data_fixture): + premium_data_fixture.register_fake_generate_ai_type() + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field(table=table) + ai_field = FieldHandler().create_field( + user, + table, + "ai", + name="AI", + ai_generative_ai_type="test_generative_ai", + ai_generative_ai_model="test_1", + ai_prompt={ + "version": 1, + "formula": f"get('fields.field_{text_field.id}')", + }, + ) + assert ai_field.error is None + + # A previously generated value must survive the dependency deletion. + model = table.get_model() + row = model.objects.create(**{f"field_{ai_field.id}": "generated"}) + + # Deleting the referenced field must report the AI field as updated so the + # client re-fetches it and sees the new broken state. + updated = FieldHandler().delete_field(user, text_field) + assert ai_field.id in [f.id for f in updated] + + ai_field.refresh_from_db() + assert ai_field.error is not None + + row.refresh_from_db() + assert getattr(row, f"field_{ai_field.id}") == "generated" + + +@pytest.mark.field_ai +@pytest.mark.django_db +@patch("baserow.contrib.database.fields.signals.field_restored.send") +def test_restoring_referenced_field_clears_ai_field_error( + field_restored_mock, premium_data_fixture +): + premium_data_fixture.register_fake_generate_ai_type() + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field(table=table) + ai_field = FieldHandler().create_field( + user, + table, + "ai", + name="AI", + ai_generative_ai_type="test_generative_ai", + ai_generative_ai_model="test_1", + ai_prompt={ + "version": 1, + "formula": f"get('fields.field_{text_field.id}')", + }, + ) + model = table.get_model() + row = model.objects.create(**{f"field_{ai_field.id}": "generated"}) + + FieldHandler().delete_field(user, text_field) + ai_field.refresh_from_db() + assert ai_field.error is not None + + # Restoring the referenced field must report the AI field as updated so the + # client re-fetches it and sees the error is gone. + TrashHandler.restore_item(user, "field", text_field.id) + related = field_restored_mock.call_args[1]["related_fields"] + assert ai_field.id in [f.id for f in related] + + ai_field.refresh_from_db() + assert ai_field.error is None + + # The generated cell value must survive the delete/restore round trip. + row.refresh_from_db() + assert getattr(row, f"field_{ai_field.id}") == "generated" diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_visitors.py b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_visitors.py index 673284a05c..63fd301bf6 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_visitors.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_visitors.py @@ -1,7 +1,12 @@ import pytest +from baserow.core.cache import local_cache from baserow.core.formula import BaserowFormulaSyntaxError -from baserow_premium.fields.visitors import replace_field_id_references +from baserow_premium.fields.visitors import ( + extract_field_id_dependencies, + get_ai_prompt_error, + replace_field_id_references, +) @pytest.mark.field_ai @@ -32,3 +37,73 @@ def test_field_id_references_invalid_id(): def test_field_id_references_invalid_formula(): with pytest.raises(BaserowFormulaSyntaxError): replace_field_id_references("get('fields.field_1'))", {}) + + +@pytest.mark.django_db +def test_get_ai_prompt_error_returns_none_for_empty_prompt(premium_data_fixture): + table = premium_data_fixture.create_database_table() + assert get_ai_prompt_error("", table.id) is None + + +@pytest.mark.django_db +def test_get_ai_prompt_error_returns_none_for_valid_reference(premium_data_fixture): + table = premium_data_fixture.create_database_table() + text_field = premium_data_fixture.create_text_field(table=table) + prompt = f"concat('Hi ', get('fields.field_{text_field.id}'))" + assert get_ai_prompt_error(prompt, table.id) is None + + +@pytest.mark.django_db +def test_get_ai_prompt_error_detects_unparseable_prompt(premium_data_fixture): + table = premium_data_fixture.create_database_table() + # Unbalanced parenthesis -> BaserowFormulaSyntaxError. + assert get_ai_prompt_error("concat('a', ", table.id) is not None + + +@pytest.mark.django_db +def test_get_ai_prompt_error_detects_missing_field_reference(premium_data_fixture): + table = premium_data_fixture.create_database_table() + # field_999999 does not exist in this table. + prompt = "get('fields.field_999999')" + assert get_ai_prompt_error(prompt, table.id) is not None + + +@pytest.mark.field_ai +def test_extract_field_id_dependencies_supports_both_quote_styles(): + assert extract_field_id_dependencies("get('fields.field_1')") == {1} + assert extract_field_id_dependencies('get("fields.field_1")') == {1} + + +@pytest.mark.django_db +def test_get_ai_prompt_error_detects_double_quoted_missing_field_reference( + premium_data_fixture, +): + table = premium_data_fixture.create_database_table() + prompt = 'get("fields.field_999999")' + assert get_ai_prompt_error(prompt, table.id) is not None + + +@pytest.mark.django_db +def test_get_ai_prompt_error_caches_field_ids_per_table( + premium_data_fixture, django_assert_num_queries +): + table = premium_data_fixture.create_database_table() + text_field = premium_data_fixture.create_text_field(table=table) + prompt = f"get('fields.field_{text_field.id}')" + + with local_cache.context(): + # The second validation reuses the cached field ids. + with django_assert_num_queries(1): + assert get_ai_prompt_error(prompt, table.id) is None + assert get_ai_prompt_error(prompt, table.id) is None + + +@pytest.mark.django_db +def test_get_ai_prompt_error_detects_reference_to_other_table_field( + premium_data_fixture, +): + table = premium_data_fixture.create_database_table() + other_table = premium_data_fixture.create_database_table() + other_field = premium_data_fixture.create_text_field(table=other_table) + prompt = f"get('fields.field_{other_field.id}')" + assert get_ai_prompt_error(prompt, table.id) is not None diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_generate_ai_values_job_type.py b/premium/backend/tests/baserow_premium_tests/fields/test_generate_ai_values_job_type.py index 4a85489828..0c014b79ef 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_generate_ai_values_job_type.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_generate_ai_values_job_type.py @@ -21,6 +21,7 @@ from baserow.core.storage import get_default_storage from baserow.core.user_files.handler import UserFileHandler from baserow_premium.fields.ai_field_output_types import ChoiceAIFieldOutputType +from baserow_premium.fields.exceptions import AIFieldPromptInvalidError from baserow_premium.fields.models import GenerateAIValuesJob @@ -52,6 +53,29 @@ def test_create_job_rows_mode(premium_data_fixture): assert job.mode == GenerateAIValuesJob.MODES.ROWS +@pytest.mark.django_db +@pytest.mark.field_ai +def test_create_job_rejected_when_prompt_broken(premium_data_fixture): + """A broken prompt must block job creation for every mode/entry point.""" + + premium_data_fixture.register_fake_generate_ai_type() + user = premium_data_fixture.create_user() + database = premium_data_fixture.create_database_application(user=user) + table = premium_data_fixture.create_database_table(database=database) + field = premium_data_fixture.create_ai_field( + table=table, ai_prompt="get('fields.field_999999')" + ) + + with pytest.raises(AIFieldPromptInvalidError): + JobHandler().create_and_start_job( + user, + "generate_ai_values", + field_id=field.id, + ) + + assert GenerateAIValuesJob.objects.count() == 0 + + @pytest.mark.django_db @pytest.mark.field_ai def test_create_job_view_mode(premium_data_fixture): @@ -638,15 +662,17 @@ def test_generate_ai_field_value_view_generative_ai_invalid_field( .created_rows ) assert patched_rows_updated.call_count == 0 - JobHandler().create_and_start_job( - user, "generate_ai_values", sync=True, field_id=field.id, row_ids=[rows[0].id] - ) - assert patched_rows_updated.call_count == 1 - updated_row = patched_rows_updated.call_args[1]["rows"][0] - assert ( - getattr(updated_row, field.db_column) - == "Generated with temperature None: Hello " - ) + # A prompt referencing a missing field is rejected at job creation instead of + # silently generating with an empty reference value. + with pytest.raises(AIFieldPromptInvalidError): + JobHandler().create_and_start_job( + user, + "generate_ai_values", + sync=True, + field_id=field.id, + row_ids=[rows[0].id], + ) + assert patched_rows_updated.call_count == 0 @pytest.mark.django_db @@ -666,7 +692,8 @@ def test_generate_ai_field_value_view_generative_ai_invalid_prompt( ) table = premium_data_fixture.create_database_table(name="table", database=database) firstname = premium_data_fixture.create_text_field(table=table, name="firstname") - formula = "concat('Hello ', get('fields.field_0'))" + # The prompt must be valid; a broken one is rejected at job creation now. + formula = f"concat('Hello ', get('fields.field_{firstname.id}'))" field = premium_data_fixture.create_ai_field( table=table, name="ai", diff --git a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue index 4b0a561520..daf6dcb972 100644 --- a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue +++ b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue @@ -80,7 +80,7 @@
@@ -92,9 +92,16 @@ :validation-context="{ dataProviderRegistry: dataProviders }" @input="updatedFormulaStr" @update:mode="updateMode" + @update:invalid="promptInvalid = $event" />
- +
  • @@ -88,12 +89,16 @@ export default { hasPremium() { return this.$hasFeature(PremiumFeatures.PREMIUM, this.workspace.id) }, + // Indicates if the field's prompt is broken and can't be used to generate values. + promptBroken() { + return !!this.field.error + }, }, methods: { openModal() { if (!this.hasPremium) { this.$refs.paidFeaturesModal.show() - } else if (this.modelAvailable) { + } else if (this.modelAvailable && !this.promptBroken) { this.$emit('hide-context') this.$refs.generateAIValuesModal.show() } diff --git a/premium/web-frontend/modules/baserow_premium/components/row/RowEditFieldAI.vue b/premium/web-frontend/modules/baserow_premium/components/row/RowEditFieldAI.vue index 57338630be..52a56f01a2 100644 --- a/premium/web-frontend/modules/baserow_premium/components/row/RowEditFieldAI.vue +++ b/premium/web-frontend/modules/baserow_premium/components/row/RowEditFieldAI.vue @@ -15,13 +15,18 @@ > {{ $t('rowEditFieldAI.generate') }} - + +
    {{ $t('rowEditFieldAI.createRowBefore') }}
    -
    +