fix(assistant): recheck starter prompt access on every request

Cap combined prompts at three and remove cached authorization-sensitive suggestions. Preserve bounded deterministic generation and cover field, row, table, policy and history changes. Thirty focused tests passed.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandCodex committed 2026-10-19 08:50:26 -05:00
1 parent 407bdcdb47
commit b250585dde
5 files changed
+140 -96

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@
through the assistant and that have rows (a count as the user), using the table
labels, a date field when present, and "What changed in ... this week?" when the
change-history tool is offered. The pick is stable for the user for the day and
cached for five minutes. Any failure yields no generated prompts.
rechecked on every request, including table, row and field access. Generation
uses bounded UI API calls and no model calls. Any failure yields no generated prompts.
- A configured starter prompt (Assistant Profile > Starter prompts, one per line) is
shown only when it declares the tables it needs and the user can read all of
them. Declare them with a leading tag: `[blog_post] Which posts are drafts?`, or
@@ -195,7 +195,7 @@ public final class AssistantSpecs
Map<String, Schema> rs = new LinkedHashMap<>();
rs.put("AssistantProfileV1", object("How the assistant presents itself (admin-configured; text only, it never changes access).",
"displayName", string("The assistant's name."), "icon", string("A Material icon name."), "greeting", string("Shown on a new conversation.").withNullable(true),
"starterPrompts", array(string(null), "Suggested first questions, built for this user: up to three generated from the tables the user can read that have data, plus any configured ones whose tables the user can read.")));
"starterPrompts", array(string(null), "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request.")));
rs.put("AssistantConfigResponseV1", object("Whether the assistant is available to this user, and how.",
"enabled", bool("Whether the panel may be used."),
"reason", string("Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH.").withNullable(true),
@@ -31,7 +31,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
@@ -60,7 +59,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** dropped, because there is no way to tell which tables it mentions.
**
** The result is stable for a user for the day (the pick is seeded by user id
** and date, not random), cached for a few minutes, bounded in work and time,
** and date, not random), rechecked on every request, bounded in work and time,
** and never throws: any failure yields no generated prompts.
*******************************************************************************/
public class StarterPrompts
@@ -72,26 +71,22 @@ public class StarterPrompts
public static final int MAX_DESCRIBE_CALLS = 3;
public static final Duration CALL_TIMEOUT = Duration.ofSeconds(3);
public static final Duration TOTAL_BUDGET = Duration.ofSeconds(8);
public static final Duration CACHE_TTL = Duration.ofMinutes(5);
public static final Duration FAILURE_TTL = Duration.ofSeconds(30);
public static final String NO_TABLE_TAG = "none";
private static final int MAX_CACHED_USERS = 2000;
private static final int MAX_PROMPT_CHARS = 200;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final UiApiToolbox toolbox;
private final Clock clock;
private final Map<String, CachedPrompts> cache = new ConcurrentHashMap<>();
/*******************************************************************************
** What was worked out for a user: the tables they may read and the
** generated prompts, good until the expiry and only for that day.
** generated prompts for this request.
*******************************************************************************/
private record CachedPrompts(Instant expires, LocalDate day, Set<String> readableTables, List<String> generated)
private record PromptSelection(Set<String> readableTables, List<String> generated)
{
}
@@ -131,7 +126,7 @@ public class StarterPrompts
** generated ones up to {@link #MAX_PROMPTS} in all. Never throws.
**
** @param context where the calls run: the user's session and the policy
** @param userId the cache key; with none, there are no generated prompts
** @param userId the daily selection seed; with none, there are no generated prompts
** @param configured the profile's starter lines, tags included
** @param historyOffered whether the change-history tool is available
*******************************************************************************/
@@ -139,10 +134,14 @@ public class StarterPrompts
{
try
{
CachedPrompts entry = entryFor(context, userId, historyOffered);
PromptSelection entry = entryFor(context, userId, historyOffered);
List<String> rs = new ArrayList<>();
for(String line : configured == null ? List.<String>of() : configured)
{
if(rs.size() >= MAX_PROMPTS)
{
break;
}
String prompt = allowedConfigured(line, entry.readableTables());
if(prompt != null)
{
@@ -169,52 +168,26 @@ public class StarterPrompts
/*******************************************************************************
** Drop every cached entry (tests, or after a change to data access).
** Recompute eligibility with the current session, permissions and data.
** Table, row and field access may change without a new session or user id.
*******************************************************************************/
public void invalidate()
private PromptSelection entryFor(UiApiToolbox.Context context, String userId, boolean historyOffered)
{
cache.clear();
}
/*******************************************************************************
** The cached entry, or a fresh one.
*******************************************************************************/
private CachedPrompts entryFor(UiApiToolbox.Context context, String userId, boolean historyOffered)
{
Instant now = clock.instant();
Instant now = clock.instant();
LocalDate today = LocalDate.ofInstant(now, ZoneOffset.UTC);
if(userId == null)
{
return (new CachedPrompts(now, today, Set.of(), List.of()));
return (new PromptSelection(Set.of(), List.of()));
}
CachedPrompts cached = cache.get(userId);
if(cached != null && cached.expires().isAfter(now) && cached.day().equals(today))
{
return (cached);
}
CachedPrompts fresh;
try
{
fresh = build(limited(context), userId, today, now, historyOffered);
return (build(limited(context), userId, today, now, historyOffered));
}
catch(RuntimeException e)
{
LOG.warn("Could not read what the user can access for starter prompts", e);
fresh = new CachedPrompts(now.plus(FAILURE_TTL), today, Set.of(), List.of());
return (new PromptSelection(Set.of(), List.of()));
}
if(cache.size() >= MAX_CACHED_USERS)
{
cache.values().removeIf(c -> !c.expires().isAfter(now));
if(cache.size() >= MAX_CACHED_USERS)
{
cache.clear();
}
}
cache.put(userId, fresh);
return (fresh);
}
@@ -255,11 +228,11 @@ public class StarterPrompts
/*******************************************************************************
** Work out the readable tables and the generated prompts.
*******************************************************************************/
private CachedPrompts build(UiApiToolbox.Context context, String userId, LocalDate today, Instant now, boolean historyOffered)
private PromptSelection build(UiApiToolbox.Context context, String userId, LocalDate today, Instant now, boolean historyOffered)
{
Instant deadline = now.plus(TOTAL_BUDGET);
Instant deadline = now.plus(TOTAL_BUDGET);
List<TableRef> readable = readableTables(context);
Set<String> names = new LinkedHashSet<>();
Set<String> names = new LinkedHashSet<>();
readable.forEach(t -> names.add(t.name()));
List<TableRef> order = new ArrayList<>(readable);
@@ -280,7 +253,7 @@ public class StarterPrompts
}
}
return (new CachedPrompts(now.plus(CACHE_TTL), today, names, promptsFor(withRows, userId, today, historyOffered)));
return (new PromptSelection(names, promptsFor(withRows, userId, today, historyOffered)));
}
@@ -289,9 +289,7 @@ components:
nullable: true
type: "string"
starterPrompts:
description: "Suggested first questions, built for this user: up to three\
\ generated from the tables the user can read that have data, plus any\
\ configured ones whose tables the user can read."
description: "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request."
items:
type: "string"
type: "array"
@@ -19,12 +19,14 @@ package com.kingsrook.qbits.assistant.engine;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
@@ -32,6 +34,8 @@ import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
@@ -43,7 +47,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The per-user starter prompts: built only from tables the user can read and
** that have rows, stable for the day, cached, and never failing.
** that have rows, stable for the day, freshly authorized, and never failing.
*******************************************************************************/
class StarterPromptsTest extends BaseAssistantTest
{
@@ -183,10 +187,10 @@ class StarterPromptsTest extends BaseAssistantTest
/*******************************************************************************
** The answer is cached per user for a few minutes, and rebuilt after.
** Every request rechecks access with bounded tool calls.
*******************************************************************************/
@Test
void cachedBriefly() throws Exception
void accessIsRecheckedWithBoundedCalls() throws Exception
{
UiApiTestServer.serve(qInstance, config);
UiApiGateway delegate = config.getRuntime().getUiApiGateway();
@@ -214,51 +218,12 @@ class StarterPromptsTest extends BaseAssistantTest
return (delegate.invoke(call));
}
};
Instant[] now = { NOW };
Clock clock = new Clock()
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public ZoneId getZone()
{
return (ZoneOffset.UTC);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Clock withZone(ZoneId zone)
{
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Instant instant()
{
return (now[0]);
}
};
StarterPrompts starter = new StarterPrompts(config.getRuntime().getToolbox(), clock);
StarterPrompts starter = new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(NOW, ZoneOffset.UTC));
starter.forUser(context(AssistantTestFixture.EDITOR, counting), AssistantTestFixture.EDITOR, List.of(), false);
int firstCalls = calls.get();
assertThat(firstCalls).isBetween(2, 1 + StarterPrompts.MAX_COUNT_CALLS + StarterPrompts.MAX_DESCRIBE_CALLS);
now[0] = NOW.plusSeconds(60);
starter.forUser(context(AssistantTestFixture.EDITOR, counting), AssistantTestFixture.EDITOR, List.of(), false);
assertThat(calls.get()).isEqualTo(firstCalls);
now[0] = NOW.plusSeconds(StarterPrompts.CACHE_TTL.toSeconds() + 1);
starter.forUser(context(AssistantTestFixture.EDITOR, counting), AssistantTestFixture.EDITOR, List.of(), false);
assertThat(calls.get()).isEqualTo(firstCalls * 2);
@@ -288,4 +253,111 @@ class StarterPromptsTest extends BaseAssistantTest
assertThat(StarterPrompts.plural("Media")).isEqualTo("media");
}
/*******************************************************************************
** Configured prompts and generated prompts share the same total limit.
*******************************************************************************/
@Test
void configuredPromptsRespectTotalLimit() throws Exception
{
StarterPrompts starter = prompts(NOW);
List<String> result = starter.forUser(context(AssistantTestFixture.LIMITED, config.getRuntime().getUiApiGateway()), AssistantTestFixture.LIMITED,
List.of("[blog_post] Denied", "[none] One", "[none] Two", "[media] Denied too", "[none] Three", "[none] Four"), false);
assertThat(result).containsExactly("One", "Two", "Three");
}
/*******************************************************************************
** A data-access update invalidates eligibility on the next config request.
*******************************************************************************/
@Test
void nextConfigReflectsRevokedDataAccess() throws Exception
{
UiApiTestServer.serve(qInstance, config);
context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThat(String.valueOf(((Map<?, ?>) api.config().get("profile")).get("starterPrompts"))).contains("daily stats");
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE)
.withObjectName("analytics_daily_stat").withMode("NONE"));
config.getRuntime().getDataAccessService().invalidate();
context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
assertThat(((Map<?, ?>) api.config().get("profile")).get("starterPrompts")).isEqualTo(List.of());
}
/*******************************************************************************
** A user cannot retain suggestions from an earlier permission context.
*******************************************************************************/
@Test
void changedSessionPermissionsAndToolPolicyAreRechecked() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
assertThat(starter.forUser(context(AssistantTestFixture.EDITOR, gateway), "same-user", List.of(), false)).isNotEmpty();
assertThat(starter.forUser(context(AssistantTestFixture.LIMITED, gateway), "same-user", List.of("[blog_post] Old access"), false)).isEmpty();
UiApiToolbox.Context allowed = context(AssistantTestFixture.ANALYST, gateway);
assertThat(starter.forUser(allowed, "same-user", List.of(), false)).isNotEmpty();
UiApiToolbox.Context denied = new UiApiToolbox.Context(allowed.sessionUUID(), null, gateway,
new PolicyService.ToolPolicy(true, Set.of(UiApiToolbox.DESCRIBE_TABLE), 50, true), null);
assertThat(starter.forUser(denied, "same-user", List.of(), false)).isEmpty();
}
/*******************************************************************************
** Removing the history tool immediately removes its generated suggestions.
*******************************************************************************/
@Test
void historyAvailabilityIsNotCached() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), true))).contains("What changed");
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false))).doesNotContain("What changed");
}
/*******************************************************************************
** A date field that becomes private no longer supports recency prompts.
*******************************************************************************/
@Test
void revokedDateFieldRemovesRecencyPrompts() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
String described = config.getRuntime().getToolbox().execute(context, "describe-date-fixture", UiApiToolbox.DESCRIBE_TABLE,
Map.of("table", "analytics_daily_stat")).getResult().getModelContent();
assertThat(described).contains("\"name\":\"statDate\"", "\"type\":\"DATE\"");
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false))).contains("recent");
for(String name : List.of("statDate", "createDate", "modifyDate"))
{
qInstance.getTable("analytics_daily_stat").getField(name)
.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
}
assertThat(starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false)).containsExactly("How many daily stats are there?");
}
/*******************************************************************************
** Tables with no currently visible rows are removed on the next request.
*******************************************************************************/
@Test
void changedVisibleRowsAreRechecked() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
assertThat(starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false)).isNotEmpty();
QContext.init(qInstance, AssistantTestFixture.sessionFor("seeder-0"));
new DeleteAction().execute(new DeleteInput("analytics_daily_stat").withQueryFilter(new QQueryFilter()));
assertThat(starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false)).isEmpty();
}
}