Conversation management: table access, archive/restore/delete actions, UI API 1.6.0 #65
No files matched your search
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Conversation management (UI API extension 1.6.0)
|
||||
|
||||
- `assistant_conversation` is open to every assistant user (`assistant.hasAccess` or `assistant.admin`, through the new `AssistantAccessPermissionChecker`) in the standard table UI. Rows stay locked to their owner by the `userId` record lock, so a user only ever sees, updates and deletes their own conversations; admins with the all-access key still see everyone's. The Assistant app (its nav) and every other assistant table stay admin-only.
|
||||
- Users change only a conversation's title, pinned flag and status (Active / Archived) through the table; the other fields are the assistant's own. A new title is encrypted in HIPAA mode, including literal text beginning with `enc:v1:`. Only an exact match to the same record's stored ciphertext is preserved unchanged; internal SYSTEM writes retain their existing storage path.
|
||||
- A table delete of a conversation also deletes its messages, turns, turn events, tool calls, feedback, data audits and pending actions (the same rows `DELETE /conversations/{id}` removes), so nothing is orphaned on a database without the cascading foreign keys. A conversation with an answer in progress is refused.
|
||||
- New record actions on `assistant_conversation`: **Archive conversations**, **Restore conversations** and **Delete conversations** (standard streamed ETL: selected records, Review, Result; same permission as the table; only rows the user can see; delete skips conversations with an answer in progress).
|
||||
- UI API extension assistant 1.6.0: `GET /conversations` items have `running` (an answer in progress: a RUNNING or CANCELLING turn that is not orphaned); `PATCH /conversations/{id}` accepts `{"read": true}` (sets `unreadCount` to 0); `GET /config` has `conversationsTable: "assistant_conversation"` when the session may query that table.
|
||||
|
||||
## Unreleased - Starter prompts follow access
|
||||
|
||||
- `profile.starterPrompts` in `GET /assistant/config` is built per user: up to three prompts generated from tables the user can read that have rows (UI API metadata and count as the user, bounded, current access rechecked on every request, stable order for the day), plus configured prompts only when tagged with tables the user can read (`[table] prompt`, `[none] prompt`; untagged lines are dropped). See UPGRADE.md.
|
||||
|
||||
+13
@@ -1,5 +1,18 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## Conversation management (Unreleased, UI API extension assistant 1.6.0)
|
||||
|
||||
No schema change. `assistant_conversation` is now usable by every user holding
|
||||
`assistant.hasAccess` (not only `assistant.admin`), together with three new
|
||||
record actions (Archive, Restore and Delete conversations). Rows stay locked to
|
||||
their owner. A user's table queries only return their own rows if their session
|
||||
carries their owner key: call `AssistantSecurity.applySessionKeys` from the
|
||||
authentication customizer for **every** user, not only for admins. Without it,
|
||||
non-admin users see an empty table.
|
||||
|
||||
The panel can link to the table using `conversationsTable` from `GET /config`.
|
||||
That field is present only when the user may query the table.
|
||||
|
||||
## Starter prompts follow the user's access (Unreleased)
|
||||
|
||||
`GET /assistant/config` no longer returns the profile's starter prompts as written.
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.processes.ConversationActionsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
|
||||
@@ -184,6 +185,11 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
for(QProcessMetaData process : ConversationActionsProcess.processes())
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
@@ -60,8 +61,12 @@ import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
@@ -170,6 +175,10 @@ public class AssistantApi
|
||||
profile.put("greeting", persona.greeting());
|
||||
profile.put("starterPrompts", reason == null ? starterPrompts(persona) : List.of());
|
||||
result.put("profile", profile);
|
||||
if(mayQueryConversationsTable())
|
||||
{
|
||||
result.put("conversationsTable", AssistantConversation.TABLE_NAME);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
@@ -204,10 +213,13 @@ public class AssistantApi
|
||||
{
|
||||
requireAccess();
|
||||
String effective = "ARCHIVED".equalsIgnoreCase(status) ? "ARCHIVED" : "ACTIVE";
|
||||
Set<Integer> running = runningConversationIds();
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for(AssistantConversation conversation : store.listConversations(userId, effective))
|
||||
{
|
||||
list.add(toJson(conversation));
|
||||
Map<String, Object> item = toJson(conversation);
|
||||
item.put("running", running.contains(conversation.getId()));
|
||||
list.add(item);
|
||||
}
|
||||
return (Map.of("conversations", list));
|
||||
}
|
||||
@@ -337,6 +349,18 @@ public class AssistantApi
|
||||
update.setValue("status", status);
|
||||
conversation.setStatus((String) status);
|
||||
}
|
||||
if(body.containsKey("read"))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the panel watched the reply stream in: clear the unread count //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(!Boolean.TRUE.equals(body.get("read")))
|
||||
{
|
||||
throw (new AssistantApiException(400, "read can only be true."));
|
||||
}
|
||||
update.setValue("unreadCount", 0);
|
||||
conversation.setUnreadCount(0);
|
||||
}
|
||||
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, update);
|
||||
return (toJson(conversation));
|
||||
@@ -676,6 +700,53 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ids of the user's conversations with an answer in progress: a RUNNING or
|
||||
** CANCELLING turn that is not orphaned (orphans are recovered on the way,
|
||||
** as in GET /turns/active).
|
||||
*******************************************************************************/
|
||||
Set<Integer> runningConversationIds() throws QException
|
||||
{
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
for(AssistantTurn turn : store.listRunningTurns(userId))
|
||||
{
|
||||
turn = reapIfOrphaned(turn);
|
||||
if(isRunning(turn) && turn.getConversationId() != null)
|
||||
{
|
||||
ids.add(turn.getConversationId());
|
||||
}
|
||||
}
|
||||
return (ids);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a turn row (of any owner) is an answer still in progress: running
|
||||
** here, or running elsewhere and not orphaned. Read-only (no recovery).
|
||||
*******************************************************************************/
|
||||
boolean inProgress(AssistantTurn turn)
|
||||
{
|
||||
return (isRunning(turn) && (runtime.getRunningTurn(turn.getId()) != null || !orphaned(turn)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this session may query the conversation table in the standard
|
||||
** table UI (assistant access or admin; rows stay owner-locked).
|
||||
*******************************************************************************/
|
||||
private boolean mayQueryConversationsTable()
|
||||
{
|
||||
if(userId == null || QContext.getQInstance() == null || QContext.getQInstance().getTable(AssistantConversation.TABLE_NAME) == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A remote run loses its lease, exceeds the absolute deadline, or has a
|
||||
** cancellation request that its owner did not acknowledge in time.
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.api;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
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.QInputSource;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The conversation table as users use it in the standard table UI (their own
|
||||
** rows, through the owner record lock):
|
||||
** - update: only title, pinned and status (ACTIVE / ARCHIVED) can change;
|
||||
** the other fields (owner, classification, counters, context) are the
|
||||
** assistant's own. A new title is encrypted (HIPAA mode) as the panel's
|
||||
** writes are; a stored, still-encrypted title sent back unchanged is kept.
|
||||
** - delete: refused while the conversation has an answer in progress (as
|
||||
** DELETE /conversations/{id}); afterwards its child rows are deleted the
|
||||
** same way that route deletes them, so nothing is orphaned.
|
||||
** The assistant's own writes (system input source) pass through unchanged.
|
||||
*******************************************************************************/
|
||||
public class AssistantConversationCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
public static final Set<String> USER_EDITABLE_FIELDS = Set.of("title", "pinned", "status");
|
||||
|
||||
public static final String IN_PROGRESS = "This conversation has an answer in progress. Stop it first.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users change only title, pinned and status.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(updateInput.getInputSource() == QInputSource.SYSTEM)
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
for(String field : new ArrayList<>(record.getValues().keySet()))
|
||||
{
|
||||
if(!"id".equals(field) && !USER_EDITABLE_FIELDS.contains(field))
|
||||
{
|
||||
record.removeValue(field);
|
||||
}
|
||||
}
|
||||
Serializable status = record.getValue("status");
|
||||
if(record.getValues().containsKey("status") && !"ACTIVE".equals(status) && !"ARCHIVED".equals(status))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Status must be Active or Archived."));
|
||||
}
|
||||
if(!isPreview && runtime != null && record.getValue("title") instanceof String title)
|
||||
{
|
||||
boolean unchangedStoredCiphertext = AssistantStore.isEncryptedValue(title) && record.getValueInteger("id") != null
|
||||
&& oldRecordList.orElse(List.of()).stream().anyMatch(oldRecord -> record.getValueInteger("id").equals(oldRecord.getValueInteger("id"))
|
||||
&& title.equals(oldRecord.getValueString("title")));
|
||||
if(!unchangedStoredCiphertext)
|
||||
{
|
||||
record.setValue("title", runtime.getStore().encryptForStorage(AssistantConversation.TABLE_NAME, new QRecord().withValue("title", title)).getValue("title"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation with an answer in progress is not deleted.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null || deleteInput.getInputSource() == QInputSource.SYSTEM || records.isEmpty())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(hasAnswerInProgress(runtime, api, record.getValueInteger("id")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(IN_PROGRESS));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete the deleted conversations' child rows, as the owner's rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getValueInteger("id") != null && record.getValueString("userId") != null)
|
||||
{
|
||||
runtime.getStore().deleteConversationChildren(record.getValueString("userId"), record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a conversation (of any owner the session can see) has a running
|
||||
** or cancelling turn that is not orphaned.
|
||||
*******************************************************************************/
|
||||
public static boolean hasAnswerInProgress(AssistantRuntime runtime, AssistantApi api, Integer conversationId) throws QException
|
||||
{
|
||||
if(conversationId == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
List<AssistantTurn> turns = runtime.getStore().query(AssistantTurn.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId)
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of("RUNNING", "CANCELLING")), AssistantTurn.class);
|
||||
return (turns.stream().anyMatch(api::inProgress));
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus;
|
||||
public final class AssistantSpecs
|
||||
{
|
||||
public static final String EXTENSION_NAME = "assistant";
|
||||
public static final String EXTENSION_VERSION = "1.5.0";
|
||||
public static final String EXTENSION_VERSION = "1.6.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -205,10 +205,13 @@ public final class AssistantSpecs
|
||||
+ "maxAttachments (files per question) and attachmentTypes (human-readable type names for the file picker hint; images only when the model reads them)."),
|
||||
"features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation; attachments: files uploaded with POST /qqq/v1/uploads "
|
||||
+ "and sent as pageContext.attachments [{uploadRef, fileName}] are read for the question."),
|
||||
"profile", ref("AssistantProfileV1")));
|
||||
"profile", ref("AssistantProfileV1"),
|
||||
"conversationsTable", string("Since 1.6.0: the table name of the user's conversations (assistant_conversation) for the standard table UI "
|
||||
+ "(manage, archive, restore and delete); present only when this session may query that table.").withNullable(true)));
|
||||
rs.put("AssistantConversationV1", object("One of the user's own conversations.",
|
||||
"id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null),
|
||||
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true)));
|
||||
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true),
|
||||
"running", bool("Since 1.6.0, in GET /conversations items only: whether an answer is in progress (a RUNNING or CANCELLING turn that is not orphaned).").withNullable(true)));
|
||||
rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null)));
|
||||
rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).",
|
||||
"id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true),
|
||||
@@ -818,7 +821,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected String summary()
|
||||
{
|
||||
return ("Rename, pin or archive a conversation");
|
||||
return ("Rename, pin, archive or mark read a conversation");
|
||||
}
|
||||
|
||||
|
||||
@@ -834,7 +837,8 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED.")));
|
||||
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."),
|
||||
"read", bool("Since 1.6.0: true sets unreadCount to 0 (the user watched the reply stream in). Only true is accepted.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guards what every assistant user may use in the standard table UI (their
|
||||
** own conversations and the conversation record actions): the user needs
|
||||
** assistant access ("assistant.hasAccess") or "assistant.admin". Which rows
|
||||
** they see is decided by the owner record lock, not by this checker.
|
||||
*******************************************************************************/
|
||||
public class AssistantAccessPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || !(session.hasPermission(AssistantQBitConfig.ACCESS_PERMISSION) || session.hasPermission(AssistantQBitConfig.ADMIN_PERMISSION)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Not authorized to use the assistant."));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+26
-2
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.metadata;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantConversationCustomizer;
|
||||
import com.kingsrook.qbits.assistant.health.AssistantHealthWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
@@ -90,7 +91,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** - The key type's all-access key (assistant_user_id_all_access) lets admins see
|
||||
** everyone's rows; hosts grant it to users holding assistant.admin with
|
||||
** AssistantSecurity.applySessionKeys in their authentication customizer.
|
||||
** - Tables, app and process need assistant.admin in the generic UI / API.
|
||||
** - Tables, app and process need assistant.admin in the generic UI / API,
|
||||
** except assistant_conversation and its record actions (archive, restore,
|
||||
** delete), which every assistant user may use on their own rows. The app
|
||||
** (its nav) stays admin-only.
|
||||
** - assistant_usage_daily rows with a null userId are the INSTANCE rollup and
|
||||
** are readable by any session (the lock allows null).
|
||||
** - The assistant's own tool kernel never exposes assistant_* tables to the
|
||||
@@ -160,10 +164,15 @@ public class AssistantMetaDataBuilder
|
||||
{
|
||||
List<QTableMetaData> tables = new ArrayList<>();
|
||||
|
||||
QCodeReference conversationCustomizer = new QCodeReference(AssistantConversationCustomizer.class);
|
||||
tables.add(owned(base(AssistantConversation.TABLE_NAME, "Assistant Conversation", "forum", AssistantConversation.class), false)
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("title")
|
||||
.withoutCapabilities(Capability.TABLE_INSERT));
|
||||
.withoutCapabilities(Capability.TABLE_INSERT)
|
||||
.withPermissionRules(accessRules())
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, conversationCustomizer)
|
||||
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, conversationCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, conversationCustomizer));
|
||||
text(tables.get(0), "contextJson");
|
||||
|
||||
tables.add(text(readOnly(owned(base(AssistantMessage.TABLE_NAME, "Assistant Message", "chat", AssistantMessage.class), false)), "partsJson")
|
||||
@@ -415,6 +424,21 @@ public class AssistantMetaDataBuilder
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** assistant access or assistant.admin, through the custom checker (explicit
|
||||
** level and deny behavior, as in adminRules): for the conversation table
|
||||
** and its record actions, which users use on their own (record-locked) rows.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules accessRules()
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withDenyBehavior(DenyBehavior.HIDDEN)
|
||||
.withCustomPermissionChecker(new QCodeReference(AssistantAccessPermissionChecker.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
+35
-1
@@ -313,6 +313,20 @@ public class AssistantStore
|
||||
** Delete a conversation and everything under it.
|
||||
*******************************************************************************/
|
||||
public void deleteConversation(String userId, Integer conversationId) throws QException
|
||||
{
|
||||
deleteConversationChildren(userId, conversationId);
|
||||
delete(AssistantConversation.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, conversationId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a conversation's child rows (turns and their events, messages,
|
||||
** tool calls, feedback, data audits, pending actions), not the conversation
|
||||
** itself. A table delete of the conversation calls this afterwards, so
|
||||
** nothing is orphaned on a database without the cascading foreign keys.
|
||||
*******************************************************************************/
|
||||
public void deleteConversationChildren(String userId, Integer conversationId) throws QException
|
||||
{
|
||||
List<Serializable> turnIds = new ArrayList<>();
|
||||
for(AssistantTurn turn : query(AssistantTurn.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId), AssistantTurn.class))
|
||||
@@ -329,7 +343,6 @@ public class AssistantStore
|
||||
}
|
||||
delete(AssistantDataAudit.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
|
||||
delete(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
|
||||
delete(AssistantConversation.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, conversationId));
|
||||
}
|
||||
|
||||
|
||||
@@ -958,6 +971,27 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A copy of a record with the stored-encrypted fields encrypted (HIPAA
|
||||
** mode), for writes that do not go through this store (the table UI).
|
||||
*******************************************************************************/
|
||||
public QRecord encryptForStorage(String tableName, QRecord record)
|
||||
{
|
||||
return (cryptRecord(tableName, record, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a stored value is in the encrypted form (HIPAA mode).
|
||||
*******************************************************************************/
|
||||
public static boolean isEncryptedValue(String value)
|
||||
{
|
||||
return (ConversationEncryption.isEncrypted(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy records at the storage boundary so memory-backend records and callers are never mutated.
|
||||
*******************************************************************************/
|
||||
|
||||
+10
@@ -89,6 +89,16 @@ final class ConversationEncryption
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a stored value is in this codec's encrypted form.
|
||||
*******************************************************************************/
|
||||
public static boolean isEncrypted(String value)
|
||||
{
|
||||
return (value != null && value.startsWith(PREFIX));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always encrypt plaintext, even strings that happen to start with enc:v1:.
|
||||
*******************************************************************************/
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantConversationCustomizer;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Archive conversations", "Restore conversations" and "Delete conversations":
|
||||
** standard streamed-ETL record actions on assistant_conversation. The
|
||||
** selected records come in through the user's own query (so the owner
|
||||
** record lock decides which rows they act on), Review says what will
|
||||
** happen, Result what happened. Same permission as the table: assistant
|
||||
** access or assistant.admin. Table actions, never navigation entries.
|
||||
** Delete removes a conversation with its messages, turns, events, tool
|
||||
** calls, feedback and pending actions, and skips one whose answer is still
|
||||
** in progress, as DELETE /conversations/{id} does.
|
||||
*******************************************************************************/
|
||||
public final class ConversationActionsProcess
|
||||
{
|
||||
public static final String ARCHIVE = "assistantConversationArchive";
|
||||
public static final String RESTORE = "assistantConversationRestore";
|
||||
public static final String DELETE = "assistantConversationDelete";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a process does with one selected conversation.
|
||||
*******************************************************************************/
|
||||
enum Outcome
|
||||
{
|
||||
ACT,
|
||||
SKIP,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ConversationActionsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The three processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
return (List.of(
|
||||
process(ARCHIVE, "Archive conversations", "archive", "The selected conversations will be archived. They leave the assistant's list; nothing is deleted."),
|
||||
process(RESTORE, "Restore conversations", "unarchive", "The selected archived conversations will return to the assistant's list."),
|
||||
process(DELETE, "Delete conversations", "delete", "The selected conversations will be deleted with their messages. This cannot be undone.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard streamed-ETL record action on the conversation table.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData process(String name, String label, String icon, String previewMessage)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantConversation.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantConversation.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, previewMessage);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values);
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setIcon(new QIcon(icon));
|
||||
process.setTableName(AssistantConversation.TABLE_NAME);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.accessRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify one selected conversation for this process.
|
||||
*******************************************************************************/
|
||||
static Outcome outcome(String processName, QRecord record, AssistantRuntime runtime, AssistantApi api) throws QException
|
||||
{
|
||||
String status = record.getValueString("status");
|
||||
return (switch(processName)
|
||||
{
|
||||
case ARCHIVE -> "ARCHIVED".equals(status) ? Outcome.SKIP : Outcome.ACT;
|
||||
case RESTORE -> "ARCHIVED".equals(status) ? Outcome.ACT : Outcome.SKIP;
|
||||
default -> AssistantConversationCustomizer.hasAnswerInProgress(runtime, api, record.getValueInteger("id")) ? Outcome.IN_PROGRESS : Outcome.ACT;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary lines, worded for the process (future on Review, past on Result).
|
||||
*******************************************************************************/
|
||||
static Map<Outcome, ProcessSummaryLine> lines(String processName)
|
||||
{
|
||||
Map<Outcome, ProcessSummaryLine> lines = new EnumMap<>(Outcome.class);
|
||||
switch(processName)
|
||||
{
|
||||
case ARCHIVE ->
|
||||
{
|
||||
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be archived", "conversations will be archived",
|
||||
"conversation was archived", "conversations were archived"));
|
||||
lines.put(Outcome.SKIP, DiscoverModelsProcess.line(Status.INFO, "conversation is already archived", "conversations are already archived",
|
||||
"conversation was already archived", "conversations were already archived"));
|
||||
}
|
||||
case RESTORE ->
|
||||
{
|
||||
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be restored", "conversations will be restored",
|
||||
"conversation was restored", "conversations were restored"));
|
||||
lines.put(Outcome.SKIP, DiscoverModelsProcess.line(Status.INFO, "conversation is not archived", "conversations are not archived",
|
||||
"conversation was not archived", "conversations were not archived"));
|
||||
}
|
||||
default ->
|
||||
{
|
||||
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be deleted with its messages", "conversations will be deleted with their messages",
|
||||
"conversation was deleted", "conversations were deleted"));
|
||||
lines.put(Outcome.IN_PROGRESS, DiscoverModelsProcess.line(Status.ERROR, "conversation has an answer in progress and will not be deleted",
|
||||
"conversations have an answer in progress and will not be deleted", "conversation was not deleted: an answer was in progress",
|
||||
"conversations were not deleted: an answer was in progress"));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lines that have a count, in a fixed order.
|
||||
*******************************************************************************/
|
||||
static ArrayList<ProcessSummaryLineInterface> summary(Map<Outcome, ProcessSummaryLine> lines, String nothing)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
for(ProcessSummaryLine line : lines.values())
|
||||
{
|
||||
line.addSelfToListIfAnyCount(rs);
|
||||
}
|
||||
if(rs.isEmpty())
|
||||
{
|
||||
rs.add(new ProcessSummaryLine(Status.INFO, null, nothing));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The running assistant (or a clear error).
|
||||
*******************************************************************************/
|
||||
private static AssistantRuntime runtime() throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null)
|
||||
{
|
||||
throw (new QException("The assistant is not running on this server."));
|
||||
}
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what will happen to each selected conversation. Changes nothing.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private Map<Outcome, ProcessSummaryLine> lines;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
|
||||
if(lines == null)
|
||||
{
|
||||
lines = lines(input.getProcessName());
|
||||
}
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
lines.get(outcome(input.getProcessName(), record, runtime, api)).incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (summary(lines == null ? Map.of() : lines, "No conversations were selected."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result: archive, restore or delete each conversation (checked again), as
|
||||
** the user, through the assistant's store.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private Map<Outcome, ProcessSummaryLine> lines;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
|
||||
if(lines == null)
|
||||
{
|
||||
lines = lines(input.getProcessName());
|
||||
}
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
Integer id = record.getValueInteger("id");
|
||||
Outcome outcome = outcome(input.getProcessName(), record, runtime, api);
|
||||
if(outcome == Outcome.ACT)
|
||||
{
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case ARCHIVE -> runtime.getStore().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "ARCHIVED"));
|
||||
case RESTORE -> runtime.getStore().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "ACTIVE"));
|
||||
default -> runtime.getStore().deleteConversation(record.getValueString("userId"), id);
|
||||
}
|
||||
}
|
||||
lines.get(outcome).incrementCountAndAddPrimaryKey(id);
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (summary(lines == null ? Map.of() : lines, "No conversations were changed."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@ entries:
|
||||
content: |
|
||||
A conversation is created when a user asks their first question in the assistant panel. Everything that follows (messages, turns, tool calls, feedback, model calls) hangs off it and is listed on its screen.
|
||||
|
||||
Most fields are filled in automatically and cannot be edited. You can only change the **{{label:assistant_conversation.title}}** and whether it is **{{label:assistant_conversation.pinned}}**.
|
||||
Most fields are filled in automatically and cannot be edited. You can only change the **{{label:assistant_conversation.title}}**, whether it is **{{label:assistant_conversation.pinned}}** and its **{{label:assistant_conversation.status}}** (Active or Archived).
|
||||
|
||||
Use this list to see who is using the assistant, how much it costs, and to open a conversation when you need to find out why an answer went wrong. Conversations are not kept forever: the **{{label:assistantRetention}}** action deletes conversations that have passed the configured inactivity period (90 days by default), together with their messages, turns, tool calls and feedback.
|
||||
Every assistant user can open this table to manage their own conversations: only their own rows are listed. They can archive, restore and delete conversations with **{{label:assistantConversationArchive}}**, **{{label:assistantConversationRestore}}** and **{{label:assistantConversationDelete}}**. Deleting a conversation also deletes its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress cannot be deleted until the answer is stopped.
|
||||
|
||||
Administrators see every user's conversations here. Use this list to see who is using the assistant, how much it costs, and to open a conversation when you need to find out why an answer went wrong. Conversations are not kept forever: the **{{label:assistantRetention}}** action deletes conversations that have passed the configured inactivity period (90 days by default), together with their messages, turns, tool calls and feedback.
|
||||
|
||||
See also **{{label:assistant_policy}}** for limits and **{{label:assistant_provider}}** for where answers come from.
|
||||
- key: table:assistant_conversation;section:conversation
|
||||
@@ -1555,6 +1557,21 @@ entries:
|
||||
summary: When the worker last reported that this answer was still running.
|
||||
- key: table:assistant_turn;field:cancelRequestedAt
|
||||
summary: When a request to stop this answer was recorded.
|
||||
- key: process:assistantConversationArchive;slot:help
|
||||
summary: Archive the selected conversations. They leave the assistant's list, and nothing is deleted.
|
||||
content: |
|
||||
Select conversations in **{{label:assistant_conversation}}** and choose **Archive conversations**. Review shows how many will be archived and how many are already archived. You can only act on conversations you can see: your own, or everyone's if you are an assistant administrator.
|
||||
|
||||
- key: process:assistantConversationRestore;slot:help
|
||||
summary: Restore the selected archived conversations to the assistant's list.
|
||||
content: |
|
||||
Select archived conversations in **{{label:assistant_conversation}}** and choose **Restore conversations**. Conversations that are not archived are skipped.
|
||||
|
||||
- key: process:assistantConversationDelete;slot:help
|
||||
summary: Delete the selected conversations with their messages. This cannot be undone.
|
||||
content: |
|
||||
Select conversations in **{{label:assistant_conversation}}** and choose **Delete conversations**. Each conversation is deleted with its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress is skipped. Stop the answer first, then delete it.
|
||||
|
||||
- key: process:assistantDataAccessAddDefaults;slot:help
|
||||
summary: Add missing data access rules for application tables using their current effective access.
|
||||
content: |
|
||||
|
||||
@@ -200,6 +200,12 @@ components:
|
||||
type: "object"
|
||||
profile:
|
||||
$ref: "#/components/schemas/AssistantProfileV1"
|
||||
conversationsTable:
|
||||
description: "Since 1.6.0: the table name of the user's conversations (assistant_conversation)\
|
||||
\ for the standard table UI (manage, archive, restore and delete); present\
|
||||
\ only when this session may query that table."
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AssistantConversationDetailResponseV1:
|
||||
description: "A conversation and a page of its messages, oldest first."
|
||||
@@ -242,6 +248,11 @@ components:
|
||||
description: "ISO-8601 instant."
|
||||
nullable: true
|
||||
type: "string"
|
||||
running:
|
||||
description: "Since 1.6.0, in GET /conversations items only: whether an\
|
||||
\ answer is in progress (a RUNNING or CANCELLING turn that is not orphaned)."
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AssistantErrorV1:
|
||||
description: "Deprecated since 1.3.0: errors use BasicErrorResponseV1."
|
||||
@@ -457,7 +468,7 @@ info:
|
||||
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
|
||||
\ and data access explanations"
|
||||
title: "QQQ Middleware API extension: assistant"
|
||||
version: "1.5.0"
|
||||
version: "1.6.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/assistant/config:
|
||||
@@ -833,7 +844,7 @@ paths:
|
||||
tags:
|
||||
- "assistant"
|
||||
patch:
|
||||
description: "Rename, pin or archive a conversation"
|
||||
description: "Rename, pin, archive or mark read a conversation"
|
||||
operationId: "patchAssistantConversationsById"
|
||||
parameters:
|
||||
- description: "The conversation id."
|
||||
@@ -855,6 +866,10 @@ paths:
|
||||
status:
|
||||
description: "ACTIVE or ARCHIVED."
|
||||
type: "string"
|
||||
read:
|
||||
description: "Since 1.6.0: true sets unreadCount to 0 (the user\
|
||||
\ watched the reply stream in). Only true is accepted."
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
required: true
|
||||
responses:
|
||||
@@ -916,7 +931,7 @@ paths:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Rename, pin or archive a conversation"
|
||||
summary: "Rename, pin, archive or mark read a conversation"
|
||||
tags:
|
||||
- "assistant"
|
||||
/qqq/v1/assistant/conversations/{id}/turns:
|
||||
|
||||
+14
-2
@@ -19,10 +19,12 @@ package com.kingsrook.qbits.assistant;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAccessPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
@@ -78,7 +80,8 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
RecordSecurityLock lock = table.getRecordSecurityLocks().get(0);
|
||||
assertThat(lock.getFieldName()).isEqualTo("userId");
|
||||
assertThat(lock.getSecurityKeyType()).isEqualTo(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE);
|
||||
assertThat(table.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantAdminPermissionChecker.class.getName());
|
||||
assertThat(table.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantConversation.TABLE_NAME.equals(name)
|
||||
? AssistantAccessPermissionChecker.class.getName() : AssistantAdminPermissionChecker.class.getName());
|
||||
}
|
||||
assertThat(qInstance.getTable(AssistantUsageDaily.TABLE_NAME).getRecordSecurityLocks().get(0).getNullValueBehavior()).isEqualTo(RecordSecurityLock.NullValueBehavior.ALLOW);
|
||||
assertThat(qInstance.getTable(AssistantPolicy.TABLE_NAME).getRecordSecurityLocks()).isNullOrEmpty();
|
||||
@@ -163,7 +166,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
new QInstanceEnricher(host).enrich();
|
||||
|
||||
List<MetaDataWithPermissionRules> adminObjects = List.of(host.getApp(AssistantMetaDataBuilder.APP_NAME),
|
||||
host.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME), host.getTable(AssistantPolicy.TABLE_NAME), host.getTable(AssistantConversation.TABLE_NAME));
|
||||
host.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME), host.getTable(AssistantPolicy.TABLE_NAME), host.getTable(AssistantMessage.TABLE_NAME));
|
||||
|
||||
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
for(MetaDataWithPermissionRules object : adminObjects)
|
||||
@@ -178,6 +181,15 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), object)).as(object.getName()).isEqualTo(PermissionCheckResult.ALLOW);
|
||||
}
|
||||
QContext.clear();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the conversation table is for every assistant user (own rows only) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), host.getTable(AssistantConversation.TABLE_NAME))).isEqualTo(PermissionCheckResult.ALLOW);
|
||||
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.NO_ACCESS));
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), host.getTable(AssistantConversation.TABLE_NAME))).isEqualTo(PermissionCheckResult.DENY_HIDE);
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.api;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAccessPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.processes.ConversationActionsProcess;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Conversation management: the conversation table open to assistant users
|
||||
** (owner-locked), its archive / restore / delete record actions, the table
|
||||
** delete that leaves no child rows behind, and the UI API additions
|
||||
** (running, read, conversationsTable).
|
||||
*******************************************************************************/
|
||||
class ConversationManagementTest extends BaseAssistantTest
|
||||
{
|
||||
private static final List<String> CHILD_TABLES = List.of(AssistantMessage.TABLE_NAME, AssistantTurn.TABLE_NAME, AssistantToolCall.TABLE_NAME,
|
||||
AssistantFeedback.TABLE_NAME, AssistantPendingAction.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clearContext()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assistant users may use the conversation table (and its actions); the
|
||||
** admin app, its nav and the other tables stay admin-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableOpenToAssistantUsersAppStaysAdminOnly()
|
||||
{
|
||||
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getPermissionRules().getCustomPermissionChecker().getName())
|
||||
.isEqualTo(AssistantAccessPermissionChecker.class.getName());
|
||||
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getRecordSecurityLocks().get(0).getFieldName()).isEqualTo("userId");
|
||||
assertThat(qInstance.getTable(AssistantMessage.TABLE_NAME).getPermissionRules().getCustomPermissionChecker().getName())
|
||||
.isEqualTo(AssistantAdminPermissionChecker.class.getName());
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getPermissionRules().getCustomPermissionChecker().getName())
|
||||
.isEqualTo(AssistantAdminPermissionChecker.class.getName());
|
||||
for(String name : List.of(ConversationActionsProcess.ARCHIVE, ConversationActionsProcess.RESTORE, ConversationActionsProcess.DELETE))
|
||||
{
|
||||
QProcessMetaData process = qInstance.getProcess(name);
|
||||
assertThat(process.getTableName()).isEqualTo(AssistantConversation.TABLE_NAME);
|
||||
assertThat(process.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantAccessPermissionChecker.class.getName());
|
||||
assertThat(process.getPermissionRules().getDeniedClients()).contains("assistant");
|
||||
}
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
|
||||
.doesNotContain(ConversationActionsProcess.ARCHIVE, ConversationActionsProcess.RESTORE, ConversationActionsProcess.DELETE);
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
|
||||
for(TablePermissionSubType type : List.of(TablePermissionSubType.READ, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, type)).as(type.name()).isTrue();
|
||||
}
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantMessage.TABLE_NAME), AssistantMessage.TABLE_NAME, TablePermissionSubType.READ)).isFalse();
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), qInstance.getApp(AssistantMetaDataBuilder.APP_NAME))).isEqualTo(PermissionCheckResult.DENY_HIDE);
|
||||
assertThat(PermissionsHelper.hasProcessPermission(new MetaDataInput(), ConversationActionsProcess.DELETE)).isTrue();
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(NO_ACCESS, config));
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, TablePermissionSubType.READ)).isFalse();
|
||||
assertThat(PermissionsHelper.hasProcessPermission(new MetaDataInput(), ConversationActionsProcess.DELETE)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User A cannot see, update or delete user B's conversation through the
|
||||
** table actions; their own rows they can.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ownerLockOnTableActions() throws Exception
|
||||
{
|
||||
Integer mine = conversation(EDITOR, "Mine");
|
||||
Integer theirs = conversation(OTHER_EDITOR, "Theirs");
|
||||
|
||||
asUser(EDITOR);
|
||||
List<Serializable> visible = new QueryAction().execute(new QueryInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)).getRecords()
|
||||
.stream().map(r -> r.getValue("id")).toList();
|
||||
assertThat(visible).contains(mine).doesNotContain(theirs);
|
||||
|
||||
UpdateOutput update = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", theirs).withValue("title", "Hijacked")));
|
||||
assertThat(update.getRecords()).allSatisfy(r -> assertThat(r.getErrors()).isNotEmpty());
|
||||
|
||||
assertThatDeleteFailsOrDoesNothing(theirs);
|
||||
|
||||
asUser(ADMIN);
|
||||
AssistantConversation stillThere = store().getConversation(OTHER_EDITOR, theirs);
|
||||
assertThat(stillThere).isNotNull();
|
||||
assertThat(stillThere.getTitle()).isEqualTo("Theirs");
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// own row: title / pinned / status change; the rest is ours //
|
||||
////////////////////////////////////////////////////////////////
|
||||
asUser(EDITOR);
|
||||
UpdateOutput giveAway = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", mine).withValue("userId", OTHER_EDITOR)));
|
||||
assertThat(giveAway.getRecords().get(0).getErrors()).as("the owner lock refuses moving a row to another user").isNotEmpty();
|
||||
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", mine).withValue("title", "Renamed").withValue("status", "ARCHIVED")
|
||||
.withValue("unreadCount", 99).withValue("maxClassification", "NONE")));
|
||||
AssistantConversation updated = store().getConversation(EDITOR, mine);
|
||||
assertThat(updated.getTitle()).isEqualTo("Renamed");
|
||||
assertThat(updated.getStatus()).isEqualTo("ARCHIVED");
|
||||
assertThat(updated.getUnreadCount()).isNotEqualTo(99);
|
||||
assertThat(updated.getUserId()).isEqualTo(EDITOR);
|
||||
|
||||
UpdateOutput bad = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", mine).withValue("status", "DELETED")));
|
||||
assertThat(bad.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
assertThat(store().getConversation(EDITOR, mine).getStatus()).isEqualTo("ARCHIVED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table delete removes the conversation's child rows too; a conversation
|
||||
** with an answer in progress is refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableDeleteRemovesChildrenAndRefusesRunning() throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
new DeleteAction().execute(new DeleteInput(AssistantTurnEvent.TABLE_NAME).withQueryFilter(new QQueryFilter()));
|
||||
Integer mine = conversation(EDITOR, "With children");
|
||||
children(EDITOR, mine);
|
||||
Integer other = conversation(EDITOR, "Sibling");
|
||||
children(EDITOR, other);
|
||||
|
||||
asUser(EDITOR);
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(mine)));
|
||||
assertThat(output.getRecordsWithErrors()).isNullOrEmpty();
|
||||
assertThat(store().getConversation(EDITOR, mine)).isNull();
|
||||
for(String table : CHILD_TABLES)
|
||||
{
|
||||
assertThat(rows(table, mine)).as(table).isEmpty();
|
||||
assertThat(rows(table, other)).as(table + " of another conversation").isNotEmpty();
|
||||
}
|
||||
asUser(ADMIN);
|
||||
assertThat(new QueryAction().execute(new QueryInput(AssistantTurnEvent.TABLE_NAME)).getRecords()).as("only the sibling's turn event is left").hasSize(1);
|
||||
|
||||
Integer busy = conversation(EDITOR, "Busy");
|
||||
runningTurn(EDITOR, busy, Instant.now());
|
||||
asUser(EDITOR);
|
||||
DeleteOutput refused = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(busy)));
|
||||
assertThat(refused.getRecordsWithErrors()).isNotEmpty();
|
||||
assertThat(store().getConversation(EDITOR, busy)).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Archive, restore and delete as standard record actions, only on rows the
|
||||
** user can see.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bulkRecordActions() throws Exception
|
||||
{
|
||||
Integer one = conversation(EDITOR, "One");
|
||||
Integer two = conversation(EDITOR, "Two");
|
||||
Integer busy = conversation(EDITOR, "Busy");
|
||||
Integer theirs = conversation(OTHER_EDITOR, "Theirs");
|
||||
children(EDITOR, one);
|
||||
runningTurn(EDITOR, busy, Instant.now());
|
||||
|
||||
asUser(EDITOR);
|
||||
runProcess(ConversationActionsProcess.ARCHIVE, List.of(one, two, theirs));
|
||||
assertThat(store().getConversation(EDITOR, one).getStatus()).isEqualTo("ARCHIVED");
|
||||
assertThat(store().getConversation(EDITOR, two).getStatus()).isEqualTo("ARCHIVED");
|
||||
|
||||
runProcess(ConversationActionsProcess.RESTORE, List.of(two, theirs));
|
||||
assertThat(store().getConversation(EDITOR, two).getStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(store().getConversation(EDITOR, one).getStatus()).isEqualTo("ARCHIVED");
|
||||
|
||||
runProcess(ConversationActionsProcess.DELETE, List.of(one, busy, theirs));
|
||||
assertThat(store().getConversation(EDITOR, one)).isNull();
|
||||
assertThat(rows(AssistantMessage.TABLE_NAME, one)).isEmpty();
|
||||
assertThat(store().getConversation(EDITOR, busy)).as("an answer in progress is not deleted").isNotNull();
|
||||
|
||||
asUser(ADMIN);
|
||||
AssistantConversation other = store().getConversation(OTHER_EDITOR, theirs);
|
||||
assertThat(other).isNotNull();
|
||||
assertThat(other.getStatus()).isEqualTo("ACTIVE");
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// review and result summaries use the standard lines //
|
||||
////////////////////////////////////////////////////////
|
||||
assertThat(ConversationActionsProcess.processes()).extracting(QProcessMetaData::getLabel)
|
||||
.containsExactly("Archive conversations", "Restore conversations", "Delete conversations");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /conversations items carry running; an orphaned run is not running.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void listShowsRunning() throws Exception
|
||||
{
|
||||
Integer live = conversation(EDITOR, "Live");
|
||||
Integer stale = conversation(EDITOR, "Stale");
|
||||
Integer idle = conversation(EDITOR, "Idle");
|
||||
runningTurn(EDITOR, live, Instant.now());
|
||||
runningTurn(EDITOR, stale, Instant.now().minusSeconds(3600));
|
||||
|
||||
AssistantApi api = asUser(EDITOR);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> list = (List<Map<String, Object>>) api.listConversations(null).get("conversations");
|
||||
Map<Object, Object> running = list.stream().collect(Collectors.toMap(c -> c.get("id"), c -> c.get("running")));
|
||||
assertThat(running).containsEntry(live, true).containsEntry(stale, false).containsEntry(idle, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PATCH {"read": true} clears the unread count; anything else is a 400.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void patchRead() throws Exception
|
||||
{
|
||||
Integer id = conversation(EDITOR, "Unread");
|
||||
asUser(EDITOR);
|
||||
store().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("unreadCount", 3));
|
||||
|
||||
AssistantApi api = asUser(EDITOR);
|
||||
assertThat(api.patchConversation(id, Map.of("read", true))).containsEntry("unreadCount", 0);
|
||||
assertThat(store().getConversation(EDITOR, id).getUnreadCount()).isZero();
|
||||
assertThatThrownBy(() -> api.patchConversation(id, Map.of("read", false)))
|
||||
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(400));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /config names the conversation table only when the session may
|
||||
** query it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configNamesTheTable() throws Exception
|
||||
{
|
||||
assertThat(asUser(EDITOR).config()).containsEntry("conversationsTable", AssistantConversation.TABLE_NAME);
|
||||
assertThat(asUser(ADMIN).config()).containsEntry("conversationsTable", AssistantConversation.TABLE_NAME);
|
||||
assertThat(asUser(NO_ACCESS).config()).doesNotContainKey("conversationsTable");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A delete of someone else's row either errors or deletes nothing.
|
||||
*******************************************************************************/
|
||||
private void assertThatDeleteFailsOrDoesNothing(Integer id)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(id)));
|
||||
assertThat(output.getDeletedRecordCount()).isZero();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
//////////////////////////////////
|
||||
// refused outright: also fine //
|
||||
//////////////////////////////////
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a record action as the current user over the given ids.
|
||||
*******************************************************************************/
|
||||
private void runProcess(String process, List<Integer> ids) throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(process);
|
||||
input.setCallback(QProcessCallbackFactory.forPrimaryKeys("id", ids));
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
new RunProcessAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Switch the context to a user's own session; returns an API bound to it.
|
||||
*******************************************************************************/
|
||||
private AssistantApi asUser(String userId)
|
||||
{
|
||||
QSession session = AssistantTestFixture.ownerSession(userId, config);
|
||||
QContext.init(qInstance, session);
|
||||
return (new AssistantApi(config.getRuntime(), session));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantStore store()
|
||||
{
|
||||
return (config.getRuntime().getStore());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation owned by a user (created as an admin).
|
||||
*******************************************************************************/
|
||||
private Integer conversation(String owner, String title) throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
return (store().createConversation(owner, title, null).getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One of each child row under a conversation.
|
||||
*******************************************************************************/
|
||||
private void children(String owner, Integer conversationId) throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
AssistantMessage message = store().insert(new AssistantMessage().withConversationId(conversationId).withUserId(owner).withRole("USER").withStatus("COMPLETE").withSeq(1).withPartsJson("[]"));
|
||||
AssistantTurn turn = store().insert(new AssistantTurn().withConversationId(conversationId).withUserId(owner).withStatus("COMPLETE").withUserMessageId(message.getId()));
|
||||
store().insert(new AssistantTurnEvent().withTurnId(turn.getId()).withUserId(owner).withSeq(1).withEventType("message.end"));
|
||||
store().insert(new AssistantToolCall().withConversationId(conversationId).withTurnId(turn.getId()).withUserId(owner).withToolName("query"));
|
||||
store().insert(new AssistantFeedback().withConversationId(conversationId).withMessageId(message.getId()).withUserId(owner).withRating(1));
|
||||
store().insert(new AssistantPendingAction().withConversationId(conversationId).withTurnId(turn.getId()).withUserId(owner).withStatus(AssistantPendingAction.STATUS_PENDING));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A RUNNING turn on another (possibly dead) pod with the given heartbeat.
|
||||
*******************************************************************************/
|
||||
private void runningTurn(String owner, Integer conversationId, Instant heartbeat) throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
store().insert(new AssistantTurn().withConversationId(conversationId).withUserId(owner).withStatus("RUNNING")
|
||||
.withStartedAt(heartbeat).withHeartbeatAt(heartbeat).withLeaseOwner("another-pod").withPhase("thinking"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows of a child table for one conversation (any owner).
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table, Integer conversationId) throws QException
|
||||
{
|
||||
QSession previous = QContext.getQSession();
|
||||
asUser(ADMIN);
|
||||
try
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter().withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId))).getRecords());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(qInstance, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -50,6 +50,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
@@ -166,6 +168,52 @@ class HipaaStorageTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User titles are plaintext unless they match this record's stored ciphertext.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableTitleUpdatesEncryptPrefixLookingPlaintext() throws Exception
|
||||
{
|
||||
AssistantStore store = setupProvider();
|
||||
String owner = AssistantTestFixture.ADMIN;
|
||||
AssistantConversation conversation = store.createConversation(owner, "Original title", null);
|
||||
Integer id = conversation.getId();
|
||||
for(String title : List.of("Renamed title", "enc:v1:literal private user text"))
|
||||
{
|
||||
var updated = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", id).withValue("title", title)));
|
||||
assertThat(updated.getRecords()).hasSize(1).allSatisfy(record -> assertThat(record.getErrors()).isEmpty());
|
||||
assertThat(rawTitle(id)).startsWith("enc:v1:").isNotEqualTo(title).doesNotContain("private user text");
|
||||
assertThat(store.getConversation(owner, id).getTitle()).isEqualTo(title);
|
||||
}
|
||||
|
||||
String storedTitle = rawTitle(id);
|
||||
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", id).withValue("title", storedTitle).withValue("pinned", true)));
|
||||
assertThat(rawTitle(id)).isEqualTo(storedTitle);
|
||||
assertThat(store.getConversation(owner, id).getTitle()).isEqualTo("enc:v1:literal private user text");
|
||||
assertThat(store.getConversation(owner, id).getPinned()).isTrue();
|
||||
|
||||
AssistantConversation second = store.createConversation(owner, "Another title", null);
|
||||
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", second.getId()).withValue("title", storedTitle)));
|
||||
assertThat(rawTitle(second.getId())).isNotEqualTo(storedTitle);
|
||||
assertThat(store.getConversation(owner, second.getId()).getTitle()).isEqualTo(storedTitle);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect stored bytes without the assistant store's decryption boundary.
|
||||
*******************************************************************************/
|
||||
private String rawTitle(Integer id) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(AssistantConversation.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id))).getRecords().get(0).getValueString("title"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication failure never returns ciphertext or unauthenticated plaintext; legacy values still load.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user