Skip to content

Commit 6b4d7f4

Browse files
authored
fix: ground Kuma with workspace plan context (baserow#5227)
* fix: ground Kuma with workspace plan context Inject the current workspace plan tier into Kuma's runtime context and add a docs-first guardrail so feature and plan questions are grounded instead of guessed. * fix: tighten Kuma plan tier grounding Handle missing and unknown license tiers explicitly, log unexpected plan lookup failures, and align the prompt with the exact lower-case plan tokens injected into assistant context. * address feedback
1 parent 54c8509 commit 6b4d7f4

11 files changed

Lines changed: 311 additions & 33 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "bug",
3+
"message": "Give Kuma the current license tier in its context and steer uncertain feature or plan questions to docs search.",
4+
"issue_origin": "github",
5+
"issue_number": 5210,
6+
"domain": "core",
7+
"bullet_points": [],
8+
"created_at": "2026-04-17"
9+
}

enterprise/backend/src/baserow_enterprise/assistant/agents.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
from baserow_enterprise.assistant.prompts import AGENT_SYSTEM_PROMPT
66
from baserow_enterprise.assistant.tools.toolset import tool_manifest_line_compact
77

8+
FREE_LICENSE_TIER = "free"
9+
_CANONICAL_LICENSE_TIERS = {
10+
FREE_LICENSE_TIER,
11+
"premium",
12+
"advanced",
13+
"enterprise",
14+
}
15+
_LICENSE_TIER_ALIASES = {
16+
"enterprise_without_support": "enterprise",
17+
}
18+
819
main_agent: Agent[AssistantDeps, str] = Agent(
920
deps_type=AssistantDeps,
1021
output_type=str,
@@ -14,6 +25,17 @@
1425
)
1526

1627

28+
def _canonical_license_tier(license_tier: str) -> str:
29+
"""
30+
Return the public license tier token that is safe to inject into the prompt.
31+
"""
32+
33+
normalized_tier = _LICENSE_TIER_ALIASES.get(license_tier, license_tier)
34+
if normalized_tier in _CANONICAL_LICENSE_TIERS:
35+
return normalized_tier
36+
return FREE_LICENSE_TIER
37+
38+
1739
@main_agent.instructions
1840
def dynamic_ui_context(ctx) -> str:
1941
"""Inject the UI context into the system prompt dynamically."""
@@ -31,6 +53,20 @@ def dynamic_mode(ctx) -> str:
3153
return f"\n<mode>{ctx.deps.mode.value}</mode>"
3254

3355

56+
@main_agent.instructions
57+
def dynamic_license_tier(ctx) -> str:
58+
"""Inject the active workspace license tier and its paid features."""
59+
60+
lt = ctx.deps.license_tier
61+
if lt is None:
62+
return f"\n<license_tier>{FREE_LICENSE_TIER}</license_tier>"
63+
features = ",".join(sorted(lt.features))
64+
return (
65+
f"\n<license_tier>{_canonical_license_tier(lt.type)}</license_tier>"
66+
f"\n<features>{features}</features>"
67+
)
68+
69+
3470
@main_agent.instructions
3571
def dynamic_current_task(ctx) -> str:
3672
"""Pin the original user request as immutable context."""

enterprise/backend/src/baserow_enterprise/assistant/assistant.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
from typing import Any, AsyncGenerator
33

4+
from django.contrib.auth.models import AbstractUser
45
from django.core.cache import cache
56
from django.utils import translation
67

@@ -22,6 +23,7 @@
2223
from pydantic_ai.usage import UsageLimits
2324

2425
from baserow.api.sessions import get_client_undo_redo_action_group_id
26+
from baserow.core.models import Workspace
2527
from baserow_enterprise.assistant.agents import main_agent, title_agent
2628
from baserow_enterprise.assistant.deps import (
2729
AgentMode,
@@ -46,6 +48,8 @@
4648
)
4749
from baserow_enterprise.assistant.tools.navigation.utils import unsafe_navigate_to
4850
from baserow_enterprise.assistant.tools.registries import assistant_tool_registry
51+
from baserow_premium.api.user.user_data_types import ActiveLicensesDataType
52+
from baserow_premium.license.registries import LicenseType, license_type_registry
4953

5054
from .models import AssistantChat, AssistantChatMessage, AssistantChatPrediction
5155
from .types import (
@@ -101,6 +105,37 @@ def set_assistant_cancellation_key(
101105
cache.set(get_assistant_cancellation_key(chat_uuid), True, timeout=timeout)
102106

103107

108+
def _get_workspace_license_type(
109+
user: AbstractUser, workspace: Workspace
110+
) -> LicenseType | None:
111+
"""
112+
Pick the highest-``order`` ``LicenseType`` active for the user in the workspace,
113+
reusing the same data the frontend consumes from ``ActiveLicensesDataType``. Returns
114+
``None`` when no license applies.
115+
116+
:param user: The user for whom to get the license type.
117+
:param workspace: The workspace for which to get the license type.
118+
:return: The active LicenseType with the highest order, or None if no license is
119+
active.
120+
"""
121+
122+
try:
123+
active = ActiveLicensesDataType().get_user_data(user, None)
124+
names = set(active["instance_wide"]) | set(
125+
active["per_workspace"].get(workspace.id, {})
126+
)
127+
return max(
128+
(lt for lt in license_type_registry.get_all() if lt.type in names),
129+
key=lambda lt: lt.order,
130+
default=None,
131+
)
132+
except Exception:
133+
logger.exception(
134+
"Failed to determine workspace license type for assistant context."
135+
)
136+
return None
137+
138+
104139
def _extract_tool_thought(event: FunctionToolCallEvent) -> str | None:
105140
"""Extract the chain-of-thought ``thought`` argument from a tool call
106141
event, if present and non-empty."""
@@ -134,6 +169,7 @@ def __init__(self, chat: AssistantChat):
134169
user=self._user,
135170
workspace=self._workspace,
136171
tool_helpers=self._tool_helpers,
172+
license_tier=_get_workspace_license_type(self._user, self._workspace),
137173
)
138174
self._toolset, db_m, app_m, auto_m, explain_m = (
139175
assistant_tool_registry.build_toolset(

enterprise/backend/src/baserow_enterprise/assistant/deps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from baserow_enterprise.assistant.tools.navigation.types import (
1414
AnyNavigationRequestType,
1515
)
16+
from baserow_premium.license.registries import LicenseType
1617

1718

1819
class AgentMode(str, Enum):
@@ -120,6 +121,7 @@ class AssistantDeps:
120121
workspace: "Workspace"
121122
tool_helpers: ToolHelpers
122123
mode: AgentMode = AgentMode.DATABASE
124+
license_tier: "LicenseType | None" = None
123125
sources: list[str] = field(default_factory=list)
124126
dynamic_tools: list[Tool] = field(default_factory=list)
125127
database_manifest: str = ""

enterprise/backend/src/baserow_enterprise/assistant/prompts.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@
4141
</baserow_knowledge>
4242
"""
4343

44+
GROUNDING = """\
45+
<grounding>
46+
If you are not sure whether a Baserow feature, plan, limit, setting, or UI behavior exists, do not guess. Use `search_user_docs` first.
47+
If the docs do not confirm it, say you don't know. Never invent plan names, feature names, pricing, upgrade advice, or UI paths.
48+
The canonical plan names are Free, Premium, Advanced, and Enterprise. `<license_tier>` uses the lowercase equivalents (`free`, `premium`, `advanced`, `enterprise`); treat them as exact matches.
49+
`<features>` is the exhaustive list of paid feature flags the current workspace has. Never claim a feature is available if it is not in `<features>`. Use `search_user_docs` to explain what each feature does.
50+
</grounding>
51+
"""
52+
4453
LIMITATIONS_AND_SOURCES = f"""\
4554
<limitations>
4655
Cannot create/modify/delete: user accounts, workspaces, dashboards, widgets, snapshots, webhooks, integrations, roles, permissions.
@@ -53,5 +62,6 @@
5362
+ RULES
5463
+ HANDLING_AMBIGUITY
5564
+ BASEROW_KNOWLEDGE
65+
+ GROUNDING
5666
+ LIMITATIONS_AND_SOURCES
5767
)

enterprise/backend/src/baserow_enterprise/assistant/telemetry.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from uuid import uuid4
3737

3838
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
39+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
3940
from opentelemetry.trace import SpanKind
4041

4142
from baserow.core.posthog import get_posthog_client
@@ -461,7 +462,8 @@ def setup_instrumentation():
461462

462463
from pydantic_ai import Agent, InstrumentationSettings
463464

464-
tracer_provider = TracerProvider()
465+
# Prevent environment OTEL_TRACES_SAMPLER config from dropping assistant traces.
466+
tracer_provider = TracerProvider(sampler=ALWAYS_ON)
465467
tracer_provider.add_span_processor(PosthogSpanProcessor())
466468

467469
Agent.instrument_all(

enterprise/backend/src/baserow_enterprise/assistant/tools/search_user_docs/tools.py

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ async def _search_user_docs_impl(
170170
) -> dict[str, Any]:
171171
"""Inner implementation of search_user_docs, separated for error handling."""
172172

173+
from baserow_enterprise.assistant.model_profiles import get_model_string
174+
from baserow_enterprise.assistant.retrying_model import _resolve_model
175+
173176
@sync_to_async
174177
def _search(question: str) -> list[KnowledgeBaseChunk]:
175178
chunks = KnowledgeBaseHandler().search(question, 15)
@@ -198,41 +201,35 @@ def _search(question: str) -> list[KnowledgeBaseChunk]:
198201
f"Question: {question}\n\n"
199202
f"Documentation context (source URL -> content):\n{context}"
200203
)
201-
from baserow_enterprise.assistant.model_profiles import get_model_string
202-
from baserow_enterprise.assistant.retrying_model import _resolve_model
203204

204205
agent_result = await search_docs_agent.run(
205206
prompt, model=_resolve_model(get_model_string())
206207
)
207208
prediction = agent_result.output
208209

210+
# Force reliability to 0 if model says nothing was found.
211+
nothing_found = "nothing found" in prediction.answer.lower()
212+
reliability = 0.0 if nothing_found else prediction.reliability
213+
209214
sources = []
210215
available_urls = {chunk.source_document.source_url for chunk in relevant_chunks}
211-
for url in prediction.sources:
212-
# somehow LLMs sometimes return sources as objects
213-
if isinstance(url, dict) and "url" in url:
214-
url = url["url"]
215-
216-
if not isinstance(url, str):
217-
continue
218-
219-
if url in available_urls and url not in sources:
220-
sources.append(url)
221-
if len(sources) >= 3:
222-
break
223-
224-
# Only fallback to available URLs if reliability is high AND we have a
225-
# real answer. Don't populate sources if the model indicated no relevant
226-
# docs were found.
227-
nothing_found = "nothing found" in prediction.answer.lower()
228-
if not sources and prediction.reliability > 0.8 and not nothing_found:
229-
sources = list(available_urls)[:3]
216+
if not nothing_found:
217+
for url in prediction.sources:
218+
# somehow LLMs sometimes return sources as objects
219+
if isinstance(url, dict) and "url" in url:
220+
url = url["url"]
230221

231-
# Override reliability to 0 if the model explicitly said nothing was
232-
# found. The model sometimes returns high reliability for "nothing
233-
# found" answers, which is semantically incorrect - we want reliability
234-
# to reflect whether we actually found useful information.
235-
reliability = 0.0 if nothing_found else prediction.reliability
222+
if not isinstance(url, str):
223+
continue
224+
225+
if url in available_urls and url not in sources:
226+
sources.append(url)
227+
if len(sources) >= 3:
228+
break
229+
230+
# Fallback to available URLs if the model didn't cite sources.
231+
if not sources:
232+
sources = list(available_urls)[:3]
236233

237234
if reliability >= 0.7:
238235
reliability_note = (
@@ -242,7 +239,8 @@ def _search(question: str) -> list[KnowledgeBaseChunk]:
242239
reliability_note = (
243240
"PARTIAL MATCH: Some relevant information was found, but the "
244241
"documentation may not fully cover this topic. Supplement with "
245-
"general knowledge but warn the user that details may be incomplete."
242+
"general knowledge if you're confident it is accurate and up to date, "
243+
"but warn the user that details may be incomplete."
246244
)
247245
else:
248246
reliability_note = (

enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/eval_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pydantic_ai.usage import UsageLimits
1616

1717
from baserow_enterprise.assistant.agents import main_agent
18+
from baserow_enterprise.assistant.assistant import _get_workspace_license_type
1819
from baserow_enterprise.assistant.deps import AssistantDeps, ToolHelpers
1920
from baserow_enterprise.assistant.tools.registries import assistant_tool_registry
2021
from baserow_enterprise.assistant.types import (
@@ -209,6 +210,7 @@ def create_eval_assistant(user, workspace, max_iters=15, model=None):
209210
user=user,
210211
workspace=workspace,
211212
tool_helpers=tool_helpers,
213+
license_tier=_get_workspace_license_type(user, workspace),
212214
)
213215

214216
# Build the single-agent toolset (navigation + core + database + automation)

enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_search_user_docs.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,21 @@ def _require_knowledge_base(synced_knowledge_base):
111111
["permission", "field", "read", "lock"],
112112
id="field-permissions",
113113
),
114+
pytest.param(
115+
"Which Baserow plan unlocks field-level permissions for a workspace?",
116+
["field-level-permissions", "permissions"],
117+
["plan", "field-level permissions", "field permissions", "enterprise"],
118+
id="plan-for-field-level-permissions",
119+
),
120+
pytest.param(
121+
(
122+
"I can't find the conditional options toggle for my single select field. "
123+
"Should I upgrade, or is there another requirement?"
124+
),
125+
["single-select", "select-option", "fields"],
126+
["conditional", "single select", "plan", "upgrade"],
127+
id="conditional-options-plan-question",
128+
),
114129
pytest.param(
115130
(
116131
"How can I create a calendar that shows my tasks, but only the ones assigned to me."
@@ -121,9 +136,8 @@ def _require_knowledge_base(synced_knowledge_base):
121136
),
122137
pytest.param(
123138
(
124-
"I'm trying to combine the first name and last name columns "
125-
"into one, but I want to make sure it's uppercase. Can you tell me how to "
126-
"write that formula?"
139+
"What would a formula look like that combines a first name and last name field "
140+
"into a full name field?"
127141
),
128142
["formula", "understanding-formulas"],
129143
["concat", "upper", "formula"],
@@ -270,7 +284,7 @@ def test_search_user_docs(
270284
hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}",
271285
)
272286
checks.check(
273-
f"returned at least one source URL for user docs",
287+
"returned at least one source URL for user docs",
274288
len(sources) >= 1,
275289
hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}",
276290
)

0 commit comments

Comments
 (0)