feat(admin): Assistant Admin record screens that fit each table #6
No files matched your search
@@ -22,6 +22,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
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;
|
||||
@@ -150,6 +151,8 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
tables.add(customized);
|
||||
}
|
||||
qInstance.addPossibleValueSource(builder.buildConversationPossibleValueSource());
|
||||
AssistantRecordScreens.buildPossibleValueSources().forEach(qInstance::addPossibleValueSource);
|
||||
AssistantRecordScreens.addConversationChildLists(qInstance);
|
||||
|
||||
QProcessMetaData retention = builder.buildRetentionProcess();
|
||||
retention.setSourceQBitName(qBitMetaData.getName());
|
||||
|
||||
+1
@@ -154,6 +154,7 @@ public class AssistantMetaDataBuilder
|
||||
.withUniqueKey(new UniqueKey("scope", "userId", "usageDate"))
|
||||
.withRecordLabelFormat("%s %s").withRecordLabelFields("scope", "usageDate"));
|
||||
|
||||
tables.forEach(AssistantRecordScreens::apply);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
* 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.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
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.AssistantPolicy;
|
||||
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.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PVSValueFormatAndFields;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record screens (create, edit, view) for the Assistant Admin tables.
|
||||
**
|
||||
** - Named sections instead of QQQ's generic "Identity / Other Fields", with
|
||||
** any field not placed in one kept in a final "Details" section, so a new
|
||||
** column always shows.
|
||||
** - JSON columns use the JSON code viewer; cost columns are micro-dollars
|
||||
** and say so; counts use thousands separators.
|
||||
** - Message, turn and tool-call ids link to their records; a conversation
|
||||
** lists its messages, turns, tool calls and feedback.
|
||||
** - Policy: the effort is picked from the supported levels, every limit has
|
||||
** help text, and the conversation status (the assistant's own) is
|
||||
** read-only.
|
||||
*******************************************************************************/
|
||||
public final class AssistantRecordScreens
|
||||
{
|
||||
public static final String EFFORT_PVS_NAME = "assistant_effort";
|
||||
public static final String DETAILS_SECTION = "details";
|
||||
static final String JSON = "json";
|
||||
static final String MICROS_HELP = "In micro-dollars: 1,000,000 is $1.00.";
|
||||
static final String CHILD_LIST_PREFIX = "conversation";
|
||||
|
||||
private static final List<String> DATES = List.of("createDate", "modifyDate");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private AssistantRecordScreens()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible-value sources the screens use: the effort levels, and the turn
|
||||
** and message tables (for their id fields).
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> buildPossibleValueSources()
|
||||
{
|
||||
List<QPossibleValue<?>> efforts = new ArrayList<>();
|
||||
for(String effort : List.of("low", "medium", "high", "xhigh", "max"))
|
||||
{
|
||||
if(AssistantModelRouting.EFFORTS.contains(effort))
|
||||
{
|
||||
efforts.add(new QPossibleValue<>(effort, effort.substring(0, 1).toUpperCase() + effort.substring(1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (List.of(
|
||||
new QPossibleValueSource().withName(EFFORT_PVS_NAME).withType(QPossibleValueSourceType.ENUM)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(efforts),
|
||||
QPossibleValueSource.newForTable(AssistantTurn.TABLE_NAME),
|
||||
QPossibleValueSource.newForTable(AssistantMessage.TABLE_NAME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sections, viewers, links and help for one table (by its name).
|
||||
*******************************************************************************/
|
||||
public static void apply(QTableMetaData table)
|
||||
{
|
||||
switch(table.getName())
|
||||
{
|
||||
case AssistantConversation.TABLE_NAME -> conversation(table);
|
||||
case AssistantMessage.TABLE_NAME -> message(table);
|
||||
case AssistantTurn.TABLE_NAME -> turn(table);
|
||||
case AssistantTurnEvent.TABLE_NAME -> turnEvent(table);
|
||||
case AssistantToolCall.TABLE_NAME -> toolCall(table);
|
||||
case AssistantFeedback.TABLE_NAME -> feedback(table);
|
||||
case AssistantPolicy.TABLE_NAME -> policy(table);
|
||||
case AssistantUsageDaily.TABLE_NAME -> usageDaily(table);
|
||||
default ->
|
||||
{
|
||||
// not an assistant table
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation's messages, turns, tool calls and feedback, listed on it
|
||||
** (joins on conversationId, read-only child lists).
|
||||
*******************************************************************************/
|
||||
public static void addConversationChildLists(QInstance qInstance)
|
||||
{
|
||||
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
|
||||
if(conversation == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
childList(qInstance, conversation, AssistantMessage.TABLE_NAME, "Messages", "chat", List.of("seq", "role", "status", "model", "createDate"));
|
||||
childList(qInstance, conversation, AssistantTurn.TABLE_NAME, "Turns", "sync_alt", List.of("status", "model", "toolCallCount", "costMicros", "startedAt"));
|
||||
childList(qInstance, conversation, AssistantToolCall.TABLE_NAME, "Tool Calls", "build", List.of("toolName", "outcome", "rowCount", "durationMs", "createDate"));
|
||||
childList(qInstance, conversation, AssistantFeedback.TABLE_NAME, "Feedback", "thumbs_up_down", List.of("rating", "reason", "createDate"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void conversation(QTableMetaData table)
|
||||
{
|
||||
readOnly(table, "status");
|
||||
json(table, "contextJson");
|
||||
micros(table, "totalCostMicros");
|
||||
counts(table, "totalTokensIn", "totalTokensOut", "unreadCount");
|
||||
sections(table,
|
||||
section("conversation", "Conversation", "forum", Tier.T1, "id", "title", "userId", "status", "pinned", "lastMessageAt", "unreadCount"),
|
||||
section("usage", "Usage", "query_stats", Tier.T2, "totalTokensIn", "totalTokensOut", "totalCostMicros"),
|
||||
section("context", "Context", "data_object", Tier.T3, "contextJson"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void message(QTableMetaData table)
|
||||
{
|
||||
json(table, "partsJson");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
link(table, "parentMessageId", AssistantMessage.TABLE_NAME, "Parent Message");
|
||||
table.getField("partsJson").withLabel("Parts");
|
||||
sections(table,
|
||||
section("message", "Message", "chat", Tier.T1, "id", "conversationId", "role", "status", "seq", "model", "turnId", "parentMessageId", "userId"),
|
||||
section("parts", "Parts", "data_object", Tier.T2, "partsJson"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void turn(QTableMetaData table)
|
||||
{
|
||||
link(table, "userMessageId", AssistantMessage.TABLE_NAME, "User Message");
|
||||
link(table, "assistantMessageId", AssistantMessage.TABLE_NAME, "Assistant Message");
|
||||
micros(table, "costMicros");
|
||||
counts(table, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "toolCallCount", "modelCallCount");
|
||||
sections(table,
|
||||
section("turn", "Turn", "sync_alt", Tier.T1, "id", "conversationId", "status", "model", "startedAt", "endedAt", "userMessageId", "assistantMessageId", "userId"),
|
||||
section("outcome", "Outcome", "flag", Tier.T2, "stopReason", "cancelReason", "errorCode"),
|
||||
section("usage", "Usage", "query_stats", Tier.T2, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costMicros", "toolCallCount", "modelCallCount"),
|
||||
section("provider", "Provider requests", "receipt_long", Tier.T3, "providerRequestIds"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void turnEvent(QTableMetaData table)
|
||||
{
|
||||
json(table, "dataJson");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
table.getField("dataJson").withLabel("Data");
|
||||
sections(table,
|
||||
section("event", "Event", "stream", Tier.T1, "id", "turnId", "seq", "eventType", "userId"),
|
||||
section("data", "Data", "data_object", Tier.T2, "dataJson"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void toolCall(QTableMetaData table)
|
||||
{
|
||||
json(table, "inputJson");
|
||||
json(table, "resultJson");
|
||||
json(table, "redactionsJson");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
counts(table, "rowCount", "durationMs");
|
||||
table.getField("inputJson").withLabel("Input");
|
||||
table.getField("resultJson").withLabel("Result");
|
||||
table.getField("redactionsJson").withLabel("Redactions");
|
||||
sections(table,
|
||||
section("call", "Tool Call", "build", Tier.T1, "id", "toolName", "outcome", "conversationId", "turnId", "toolCallId", "userId"),
|
||||
section("target", "Target and permission", "policy", Tier.T2, "tableName", "processName", "widgetName", "permissionCheck"),
|
||||
section("result", "Result", "fact_check", Tier.T2, "resultSummary", "rowCount", "durationMs"),
|
||||
section("payloads", "Payloads", "data_object", Tier.T3, "inputJson", "resultJson", "redactionsJson"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void feedback(QTableMetaData table)
|
||||
{
|
||||
link(table, "messageId", AssistantMessage.TABLE_NAME, "Message");
|
||||
sections(table,
|
||||
section("feedback", "Feedback", "thumbs_up_down", Tier.T1, "id", "rating", "reason", "comment"),
|
||||
section("about", "About", "chat", Tier.T2, "conversationId", "messageId", "userId"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one table people edit: limits are in tokens or micro-dollars, soft
|
||||
** limits warn and hard limits stop.
|
||||
*******************************************************************************/
|
||||
private static void policy(QTableMetaData table)
|
||||
{
|
||||
QFieldMetaData effort = table.getFields().get("mainEffort");
|
||||
if(effort != null)
|
||||
{
|
||||
effort.withPossibleValueSourceName(EFFORT_PVS_NAME).withLabel("Effort")
|
||||
.withHelpContent(new QHelpContent("How hard the main model thinks before answering; higher costs more and is slower. Empty uses the configured effort."));
|
||||
}
|
||||
help(table, "name", "A name for this policy. The assistant uses one policy, the one with the lowest id; other rows have no effect.");
|
||||
help(table, "mainModel", "The model id answers come from; empty uses the model configured for the application.");
|
||||
help(table, "killSwitch", "On: the assistant takes no new requests from anyone.");
|
||||
help(table, "killSwitchReason", "Shown to users while the kill switch is on.");
|
||||
for(String scope : List.of("userDaily", "instanceMonthly"))
|
||||
{
|
||||
String who = scope.equals("userDaily") ? "one user in a day" : "the whole instance in a month";
|
||||
help(table, scope + "TokensSoft", "Tokens " + who + " may use before a warning. Empty uses the configured limit.");
|
||||
help(table, scope + "TokensHard", "Tokens " + who + " may use before requests are refused. Empty uses the configured limit.");
|
||||
help(table, scope + "CostMicrosSoft", "Cost " + who + " may reach before a warning. Empty uses the configured limit. " + MICROS_HELP);
|
||||
help(table, scope + "CostMicrosHard", "Cost " + who + " may reach before requests are refused. Empty uses the configured limit. " + MICROS_HELP);
|
||||
}
|
||||
counts(table, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard",
|
||||
"instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard");
|
||||
sections(table,
|
||||
section("policy", "Policy", "policy", Tier.T1, "id", "name", "killSwitch", "killSwitchReason"),
|
||||
section("model", "Model", "smart_toy", Tier.T2, "mainModel", "mainEffort"),
|
||||
section("userDaily", "Per user, per day", "person", Tier.T2, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard"),
|
||||
section("instanceMonthly", "Whole instance, per month", "domain", Tier.T2, "instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void usageDaily(QTableMetaData table)
|
||||
{
|
||||
micros(table, "costMicros");
|
||||
counts(table, "turnCount", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens");
|
||||
help(table, "scope", "USER rows are one user's day; the INSTANCE row (no user) is everyone's.");
|
||||
sections(table,
|
||||
section("usage", "Usage", "query_stats", Tier.T1, "id", "usageDate", "scope", "userId"),
|
||||
section("totals", "Totals", "functions", Tier.T2, "turnCount", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costMicros"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set a table's sections, then the dates, then a Details section with any
|
||||
** field not placed (absent fields are skipped).
|
||||
*******************************************************************************/
|
||||
static void sections(QTableMetaData table, QFieldSection... sections)
|
||||
{
|
||||
Set<String> placed = new LinkedHashSet<>();
|
||||
List<QFieldSection> result = new ArrayList<>();
|
||||
for(QFieldSection section : sections)
|
||||
{
|
||||
List<String> names = new ArrayList<>(section.getFieldNames());
|
||||
names.removeIf(name -> !table.getFields().containsKey(name) || placed.contains(name));
|
||||
placed.addAll(names);
|
||||
if(!names.isEmpty())
|
||||
{
|
||||
section.setFieldNames(names);
|
||||
result.add(section);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> dates = new ArrayList<>(DATES);
|
||||
dates.removeIf(name -> !table.getFields().containsKey(name) || placed.contains(name));
|
||||
placed.addAll(dates);
|
||||
|
||||
List<String> rest = new ArrayList<>(table.getFields().keySet());
|
||||
rest.removeIf(placed::contains);
|
||||
if(!rest.isEmpty())
|
||||
{
|
||||
result.add(section(DETAILS_SECTION, "Details", "list", Tier.T2, rest.toArray(new String[0])));
|
||||
}
|
||||
if(!dates.isEmpty())
|
||||
{
|
||||
result.add(section("dates", "Dates", "calendar_month", Tier.T3, dates.toArray(new String[0])));
|
||||
}
|
||||
table.setSections(result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static QFieldSection section(String name, String label, String icon, Tier tier, String... fieldNames)
|
||||
{
|
||||
return (new QFieldSection(name, label, new QIcon(icon), tier, new ArrayList<>(List.of(fieldNames))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A JSON column: TEXT with the JSON code viewer.
|
||||
*******************************************************************************/
|
||||
static void json(QTableMetaData table, String fieldName)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null)
|
||||
{
|
||||
field.setType(QFieldType.TEXT);
|
||||
field.withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode(JSON)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An id column shown as a link to its record.
|
||||
*******************************************************************************/
|
||||
static void link(QTableMetaData table, String fieldName, String tableName, String label)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null)
|
||||
{
|
||||
field.withPossibleValueSourceName(tableName).withLabel(label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cost columns in micro-dollars.
|
||||
*******************************************************************************/
|
||||
static void micros(QTableMetaData table, String... fieldNames)
|
||||
{
|
||||
for(String fieldName : fieldNames)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null)
|
||||
{
|
||||
String label = field.getLabel() == null ? QInstanceEnricher.nameToLabel(fieldName) : field.getLabel();
|
||||
field.withLabel(label.replace("Micros", "(micro-dollars)"));
|
||||
field.withDisplayFormat(DisplayFormat.COMMAS);
|
||||
help(table, fieldName, MICROS_HELP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whole-number columns shown with thousands separators.
|
||||
*******************************************************************************/
|
||||
static void counts(QTableMetaData table, String... fieldNames)
|
||||
{
|
||||
for(String fieldName : fieldNames)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null)
|
||||
{
|
||||
field.withDisplayFormat(DisplayFormat.COMMAS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void readOnly(QTableMetaData table, String... fieldNames)
|
||||
{
|
||||
for(String fieldName : fieldNames)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null)
|
||||
{
|
||||
field.setIsEditable(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Give a field help text, unless it has some (or is absent).
|
||||
*******************************************************************************/
|
||||
static void help(QTableMetaData table, String fieldName, String text)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null && (field.getHelpContents() == null || field.getHelpContents().isEmpty()))
|
||||
{
|
||||
field.withHelpContent(new QHelpContent(text));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Join the conversation to a child table on conversationId and list the
|
||||
** children on the conversation's view (read-only).
|
||||
*******************************************************************************/
|
||||
private static void childList(QInstance qInstance, QTableMetaData conversation, String childTableName, String label, String icon, List<String> fields)
|
||||
{
|
||||
if(qInstance.getTable(childTableName) == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QJoinMetaData join = new QJoinMetaData()
|
||||
.withLeftTable(conversation.getName())
|
||||
.withRightTable(childTableName)
|
||||
.withInferredName()
|
||||
.withType(JoinType.ONE_TO_MANY)
|
||||
.withJoinOn(new JoinOn("id", "conversationId"));
|
||||
if(qInstance.getJoin(join.getName()) != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
qInstance.addJoin(join);
|
||||
|
||||
List<String> shown = new ArrayList<>(fields);
|
||||
shown.removeIf(name -> !qInstance.getTable(childTableName).getFields().containsKey(name));
|
||||
qInstance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(join)
|
||||
.withLabel(label)
|
||||
.withMaxRows(50)
|
||||
.withCanAddChildRecord(false)
|
||||
.withOnlyIncludeFieldNames(shown)
|
||||
.getWidgetMetaData());
|
||||
|
||||
String sectionName = CHILD_LIST_PREFIX + label.replace(" ", "");
|
||||
conversation.addSection(new QFieldSection(sectionName, new QIcon(icon), Tier.T2).withLabel(label).withWidgetName(join.getName()));
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.util.List;
|
||||
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.AssistantPolicy;
|
||||
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.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for AssistantRecordScreens: the Assistant Admin record screens.
|
||||
*******************************************************************************/
|
||||
class AssistantRecordScreensTest extends BaseAssistantTest
|
||||
{
|
||||
private static final List<String> TABLES = List.of(AssistantConversation.TABLE_NAME, AssistantMessage.TABLE_NAME, AssistantTurn.TABLE_NAME,
|
||||
AssistantTurnEvent.TABLE_NAME, AssistantToolCall.TABLE_NAME, AssistantFeedback.TABLE_NAME, AssistantPolicy.TABLE_NAME, AssistantUsageDaily.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table has named sections, never the generic ones, and every field
|
||||
** is in exactly one section.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryFieldInOneNamedSection()
|
||||
{
|
||||
for(String tableName : TABLES)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
assertThat(table.getSections()).extracting(QFieldSection::getName).as(tableName).doesNotContain("identity", "otherFields");
|
||||
|
||||
List<String> sectioned = table.getSections().stream()
|
||||
.filter(section -> section.getFieldNames() != null)
|
||||
.flatMap(section -> section.getFieldNames().stream()).toList();
|
||||
assertThat(sectioned).as(tableName).doesNotHaveDuplicates().containsExactlyInAnyOrderElementsOf(table.getFields().keySet());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON columns get the JSON viewer, ids link, costs say micro-dollars,
|
||||
** the policy effort is a picker, the conversation status is read-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFields()
|
||||
{
|
||||
for(String[] json : List.of(new String[] { AssistantConversation.TABLE_NAME, "contextJson" }, new String[] { AssistantMessage.TABLE_NAME, "partsJson" },
|
||||
new String[] { AssistantToolCall.TABLE_NAME, "resultJson" }, new String[] { AssistantTurnEvent.TABLE_NAME, "dataJson" }))
|
||||
{
|
||||
QFieldMetaData field = qInstance.getTable(json[0]).getField(json[1]);
|
||||
assertThat(field.getType()).isEqualTo(QFieldType.TEXT);
|
||||
assertThat(field.getAdornments()).anyMatch(a -> AdornmentType.CODE_EDITOR.equals(a.getType()));
|
||||
}
|
||||
|
||||
assertThat(qInstance.getTable(AssistantToolCall.TABLE_NAME).getField("turnId").getPossibleValueSourceName()).isEqualTo(AssistantTurn.TABLE_NAME);
|
||||
assertThat(qInstance.getTable(AssistantFeedback.TABLE_NAME).getField("messageId").getPossibleValueSourceName()).isEqualTo(AssistantMessage.TABLE_NAME);
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantTurn.TABLE_NAME)).isNotNull();
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantMessage.TABLE_NAME)).isNotNull();
|
||||
|
||||
assertThat(qInstance.getTable(AssistantTurn.TABLE_NAME).getField("costMicros").getLabel()).contains("micro-dollars");
|
||||
assertThat(qInstance.getTable(AssistantTurn.TABLE_NAME).getField("costMicros").getHelpContents()).isNotEmpty();
|
||||
|
||||
QFieldMetaData effort = qInstance.getTable(AssistantPolicy.TABLE_NAME).getField("mainEffort");
|
||||
assertThat(effort.getPossibleValueSourceName()).isEqualTo(AssistantRecordScreens.EFFORT_PVS_NAME);
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantRecordScreens.EFFORT_PVS_NAME).getEnumValues()).hasSize(5);
|
||||
assertThat(qInstance.getTable(AssistantPolicy.TABLE_NAME).getField("userDailyCostMicrosHard").getHelpContents()).isNotEmpty();
|
||||
|
||||
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getField("status").getIsEditable()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation lists its messages, turns, tool calls and feedback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConversationChildLists()
|
||||
{
|
||||
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
|
||||
List<String> widgets = conversation.getSections().stream().map(QFieldSection::getWidgetName).filter(name -> name != null).toList();
|
||||
assertThat(widgets).hasSize(4);
|
||||
for(String widget : widgets)
|
||||
{
|
||||
assertThat(qInstance.getWidget(widget)).as(widget).isNotNull();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// a second pass does not add them again //
|
||||
///////////////////////////////////////////////////
|
||||
AssistantRecordScreens.addConversationChildLists(qInstance);
|
||||
assertThat(conversation.getSections().stream().filter(section -> section.getWidgetName() != null)).hasSize(4);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A field the screens do not place (e.g. a new column) lands in Details.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnplacedFieldGoesToDetails()
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(AssistantFeedback.TABLE_NAME)
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("rating", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("brandNew", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME));
|
||||
AssistantRecordScreens.apply(table);
|
||||
assertThat(table.getSection(AssistantRecordScreens.DETAILS_SECTION).getFieldNames()).containsExactly("brandNew");
|
||||
assertThat(table.getSection("dates").getFieldNames()).containsExactly("createDate");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user