Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 76f9b286c6 feat(help): ship the qbit-assistant help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:16:55 -05:00
james.maes 9b4708fafc Merge pull request #44 from QRun-IO/feature/transcript-redaction
feat: no raw PII/PHI in stored transcripts (secured data §15, QQQ RC.7)
2026-10-14 19:03:20 -05:00
7 changed files with 1577 additions and 251 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased - In-app help
- Ship the host-neutral Assistant help bundle through QQQ's native registry, replacing the temporary loader.
- Cover Assistant tables, fields, actions, widgets and selectors; retain live metadata references and add a bundle coverage gate.
- Document HOST bundle overrides and remove deployment-specific provider, classification, retention and backup guidance.
## Unreleased - Transcript redaction
- Tokenize classified PII/PHI in stored messages, tool inputs/results, replay events, pending actions and derived summaries using QQQ secured-data tokens. Preserve eligible model requests and live SSE.
+15
View File
@@ -725,3 +725,18 @@ remain untouched, as do explicit object and permission-rule denials.
History visibility uses core's `isDenied` read/all-operation check. RC.6 has no
operation argument; the later write-only deny patterns must remain separate
from that check so they do not hide the read tool.
## In-app help
The QBit registers [`help/qbit-assistant.yaml`](qbit-assistant/src/main/resources/help/qbit-assistant.yaml)
with QQQ's help-bundle registry during production. Hosts with help content storage use QQQ's
startup bundle sync; registration is harmless when the host has no `helpContent` table.
To customize the wording, register a separate bundle with `layer: HOST` using
`HelpBundles.register(qInstance, "help/my-application.yaml")`. Entries with the same key and
role override this QBIT bundle. User-edited help remains protected by core's sync.
Keep live `{{...}}` references so labels and metadata values follow the host's configuration.
`AssistantTablesGuardrailTest.everyOwnedTargetHasBundleHelp` checks coverage and live references
against the produced test instance with `HelpBundleAssertions`; it runs in the normal Maven build.
Update the bundle's literal version alongside the artifact version when releasing.
+2 -2
View File
@@ -202,7 +202,7 @@
</build>
<profiles>
<!-- The assistant needs QQQ 4.1.0-RC.6: the AI tool kernel (qqq#1078), the -->
<!-- The assistant needs QQQ 4.1.0-RC.7: the AI tool kernel (qqq#1078), the -->
<!-- developer panels (qqq#1076) and the UI API extension point, invoker -->
<!-- and credentials (qqq#1128). Until -->
<!-- qbit-build-parent imports the 4.1 BOM, this -->
@@ -215,7 +215,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<!-- RC.7 carries qqq#1182 classified-value tokens and log redaction. Keep the PR draft until published. -->
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles. -->
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
@@ -26,7 +26,6 @@ import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHome;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
@@ -43,6 +42,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
@@ -204,7 +204,8 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QAppMetaData app = builder.buildAdminApp(tables);
qInstance.addApp(app);
AssistantHelp.apply(qInstance);
// Registration is metadata-only; core startup sync guards hosts without helpContent.
HelpBundles.register(qInstance, "help/qbit-assistant.yaml");
AssistantChannelWall.register(qInstance);
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
@@ -1,126 +0,0 @@
/*
* 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 java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-assistant.yaml (the QQQ help-bundle format) to the
** Assistant app and its assistant_* tables: what each table is for and how
** its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=#1198): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class AssistantHelp
{
public static final String RESOURCE = "/help/qbit-assistant.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AssistantHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-assistant".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every assistant_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : qInstance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = qInstance.getApp(AssistantMetaDataBuilder.APP_NAME);
JsonNode appEntry = entries.get("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
File diff suppressed because it is too large. Load diff
@@ -26,11 +26,13 @@ import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -121,21 +123,45 @@ class AssistantTablesGuardrailTest extends BaseAssistantTest
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
** Every owned target has valid bundle help, even on a host without help storage.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
void everyOwnedTargetHasBundleHelp()
{
assertThat(AssistantHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
assertThat(qInstance.getTable("helpContent")).isNull();
var bundles = HelpBundles.of(qInstance).getBundles();
assertThat(bundles).hasSize(1);
assertThat(bundles.get(0).getId()).isEqualTo("qbit-assistant");
HelpBundleAssertions.forInstance(qInstance)
.bundles("help/qbit-assistant.yaml")
// Core-generated bulk actions belong to core's bundle, not this QBit.
.owning(target -> target.owner() instanceof QProcessMetaData process
? qInstance.getTable("assistant_conversation").getSourceQBitName().equals(process.getSourceQBitName())
: target.name() != null && target.name().startsWith("assistant"))
.assertComplete();
// RC.7's gate requires tables, fields, apps and processes; also cover our selectors and widgets.
for(var target : HelpTargetCatalog.collect(qInstance))
{
if(List.of("widget", "possibleValueSource").contains(target.kind()) && target.name().startsWith("assistant"))
{
assertThat(bundles.get(0).getEntries()).as(target.key())
.anyMatch(entry -> entry.getKey().equals(target.key()) && entry.getSummary() != null && !entry.getSummary().isBlank());
}
}
for(String table : assistantTables())
{
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(AssistantHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
assertThat(bundles.get(0).getEntries())
.anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:" + table + ";slot:help");
assertThat(entry.getContent()).isNotBlank();
});
}
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getHelpContent()).containsKey(AssistantHelp.SLOT);
assertThat(AssistantHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:help");
assertThat(entry.getContent()).isNotBlank();
});
}