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:
5 files changed
+140
-96
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+20
-47
@@ -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"
|
||||
|
||||
+116
-44
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user