From b5cd74789963d2b936b14d458bed57a6bff44ed0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 15:46:06 -0500 Subject: [PATCH 1/2] feat(assistant): users manage their own conversations in the table UI assistant_conversation is open to every assistant user (assistant.hasAccess or assistant.admin) through AssistantAccessPermissionChecker; rows stay locked to their owner, and the Assistant app and other tables stay admin-only. A conversation customizer limits user edits to title, pinned and status, refuses deleting a conversation with an answer in progress and deletes the child rows after a table delete, through the same store code as DELETE /conversations/{id}. Adds Archive, Restore and Delete conversations as standard streamed-ETL record actions on the table, acting only on rows the user can see. UI API extension assistant 1.6.0: conversation list items have running, PATCH /conversations/{id} accepts {"read": true}, and GET /config has conversationsTable when the session may query that table. Co-Authored-By: Claude Opus 5.5 --- .../assistant/AssistantQBitProducer.java | 6 + .../qbits/assistant/api/AssistantApi.java | 73 ++- .../api/AssistantConversationCustomizer.java | 157 +++++++ .../assistant/api/specs/AssistantSpecs.java | 14 +- .../AssistantAccessPermissionChecker.java | 51 +++ .../metadata/AssistantMetaDataBuilder.java | 28 +- .../assistant/persistence/AssistantStore.java | 36 +- .../persistence/ConversationEncryption.java | 10 + .../processes/ConversationActionsProcess.java | 302 ++++++++++++ .../main/resources/help/qbit-assistant.yaml | 21 +- .../resources/openapi/qqq-v1/assistant.yaml | 21 +- .../assistant/AssistantQBitProducerTest.java | 16 +- .../api/ConversationManagementTest.java | 429 ++++++++++++++++++ 13 files changed, 1148 insertions(+), 16 deletions(-) create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantConversationCustomizer.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantAccessPermissionChecker.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/ConversationActionsProcess.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/ConversationManagementTest.java diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java index da433c6..ef6c7b7 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java @@ -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 running = runningConversationIds(); List> list = new ArrayList<>(); for(AssistantConversation conversation : store.listConversations(userId, effective)) { - list.add(toJson(conversation)); + Map item = toJson(conversation); + item.put("running", running.contains(conversation.getId())); + list.add(item); } return (Map.of("conversations", list)); } @@ -304,6 +316,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)); @@ -658,6 +682,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 runningConversationIds() throws QException + { + Set 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. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantConversationCustomizer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantConversationCustomizer.java new file mode 100644 index 0000000..3c0a240 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantConversationCustomizer.java @@ -0,0 +1,157 @@ +/* + * 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 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 preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> 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 && !AssistantStore.isEncryptedValue(title)) + { + 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 preDelete(DeleteInput deleteInput, List 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 postDelete(DeleteInput deleteInput, List 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 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)); + } +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java index e2eea36..61d9368 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -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"; @@ -203,10 +203,13 @@ public final class AssistantSpecs "model", string("The model that answers."), "limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn."), "features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation."), - "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), @@ -816,7 +819,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"); } @@ -832,7 +835,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."))); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantAccessPermissionChecker.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantAccessPermissionChecker.java new file mode 100644 index 0000000..70f24c0 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantAccessPermissionChecker.java @@ -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.")); + } + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java index 7bb58bb..178535e 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantMetaDataBuilder.java @@ -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 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))); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java index ce5b9b0..08bc0e2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java @@ -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 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. *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/ConversationEncryption.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/ConversationEncryption.java index e40232c..abf83da 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/ConversationEncryption.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/ConversationEncryption.java @@ -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:. *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/ConversationActionsProcess.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/ConversationActionsProcess.java new file mode 100644 index 0000000..2b4356b --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/ConversationActionsProcess.java @@ -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 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 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 lines(String processName) + { + Map 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 summary(Map lines, String nothing) + { + ArrayList 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 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 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 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 getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (summary(lines == null ? Map.of() : lines, "No conversations were changed.")); + } + } +} diff --git a/qbit-assistant/src/main/resources/help/qbit-assistant.yaml b/qbit-assistant/src/main/resources/help/qbit-assistant.yaml index c99bb7d..ea0dad2 100644 --- a/qbit-assistant/src/main/resources/help/qbit-assistant.yaml +++ b/qbit-assistant/src/main/resources/help/qbit-assistant.yaml @@ -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: | diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml index c7ed880..14ecc17 100644 --- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -195,6 +195,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." @@ -237,6 +243,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." @@ -452,7 +463,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: @@ -828,7 +839,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." @@ -850,6 +861,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: @@ -911,7 +926,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: diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantQBitProducerTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantQBitProducerTest.java index e62a282..d998fe4 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantQBitProducerTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantQBitProducerTest.java @@ -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 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(); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/ConversationManagementTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/ConversationManagementTest.java new file mode 100644 index 0000000..edb09fb --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/ConversationManagementTest.java @@ -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 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 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> list = (List>) api.listConversations(null).get("conversations"); + Map 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 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 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); + } + } +} -- 2.54.0 From 589d8f5c1cf02f45d0ee23ba82fd14157671fbf3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 16 Oct 2026 15:46:06 -0500 Subject: [PATCH 2/2] docs: changelog and upgrade notes for conversation management Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++++ UPGRADE.md | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688af47..742dea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, as panel renames are. +- 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 - Optional provider health - An enabled provider that is not the default and not the only route for any configured routing (an enabled profile's simple/complex model, a budget downgrade model or a knowledge source's embedding model, with no fallback on another usable provider) is optional: when it is unreachable, its `assistant.provider.` and `assistant.model.` checks report DEGRADED with a message naming the provider requests use instead, so one unfinished local server no longer turns the whole `assistant` component DOWN. The default provider and sole routes still report DOWN. A failed provider probe still carries `reachable=false`, so the circuit breaker keeps refusing the provider. diff --git a/UPGRADE.md b/UPGRADE.md index b8d2206..581a3a5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -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. + ## Provider setup state (Unreleased) Apply the **Provider setup state** block at the end of `docs/schema-postgres.sql` -- 2.54.0