Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2182193a92 | ||
|
|
50ed5f5efb | ||
|
|
8b7aca1b72 |
@@ -7,7 +7,6 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
transmit_cache_keys
|
||||
end
|
||||
|
||||
def update_presence
|
||||
@@ -25,19 +24,6 @@ class RoomChannel < ApplicationCable::Channel
|
||||
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
|
||||
end
|
||||
|
||||
# Push the authoritative cache-key map to this subscriber on every
|
||||
# (re)subscribe. Boot and reconnect cache freshness ride the same
|
||||
# account.cache_invalidated event the dashboard already handles for live
|
||||
# invalidations — the client never pulls /cache_keys itself.
|
||||
def transmit_cache_keys
|
||||
return if @current_account.blank? || !@current_user.is_a?(User)
|
||||
|
||||
transmit({
|
||||
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
|
||||
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
|
||||
})
|
||||
end
|
||||
|
||||
def ensure_stream
|
||||
stream_from pubsub_token
|
||||
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
|
||||
|
||||
@@ -50,6 +50,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
|
||||
render json: { cache_keys: cache_keys_for_account }, status: :ok
|
||||
end
|
||||
|
||||
@@ -92,7 +93,11 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def cache_keys_for_account
|
||||
@account.cache_keys
|
||||
{
|
||||
label: fetch_value_for_key(params[:id], Label.name.underscore),
|
||||
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
|
||||
team: fetch_value_for_key(params[:id], Team.name.underscore)
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_account
|
||||
|
||||
@@ -13,17 +13,11 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
@user.assign_attributes(profile_params)
|
||||
@user.custom_attributes.merge!(custom_attributes_params)
|
||||
@user.save!
|
||||
|
||||
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
|
||||
@user.invalidate_avatar_cache
|
||||
end
|
||||
|
||||
def avatar
|
||||
@user.avatar.attachment.destroy! if @user.avatar.attached?
|
||||
@user.reload
|
||||
|
||||
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
|
||||
@user.invalidate_avatar_cache
|
||||
end
|
||||
|
||||
def auto_offline
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
export default {
|
||||
@@ -109,21 +108,14 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
|
||||
// Paint cached config from IndexedDB instantly while the cable
|
||||
// connects. Freshness needs no orchestration here: RoomChannel pushes
|
||||
// the cache-key map on every (re)subscribe and on every server-side
|
||||
// change, all through the same account.cache_invalidated event.
|
||||
await paintStoresFromCache(this.$store, this.currentAccountId);
|
||||
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
window.reconnectService = this.reconnectService;
|
||||
|
||||
|
||||
@@ -5,15 +5,14 @@ import ApiClient from './ApiClient';
|
||||
class CacheEnabledApiClient extends ApiClient {
|
||||
constructor(resource, options = {}) {
|
||||
super(resource, options);
|
||||
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
|
||||
// the server cache-key name and the IDB object-store name.
|
||||
this.cacheModelName = options.cacheModel;
|
||||
// inbox/label endpoints wrap collections in { payload }; the rest return
|
||||
// the bare array.
|
||||
this.payloadEnvelope = options.payloadEnvelope || false;
|
||||
this.dataManager = new DataManager(this.accountIdFromRoute);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
throw new Error('cacheModelName is not defined');
|
||||
}
|
||||
|
||||
get(cache = false) {
|
||||
if (cache) {
|
||||
return this.getFromCache();
|
||||
@@ -26,14 +25,14 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return this.payloadEnvelope ? response.data.payload : response.data;
|
||||
return response.data.payload;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return this.payloadEnvelope
|
||||
? { data: { payload: dataToParse } }
|
||||
: { data: dataToParse };
|
||||
return { data: { payload: dataToParse } };
|
||||
}
|
||||
|
||||
async getFromCache() {
|
||||
@@ -44,23 +43,24 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return this.getFromNetwork();
|
||||
}
|
||||
|
||||
// Trust the IDB cache. Freshness is maintained by the
|
||||
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
|
||||
// map on every (re)subscribe — boot and reconnect included — and the
|
||||
// server broadcasts it on every change. Skipping a per-call /cache_keys
|
||||
// preflight eliminates N GET requests per cold settings-page load.
|
||||
const localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
const { data } = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
|
||||
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
|
||||
|
||||
if (localData.length > 0) {
|
||||
return this.marshallData(localData);
|
||||
let localData = [];
|
||||
if (isCacheValid) {
|
||||
localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
}
|
||||
|
||||
// Empty IDB (first load or wiped): fetch data without a cache key. The
|
||||
// next pushed key map won't match the missing key and will refetch once,
|
||||
// stamping the authoritative key — the client never pulls keys itself.
|
||||
return this.refetchAndCommit(null);
|
||||
if (localData.length === 0) {
|
||||
return this.refetchAndCommit(cacheKeyFromApi);
|
||||
}
|
||||
|
||||
return this.marshallData(localData);
|
||||
}
|
||||
|
||||
async refetchAndCommit(newKey = null) {
|
||||
@@ -69,9 +69,7 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
try {
|
||||
await this.dataManager.initDb();
|
||||
|
||||
// Await replace so data is persisted before the cache key is — otherwise
|
||||
// a concurrent reader could see a fresh key paired with stale data.
|
||||
await this.dataManager.replace({
|
||||
this.dataManager.replace({
|
||||
modelName: this.cacheModelName,
|
||||
data: this.extractDataFromResponse(response),
|
||||
});
|
||||
@@ -91,15 +89,8 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
await this.dataManager.initDb();
|
||||
}
|
||||
|
||||
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
if (cacheKey === undefined) {
|
||||
const localData = await this.dataManager.get({
|
||||
modelName: this.cacheModelName,
|
||||
});
|
||||
return localData.length === 0;
|
||||
}
|
||||
|
||||
return cacheKeyFromApi === cacheKey;
|
||||
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
|
||||
return cacheKeyFromApi === cachekey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
|
||||
createAccount(data) {
|
||||
return axios.post(`${this.apiVersion}/accounts`, data);
|
||||
}
|
||||
|
||||
async getCacheKeys() {
|
||||
const response = await axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
|
||||
);
|
||||
return response.data.cache_keys;
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccountAPI();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* global axios */
|
||||
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class Agents extends CacheEnabledApiClient {
|
||||
class Agents extends ApiClient {
|
||||
constructor() {
|
||||
super('agents', { accountScoped: true, cacheModel: 'account_user' });
|
||||
super('agents', { accountScoped: true });
|
||||
}
|
||||
|
||||
bulkInvite({ emails }) {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AttributeAPI extends CacheEnabledApiClient {
|
||||
class AttributeAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('custom_attribute_definitions', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'custom_attribute_definition',
|
||||
});
|
||||
super('custom_attribute_definitions', { accountScoped: true });
|
||||
}
|
||||
|
||||
getAttributesByModel() {
|
||||
return super.get(true);
|
||||
return axios.get(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
/* global axios */
|
||||
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CannedResponse extends CacheEnabledApiClient {
|
||||
class CannedResponse extends ApiClient {
|
||||
constructor() {
|
||||
super('canned_responses', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'canned_response',
|
||||
});
|
||||
super('canned_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ searchKey } = {}) {
|
||||
if (searchKey) {
|
||||
return axios.get(`${this.url}?search=${searchKey}`);
|
||||
}
|
||||
return super.get(true);
|
||||
get({ searchKey }) {
|
||||
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
|
||||
return axios.get(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('inboxes', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'inbox',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
|
||||
getCampaigns(inboxId) {
|
||||
|
||||
@@ -2,11 +2,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
class LabelsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('labels', {
|
||||
accountScoped: true,
|
||||
cacheModel: 'label',
|
||||
payloadEnvelope: true,
|
||||
});
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
/* global axios */
|
||||
// import ApiClient from './ApiClient';
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
|
||||
export class TeamsAPI extends CacheEnabledApiClient {
|
||||
constructor() {
|
||||
super('teams', { accountScoped: true, cacheModel: 'team' });
|
||||
super('teams', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'team';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: dataToParse };
|
||||
}
|
||||
|
||||
getAgents({ teamId }) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { openDB } from 'idb';
|
||||
import { DATA_VERSION } from './version';
|
||||
import { cacheableModels, cacheableModelNames } from './cacheableModels';
|
||||
|
||||
export class DataManager {
|
||||
constructor(accountId) {
|
||||
this.modelsToSync = cacheableModelNames;
|
||||
this.modelsToSync = ['inbox', 'label', 'team'];
|
||||
this.accountId = accountId;
|
||||
this.db = null;
|
||||
}
|
||||
@@ -12,26 +11,12 @@ export class DataManager {
|
||||
async initDb() {
|
||||
if (this.db) return this.db;
|
||||
const dbName = `cw-store-${this.accountId}`;
|
||||
this.db = await openDB(dbName, DATA_VERSION, {
|
||||
upgrade(db, oldVersion, _newVersion, tx) {
|
||||
// Flush data carried over from a previous schema version so a
|
||||
// DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
|
||||
// first install, so fresh devices skip this. Clearing before creating
|
||||
// means we only ever clear stores that pre-existed this upgrade.
|
||||
if (oldVersion > 0) {
|
||||
for (let index = 0; index < db.objectStoreNames.length; index += 1) {
|
||||
tx.objectStore(db.objectStoreNames.item(index)).clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('cache-keys')) {
|
||||
db.createObjectStore('cache-keys');
|
||||
}
|
||||
cacheableModels.forEach(model => {
|
||||
if (!db.objectStoreNames.contains(model.name)) {
|
||||
db.createObjectStore(model.name, { keyPath: 'id' });
|
||||
}
|
||||
});
|
||||
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('cache-keys');
|
||||
db.createObjectStore('inbox', { keyPath: 'id' });
|
||||
db.createObjectStore('label', { keyPath: 'id' });
|
||||
db.createObjectStore('team', { keyPath: 'id' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +41,7 @@ export class DataManager {
|
||||
async replace({ modelName, data }) {
|
||||
this.validateModel(modelName);
|
||||
|
||||
await this.db.clear(modelName);
|
||||
this.db.clear(modelName);
|
||||
return this.push({ modelName, data });
|
||||
}
|
||||
|
||||
@@ -80,11 +65,9 @@ export class DataManager {
|
||||
}
|
||||
|
||||
async setCacheKeys(cacheKeys) {
|
||||
await Promise.all(
|
||||
Object.entries(cacheKeys).map(([modelName, value]) =>
|
||||
this.db.put('cache-keys', value, modelName)
|
||||
)
|
||||
);
|
||||
Object.keys(cacheKeys).forEach(async modelName => {
|
||||
this.db.put('cache-keys', cacheKeys[modelName], modelName);
|
||||
});
|
||||
}
|
||||
|
||||
async getCacheKey(modelName) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Single source of truth for IDB-cached workspace config.
|
||||
//
|
||||
// Each entry must keep `name` equal to the Rails `Model.name.underscore` value
|
||||
// so the server's `cache_keys` payload (and the IDB object store name) lines up
|
||||
// with what the client looks up.
|
||||
//
|
||||
// `setMutation` is the full commit path used to seed Vuex from IDB (boot
|
||||
// paint) and to swap in refetched rows (event-driven revalidation). Every
|
||||
// SET_* mutation must REPLACE its records (not merge) so rows deleted
|
||||
// server-side never survive as phantoms.
|
||||
export const cacheableModels = [
|
||||
{ name: 'inbox', setMutation: 'inboxes/SET_INBOXES' },
|
||||
{ name: 'label', setMutation: 'labels/SET_LABELS' },
|
||||
{ name: 'team', setMutation: 'teams/SET_TEAMS' },
|
||||
{ name: 'canned_response', setMutation: 'SET_CANNED' },
|
||||
{ name: 'account_user', setMutation: 'agents/SET_AGENTS' },
|
||||
{
|
||||
name: 'custom_attribute_definition',
|
||||
setMutation: 'attributes/SET_CUSTOM_ATTRIBUTE',
|
||||
},
|
||||
];
|
||||
|
||||
export const cacheableModelNames = cacheableModels.map(model => model.name);
|
||||
@@ -1,42 +0,0 @@
|
||||
import AgentAPI from 'dashboard/api/agents';
|
||||
import AttributeAPI from 'dashboard/api/attributes';
|
||||
import CannedResponseAPI from 'dashboard/api/cannedResponse';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// model name → cache-enabled API client. Lives here rather than in
|
||||
// cacheableModels to keep that module import-cycle-free: the API clients
|
||||
// import DataManager, which imports cacheableModels.
|
||||
const apiByModel = {
|
||||
inbox: InboxesAPI,
|
||||
label: LabelsAPI,
|
||||
team: TeamsAPI,
|
||||
canned_response: CannedResponseAPI,
|
||||
account_user: AgentAPI,
|
||||
custom_attribute_definition: AttributeAPI,
|
||||
};
|
||||
|
||||
const revalidateModel = async (store, model, newKey) => {
|
||||
try {
|
||||
const api = apiByModel[model.name];
|
||||
if (await api.validateCacheKey(newKey)) return;
|
||||
|
||||
const response = await api.refetchAndCommit(newKey);
|
||||
store.commit(model.setMutation, api.extractDataFromResponse(response));
|
||||
} catch {
|
||||
// Ignore error — a failed refetch leaves the painted data in place; the
|
||||
// next pushed key map retries.
|
||||
}
|
||||
};
|
||||
|
||||
// The single freshness engine: given a pushed { model_name => key } map
|
||||
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
|
||||
// one on every change), diff each key against IDB and refetch mismatches.
|
||||
export const dispatchCacheRevalidations = (store, keys = {}) =>
|
||||
Promise.all(
|
||||
cacheableModels
|
||||
.filter(model => keys[model.name] !== undefined)
|
||||
.map(model => revalidateModel(store, model, keys[model.name]))
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
import { DataManager } from './DataManager';
|
||||
import { cacheableModels } from './cacheableModels';
|
||||
|
||||
// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint
|
||||
// cached config instantly. This is purely local — zero network calls.
|
||||
//
|
||||
// Freshness is handled entirely by the account.cache_invalidated event:
|
||||
// RoomChannel transmits the current cache-key map on every (re)subscribe, and
|
||||
// the server broadcasts it on every change. dispatchCacheRevalidations diffs
|
||||
// those keys against IDB and refetches mismatches — the client never pulls
|
||||
// cache keys itself.
|
||||
export default async function paintStoresFromCache(store, accountId) {
|
||||
let dm;
|
||||
try {
|
||||
dm = new DataManager(accountId);
|
||||
await dm.initDb();
|
||||
} catch {
|
||||
// IDB unsupported (e.g. Firefox private mode) — silent no-op. Components
|
||||
// will fetch from the network normally via the cache-enabled API client.
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate paint: commit cached data into Vuex immediately.
|
||||
await Promise.all(
|
||||
cacheableModels.map(async model => {
|
||||
const localData = await dm.get({ modelName: model.name });
|
||||
if (localData.length === 0) return;
|
||||
store.commit(model.setMutation, localData);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
// Bump DATA_VERSION to (a) add new object stores to the IDB schema or (b)
|
||||
// flush bad/stale cache globally. The `upgrade()` callback in DataManager runs
|
||||
// only when the stored DB version is less than the requested version; on any
|
||||
// such bump it clears every existing store (a full cache reset) and then
|
||||
// idempotently creates any missing stores. So bump this whenever a cached
|
||||
// model's serializer shape changes, or to force all clients to refetch.
|
||||
//
|
||||
// Thursday, 28 May 2026 — bumped to add canned_response + account_user stores + custom_attribute_definition store
|
||||
export const DATA_VERSION = '1748390400';
|
||||
// Monday, 13 March 2023
|
||||
// Change this version if you want to invalidate old data
|
||||
export const DATA_VERSION = '1678706392';
|
||||
|
||||
@@ -98,6 +98,17 @@ class ReconnectService {
|
||||
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
|
||||
};
|
||||
|
||||
revalidateCaches = async () => {
|
||||
const { label, inbox, team } = await this.store.dispatch(
|
||||
'accounts/getCacheKeys'
|
||||
);
|
||||
await Promise.all([
|
||||
this.store.dispatch('labels/revalidate', { newKey: label }),
|
||||
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
|
||||
this.store.dispatch('teams/revalidate', { newKey: team }),
|
||||
]);
|
||||
};
|
||||
|
||||
handleRouteSpecificFetch = async () => {
|
||||
const currentRoute = this.router.currentRoute.value.name;
|
||||
if (isAConversationRoute(currentRoute, true)) {
|
||||
@@ -127,11 +138,9 @@ class ReconnectService {
|
||||
this.setConversationLastMessageId();
|
||||
};
|
||||
|
||||
// Cached workspace config needs no explicit revalidation here: ActionCable
|
||||
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
|
||||
// on every subscribe via the account.cache_invalidated event.
|
||||
onReconnect = async () => {
|
||||
await this.handleRouteSpecificFetch();
|
||||
await this.revalidateCaches();
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
@@ -270,7 +269,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
onVoiceCallIncoming = data => {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import LabelsAPI from 'dashboard/api/labels';
|
||||
import CannedResponseAPI from 'dashboard/api/cannedResponse';
|
||||
import TeamsAPI from 'dashboard/api/teams';
|
||||
|
||||
vi.mock('dashboard/api/inboxes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/labels', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/teams', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/cannedResponse', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/agents', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('dashboard/api/attributes', () => ({
|
||||
default: {
|
||||
validateCacheKey: vi.fn(),
|
||||
refetchAndCommit: vi.fn(),
|
||||
extractDataFromResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('dispatchCacheRevalidations', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = { commit: vi.fn() };
|
||||
});
|
||||
|
||||
it('refetches stale models and commits via their setMutation', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
|
||||
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
|
||||
LabelsAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
label: 'label-key',
|
||||
});
|
||||
|
||||
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
|
||||
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1 },
|
||||
]);
|
||||
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips models absent from the key payload', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(true);
|
||||
|
||||
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
|
||||
|
||||
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats missing keys as an empty payload', async () => {
|
||||
await dispatchCacheRevalidations(store);
|
||||
|
||||
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
|
||||
expect(store.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows per-model errors so one failure does not block the rest', async () => {
|
||||
InboxesAPI.validateCacheKey.mockResolvedValue(false);
|
||||
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
|
||||
CannedResponseAPI.validateCacheKey.mockResolvedValue(false);
|
||||
CannedResponseAPI.refetchAndCommit.mockResolvedValue({ data: [] });
|
||||
CannedResponseAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
|
||||
|
||||
await dispatchCacheRevalidations(store, {
|
||||
inbox: 'inbox-key',
|
||||
canned_response: 'canned-key',
|
||||
});
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('SET_CANNED', [{ id: 7 }]);
|
||||
expect(store.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache';
|
||||
import { DataManager } from '../../CacheHelper/DataManager';
|
||||
|
||||
describe('paintStoresFromCache', () => {
|
||||
const accountId = 'paint-test-account';
|
||||
const originalAxios = window.axios;
|
||||
let axiosMock;
|
||||
let dm;
|
||||
let storeMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
window.axios = axiosMock;
|
||||
|
||||
storeMock = {
|
||||
commit: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
|
||||
dm = new DataManager(accountId);
|
||||
await dm.initDb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const tx = dm.db.transaction(
|
||||
[...dm.modelsToSync, 'cache-keys'],
|
||||
'readwrite'
|
||||
);
|
||||
[...dm.modelsToSync, 'cache-keys'].forEach(name => {
|
||||
tx.objectStore(name).clear();
|
||||
});
|
||||
await tx.done;
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('does nothing when IDB is empty (first ever load)', async () => {
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).not.toHaveBeenCalled();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds Vuex from IDB without any network interaction', async () => {
|
||||
await dm.push({
|
||||
modelName: 'inbox',
|
||||
data: [{ id: 1, name: 'Support' }],
|
||||
});
|
||||
await dm.push({
|
||||
modelName: 'label',
|
||||
data: [{ id: 9, title: 'Bug' }],
|
||||
});
|
||||
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
|
||||
{ id: 1, name: 'Support' },
|
||||
]);
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [
|
||||
{ id: 9, title: 'Bug' },
|
||||
]);
|
||||
expect(axiosMock.get).not.toHaveBeenCalled();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds teams via SET_TEAMS', async () => {
|
||||
await dm.push({
|
||||
modelName: 'team',
|
||||
data: [{ id: 1, name: 'Sales' }],
|
||||
});
|
||||
|
||||
await paintStoresFromCache(storeMock, accountId);
|
||||
|
||||
expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [
|
||||
{ id: 1, name: 'Sales' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -253,6 +253,27 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidateCaches', () => {
|
||||
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
|
||||
storeMock.dispatch.mockResolvedValueOnce({
|
||||
label: 'labelKey',
|
||||
inbox: 'inboxKey',
|
||||
team: 'teamKey',
|
||||
});
|
||||
await reconnectService.revalidateCaches();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
|
||||
newKey: 'labelKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
|
||||
newKey: 'inboxKey',
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
|
||||
newKey: 'teamKey',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRouteSpecificFetch', () => {
|
||||
it('should fetch conversations and messages if current route is a conversation route', async () => {
|
||||
isAConversationRoute.mockReturnValue(true);
|
||||
@@ -314,10 +335,12 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
describe('onReconnect', () => {
|
||||
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
reconnectService.handleRouteSpecificFetch = vi.fn();
|
||||
reconnectService.revalidateCaches = vi.fn();
|
||||
await reconnectService.onReconnect();
|
||||
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
|
||||
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
|
||||
);
|
||||
|
||||
@@ -163,6 +163,10 @@ export const actions = {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
|
||||
}
|
||||
},
|
||||
|
||||
getCacheKeys: async () => {
|
||||
return AccountAPI.getCacheKeys();
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_AGENT_FETCHING_STATUS, true);
|
||||
try {
|
||||
const response = await AgentAPI.get(true);
|
||||
const response = await AgentAPI.get();
|
||||
commit(types.default.SET_AGENT_FETCHING_STATUS, false);
|
||||
commit(types.default.SET_AGENTS, response.data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -189,6 +189,17 @@ const sendAnalyticsEvent = channelType => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
revalidate: async ({ commit }, { newKey }) => {
|
||||
try {
|
||||
const isExistingKeyValid = await InboxesAPI.validateCacheKey(newKey);
|
||||
if (!isExistingKeyValid) {
|
||||
const response = await InboxesAPI.refetchAndCommit(newKey);
|
||||
commit(types.default.SET_INBOXES, response.data.payload);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
|
||||
@@ -32,6 +32,18 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
revalidate: async function revalidate({ commit }, { newKey }) {
|
||||
try {
|
||||
const isExistingKeyValid = await LabelsAPI.validateCacheKey(newKey);
|
||||
if (!isExistingKeyValid) {
|
||||
const response = await LabelsAPI.refetchAndCommit(newKey);
|
||||
commit(types.SET_LABELS, response.data.payload);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
get: async function getLabels({ commit }) {
|
||||
commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../agents';
|
||||
import * as types from '../../../mutation-types';
|
||||
import AgentAPI from '../../../../api/agents';
|
||||
import agentList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
@@ -9,13 +8,6 @@ const dispatch = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await AgentAPI.dataManager.initDb();
|
||||
await AgentAPI.dataManager.db.clear(AgentAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../attributes';
|
||||
import * as types from '../../../mutation-types';
|
||||
import AttributeAPI from '../../../../api/attributes';
|
||||
import attributesList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await AttributeAPI.dataManager.initDb();
|
||||
await AttributeAPI.dataManager.db.clear(AttributeAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../inboxes';
|
||||
import * as types from '../../../mutation-types';
|
||||
import InboxesAPI from '../../../../api/inboxes';
|
||||
import inboxList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await InboxesAPI.dataManager.initDb();
|
||||
await InboxesAPI.dataManager.db.clear(InboxesAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../labels';
|
||||
import * as types from '../../../mutation-types';
|
||||
import LabelsAPI from '../../../../api/labels';
|
||||
import labelsList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await LabelsAPI.dataManager.initDb();
|
||||
await LabelsAPI.dataManager.db.clear(LabelsAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
|
||||
@@ -2,25 +2,18 @@ import axios from 'axios';
|
||||
import { actions } from '../../teams/actions';
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
DELETE_TEAM,
|
||||
} from '../../teams/types';
|
||||
import TeamsAPI from '../../../../api/teams';
|
||||
import teamsList from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
// Clear the IDB-backed cache between tests so each case starts from a known
|
||||
// empty state and isn't affected by data persisted by a previous test.
|
||||
beforeEach(async () => {
|
||||
await TeamsAPI.dataManager.initDb();
|
||||
await TeamsAPI.dataManager.db.clear(TeamsAPI.cacheModelName);
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
@@ -40,6 +33,7 @@ describe('#actions', () => {
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[SET_TEAM_UI_FLAG, { isFetching: true }],
|
||||
[CLEAR_TEAMS],
|
||||
[SET_TEAMS, teamsList[1]],
|
||||
[SET_TEAM_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -10,14 +11,9 @@ describe('#mutations', () => {
|
||||
describe('#SET_teams', () => {
|
||||
it('set teams records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[SET_TEAMS](state, [teams[1], teams[2]]);
|
||||
expect(state.records).toEqual(teams);
|
||||
});
|
||||
|
||||
it('drops records absent from the new list', () => {
|
||||
const state = { records: { ...teams } };
|
||||
mutations[SET_TEAMS](state, [teams[1]]);
|
||||
expect(state.records).toEqual({ 1: teams[1] });
|
||||
mutations[SET_TEAMS](state, [teams[2]]);
|
||||
expect(state.records).toEqual(teams);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,4 +43,12 @@ describe('#mutations', () => {
|
||||
expect(state.records).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CLEAR_TEAMS', () => {
|
||||
it('delete teams record', () => {
|
||||
const state = { records: { 1: teams[1] } };
|
||||
mutations[CLEAR_TEAMS](state);
|
||||
expect(state.records).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -21,10 +22,22 @@ export const actions = {
|
||||
commit(SET_TEAM_UI_FLAG, { isCreating: false });
|
||||
}
|
||||
},
|
||||
revalidate: async ({ commit }, { newKey }) => {
|
||||
try {
|
||||
const isExistingKeyValid = await TeamsAPI.validateCacheKey(newKey);
|
||||
if (!isExistingKeyValid) {
|
||||
const response = await TeamsAPI.refetchAndCommit(newKey);
|
||||
commit(SET_TEAMS, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
get: async ({ commit }) => {
|
||||
commit(SET_TEAM_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const { data } = await TeamsAPI.get(true);
|
||||
commit(CLEAR_TEAMS);
|
||||
commit(SET_TEAMS, data);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
SET_TEAM_UI_FLAG,
|
||||
CLEAR_TEAMS,
|
||||
SET_TEAMS,
|
||||
SET_TEAM_ITEM,
|
||||
EDIT_TEAM,
|
||||
@@ -14,14 +15,19 @@ export const mutations = {
|
||||
};
|
||||
},
|
||||
|
||||
// Replaces (not merges) so rows deleted server-side never survive as
|
||||
// phantoms — SET_TEAMS only ever receives the full list.
|
||||
[CLEAR_TEAMS]: $state => {
|
||||
$state.records = {};
|
||||
},
|
||||
|
||||
[SET_TEAMS]: ($state, data) => {
|
||||
const records = {};
|
||||
const updatedRecords = { ...$state.records };
|
||||
data.forEach(team => {
|
||||
records[team.id] = team;
|
||||
updatedRecords[team.id] = {
|
||||
...(updatedRecords[team.id] || {}),
|
||||
...team,
|
||||
};
|
||||
});
|
||||
$state.records = records;
|
||||
$state.records = updatedRecords;
|
||||
},
|
||||
|
||||
[SET_TEAM_ITEM]: ($state, data) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const SET_TEAM_UI_FLAG = 'SET_TEAM_UI_FLAG';
|
||||
export const CLEAR_TEAMS = 'CLEAR_TEAMS';
|
||||
export const SET_TEAMS = 'SET_TEAMS';
|
||||
export const SET_TEAM_ITEM = 'SET_TEAM_ITEM';
|
||||
export const EDIT_TEAM = 'EDIT_TEAM';
|
||||
|
||||
@@ -52,9 +52,6 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
filename: avatar_file.original_filename,
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
|
||||
# Agent thumbnails are cached separately, and avatar attachments do not dirty user columns.
|
||||
avatarable.invalidate_avatar_cache if avatarable.respond_to?(:invalidate_avatar_cache)
|
||||
end
|
||||
|
||||
def log_http_error(avatar_url, error)
|
||||
|
||||
@@ -36,16 +36,10 @@ class AccountUser < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
AGENT_CACHE_RELEVANT_COLUMNS = %w[role availability auto_offline custom_role_id].freeze
|
||||
|
||||
after_create_commit :notify_creation, :create_notification_setting
|
||||
after_destroy :notify_deletion, :remove_user_from_account
|
||||
after_save :update_presence_in_redis, if: :saved_change_to_availability?
|
||||
|
||||
after_commit -> { account.update_cache_key('account_user') }, on: [:create, :destroy]
|
||||
after_update_commit -> { account.update_cache_key('account_user') },
|
||||
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
|
||||
|
||||
validates :user_id, uniqueness: { scope: :account_id }
|
||||
|
||||
def create_notification_setting
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
#
|
||||
|
||||
class CannedResponse < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
validates :content, presence: true
|
||||
validates :short_code, presence: true
|
||||
validates :account, presence: true
|
||||
|
||||
@@ -4,15 +4,11 @@ module CacheKeys
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
# Self-healing bound: if a write path ever changes cached data without
|
||||
# bumping its key, expiry forces the sentinel and every client refetches.
|
||||
# 7 days caps that staleness while sparing quiet models (labels, teams)
|
||||
# from a spurious full refetch after every idle weekend.
|
||||
CACHE_KEYS_EXPIRY = 7.days
|
||||
CACHE_KEYS_EXPIRY = 72.hours
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
# index_custom_attribute_definitions_on_account_id (account_id)
|
||||
#
|
||||
class CustomAttributeDefinition < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
STANDARD_ATTRIBUTES = {
|
||||
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
|
||||
last_activity_at],
|
||||
|
||||
@@ -50,13 +50,6 @@ class Portal < ApplicationRecord
|
||||
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
|
||||
attribute_resolver: ->(record) { record.config }
|
||||
|
||||
# Portal name/slug are embedded as help_center into the cached inbox payload
|
||||
# (api/v1/models/_inbox.json.jbuilder), so portal changes must bump the
|
||||
# account's inbox cache key. Destroy is covered too: dependent: :nullify
|
||||
# detaches inboxes via update_all and skips their callbacks.
|
||||
after_update_commit -> { account.update_cache_key('inbox') }
|
||||
after_destroy_commit -> { account.update_cache_key('inbox') }
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
|
||||
@@ -18,12 +18,6 @@ class TeamMember < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :team
|
||||
validates :user_id, uniqueness: { scope: :team_id }
|
||||
|
||||
# is_member is embedded into the cached team payload (per current user) via
|
||||
# api/v1/models/_team.json.jbuilder, so membership changes must bump the team
|
||||
# cache key. team is safe-navigated because destroying a team cascades here
|
||||
# via destroy_async, by which point the team row is already gone.
|
||||
after_commit -> { team&.account&.update_cache_key('team') }, on: [:create, :destroy]
|
||||
end
|
||||
|
||||
TeamMember.include_mod_with('Audit::TeamMember')
|
||||
|
||||
@@ -116,12 +116,8 @@ class User < ApplicationRecord
|
||||
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
|
||||
# rubocop:enable Rails/HasManyOrHasOneDependent
|
||||
|
||||
AGENT_CACHE_RELEVANT_COLUMNS = %w[name email display_name confirmed_at custom_attributes].freeze
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
after_update_commit :bump_account_user_cache_keys,
|
||||
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
|
||||
|
||||
scope :order_by_full_name, -> { order('lower(name) ASC') }
|
||||
|
||||
@@ -216,19 +212,11 @@ class User < ApplicationRecord
|
||||
super
|
||||
end
|
||||
|
||||
def invalidate_avatar_cache
|
||||
bump_account_user_cache_keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_macros
|
||||
macros.personal.destroy_all
|
||||
end
|
||||
|
||||
def bump_account_user_cache_keys
|
||||
accounts.each { |account| account.update_cache_key('account_user') }
|
||||
end
|
||||
end
|
||||
|
||||
User.include_mod_with('Audit::User')
|
||||
|
||||
@@ -58,6 +58,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
config: [
|
||||
:product_name, :feature_faq, :feature_memory, :feature_citation,
|
||||
:feature_contact_attributes,
|
||||
:documentation_sufficiency_gate_enabled,
|
||||
:welcome_message, :handoff_message, :resolution_message,
|
||||
:instructions, :temperature
|
||||
])
|
||||
|
||||
@@ -7,6 +7,27 @@ module Captain::ChatGenerationRecorder
|
||||
def record_llm_generation(chat, message)
|
||||
return unless valid_llm_message?(message)
|
||||
|
||||
if defer_llm_generation?(message)
|
||||
deferred_llm_generations << [chat, message]
|
||||
return
|
||||
end
|
||||
|
||||
record_llm_generation_span(chat, message)
|
||||
end
|
||||
|
||||
def flush_deferred_llm_generations
|
||||
deferred_llm_generations.each do |chat, message|
|
||||
record_llm_generation_span(chat, message)
|
||||
end
|
||||
ensure
|
||||
deferred_llm_generations.clear
|
||||
end
|
||||
|
||||
def discard_deferred_llm_generations
|
||||
deferred_llm_generations.clear
|
||||
end
|
||||
|
||||
def record_llm_generation_span(chat, message)
|
||||
# Create a generation span with model and token info for Langfuse cost calculation.
|
||||
# Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation.
|
||||
tracer.in_span("llm.captain.#{feature_name}.generation") do |span|
|
||||
@@ -17,6 +38,14 @@ module Captain::ChatGenerationRecorder
|
||||
Rails.logger.warn "Failed to record LLM generation: #{e.message}"
|
||||
end
|
||||
|
||||
def defer_llm_generation?(message)
|
||||
!message_has_tool_calls?(message)
|
||||
end
|
||||
|
||||
def deferred_llm_generations
|
||||
@deferred_llm_generations ||= []
|
||||
end
|
||||
|
||||
# Skip non-LLM messages (e.g., tool results that RubyLLM processes internally).
|
||||
# Check for assistant role rather than token presence - some providers/streaming modes
|
||||
# may not return token counts, but we still want to capture the generation for evals.
|
||||
|
||||
@@ -13,7 +13,10 @@ module Captain::ChatHelper
|
||||
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(last_content)
|
||||
|
||||
response = attachments.any? ? chat.ask(text, with: attachments) : chat.ask(text)
|
||||
build_response(response)
|
||||
built_response = build_response(response)
|
||||
after_chat_response(built_response)
|
||||
flush_deferred_llm_generations
|
||||
built_response
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||
@@ -57,15 +60,19 @@ module Captain::ChatHelper
|
||||
|
||||
def handle_tool_call(tool_call)
|
||||
persist_thinking_message(tool_call)
|
||||
start_tool_span(tool_call)
|
||||
start_tool_span(tool_call) unless internally_instrumented_tool?(tool_call)
|
||||
(@pending_tool_calls ||= []).push(tool_call)
|
||||
end
|
||||
|
||||
def handle_tool_result(result)
|
||||
end_tool_span(result)
|
||||
end_tool_span(result) unless internally_instrumented_tool?(@pending_tool_calls&.last)
|
||||
persist_tool_completion
|
||||
end
|
||||
|
||||
def internally_instrumented_tool?(tool_call)
|
||||
tool_call&.name.to_s == 'search_documentation'
|
||||
end
|
||||
|
||||
def add_messages_to_chat(chat)
|
||||
conversation_messages[0...-1].each do |msg|
|
||||
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(msg[:content])
|
||||
@@ -91,21 +98,13 @@ module Captain::ChatHelper
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_messages
|
||||
@messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
|
||||
end
|
||||
def conversation_messages = @messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
|
||||
|
||||
def temperature
|
||||
@assistant&.config&.[]('temperature').to_f || 1
|
||||
end
|
||||
def temperature = @assistant&.config&.[]('temperature').to_f || 1
|
||||
|
||||
def resolved_account_id
|
||||
@account&.id || @assistant&.account_id
|
||||
end
|
||||
def resolved_account_id = @account&.id || @assistant&.account_id
|
||||
|
||||
def resolved_channel_type
|
||||
@conversation&.inbox&.channel_type
|
||||
end
|
||||
def resolved_channel_type = @conversation&.inbox&.channel_type
|
||||
|
||||
# Ensures all LLM calls and tool executions within an agentic loop
|
||||
# are grouped under a single trace/session in Langfuse.
|
||||
@@ -123,6 +122,8 @@ module Captain::ChatHelper
|
||||
@agent_session_active = false unless already_active
|
||||
end
|
||||
|
||||
def after_chat_response(_response) = nil
|
||||
|
||||
# Must be implemented by including class to identify the feature for instrumentation.
|
||||
# Used for Langfuse tagging and span naming.
|
||||
def feature_name
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
module Captain::Conversation::DocumentationSupportGate
|
||||
private
|
||||
|
||||
def check_documentation_support(message_history, chat_service: nil)
|
||||
return unless documentation_support_gate_enabled?
|
||||
return unless customer_reply?
|
||||
|
||||
searches = documentation_searches(message_history)
|
||||
return if documentation_sufficiency_checked_in_tool?(searches)
|
||||
|
||||
review = documentation_support_review(message_history, searches)
|
||||
apply_documentation_support_decision(review, message_history, chat_service)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][ResponseBuilderJob] Documentation support check failed for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def documentation_support_gate_enabled?
|
||||
ActiveModel::Type::Boolean.new.cast(@assistant.config['documentation_sufficiency_gate_enabled'])
|
||||
end
|
||||
|
||||
def customer_reply?
|
||||
@response.present? &&
|
||||
@response['response'].present? &&
|
||||
@response['response'] != 'conversation_handoff' &&
|
||||
!@response['handoff_tool_called']
|
||||
end
|
||||
|
||||
def documentation_support_review(message_history, searches)
|
||||
Captain::Llm::DocumentationSufficiencyService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation
|
||||
).evaluate(
|
||||
message_history: message_history,
|
||||
documentation_searches: searches
|
||||
)
|
||||
end
|
||||
|
||||
def documentation_sufficiency_checked_in_tool?(searches)
|
||||
searches.any? { |search| (search[:documentation_sufficiency] || search['documentation_sufficiency']).present? }
|
||||
end
|
||||
|
||||
def documentation_searches(message_history)
|
||||
searches = @response['documentation_searches'].to_a
|
||||
return searches if searches.present?
|
||||
|
||||
[missing_documentation_search(last_user_message(message_history))]
|
||||
end
|
||||
|
||||
def missing_documentation_search(query)
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: []
|
||||
}
|
||||
end
|
||||
|
||||
def last_user_message(message_history)
|
||||
message = message_history.reverse.find { |item| (item[:role] || item['role']).to_s == 'user' }
|
||||
message && (message[:content] || message['content']).to_s
|
||||
end
|
||||
|
||||
def apply_documentation_support_decision(review, message_history, chat_service)
|
||||
return unless review['decision'] == 'insufficient'
|
||||
|
||||
if chat_service
|
||||
@response.replace(chat_service.generate_documentation_gap_response(message_history: message_history))
|
||||
else
|
||||
@response['response'] = default_documentation_fallback
|
||||
end
|
||||
|
||||
@response.merge!(
|
||||
'action' => 'continue',
|
||||
'action_reason' => 'missing_docs_bounded_answer',
|
||||
'action_source' => 'documentation_support',
|
||||
'documentation_sufficiency_model' => review['model']
|
||||
)
|
||||
end
|
||||
|
||||
def default_documentation_fallback
|
||||
'I do not have enough information to answer that. Would you like me to connect you with support?'
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::DocumentationSupportGate
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -34,17 +35,24 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def generate_and_process_response
|
||||
message_history = collect_previous_messages
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
chat_service = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation)
|
||||
@response = chat_service.generate_response(
|
||||
message_history: message_history
|
||||
)
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
) do |response|
|
||||
response['documentation_searches'] = chat_service.documentation_searches
|
||||
@response = response
|
||||
check_documentation_support(message_history, chat_service: chat_service) if conversation_pending?
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
end
|
||||
process_response
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
message_history = collect_previous_messages
|
||||
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
message_history: message_history
|
||||
)
|
||||
check_documentation_support(message_history) if conversation_pending?
|
||||
process_response
|
||||
end
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
#
|
||||
class Captain::AssistantResponse < ApplicationRecord
|
||||
self.table_name = 'captain_assistant_responses'
|
||||
SEARCH_LIMIT = 5
|
||||
SearchMatch = Struct.new(
|
||||
:response,
|
||||
:semantic_distance,
|
||||
keyword_init: true
|
||||
) do
|
||||
def to_h
|
||||
{
|
||||
response_id: response.id,
|
||||
question: response.question,
|
||||
answer: response.answer,
|
||||
source: response.documentable&.try(:external_link),
|
||||
semantic_distance: semantic_distance
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :account
|
||||
@@ -47,8 +63,21 @@ class Captain::AssistantResponse < ApplicationRecord
|
||||
enum status: { pending: 0, approved: 1 }
|
||||
|
||||
def self.search(query, account_id: nil)
|
||||
search_with_metadata(query, account_id: account_id).map(&:response)
|
||||
end
|
||||
|
||||
def self.search_with_metadata(query, account_id: nil, limit: SEARCH_LIMIT)
|
||||
semantic_search_matches(query, account_id: account_id, limit: limit)
|
||||
end
|
||||
|
||||
def self.semantic_search_matches(query, account_id:, limit:)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
|
||||
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
|
||||
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(limit).map do |response|
|
||||
SearchMatch.new(
|
||||
response: response,
|
||||
semantic_distance: response.neighbor_distance&.to_f
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -39,11 +39,4 @@ class CustomRole < ApplicationRecord
|
||||
|
||||
validates :name, presence: true
|
||||
validates :permissions, inclusion: { in: PERMISSIONS }
|
||||
|
||||
# CustomRole details are embedded into the cached account_user payload via
|
||||
# api/v1/models/_account_user.json.jbuilder, so bump that cache key on any
|
||||
# change. `dependent: :nullify` updates account_users via update_all (which
|
||||
# skips their callbacks), so the deletion is bumped here directly.
|
||||
after_update_commit -> { account.update_cache_key('account_user') }
|
||||
after_destroy_commit -> { account.update_cache_key('account_user') }
|
||||
end
|
||||
|
||||
@@ -6,10 +6,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[id display_id inbox_id contact_id status priority label_list custom_attributes additional_attributes].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
@@ -19,6 +16,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -100,6 +98,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
|
||||
response['agent_name'] = result.context&.dig(:current_agent)
|
||||
response['handoff_tool_called'] = result.context&.dig(:captain_v2_handoff_tool_called) || false
|
||||
response['documentation_searches'] = result.context&.dig(:state, :documentation_searches).to_a
|
||||
response
|
||||
end
|
||||
|
||||
@@ -115,7 +114,8 @@ class Captain::Assistant::AgentRunnerService
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
assistant_config: @assistant.config,
|
||||
documentation_searches: []
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class Captain::DocumentationSearchService
|
||||
TOP_MATCHES_TO_FORMAT = 5
|
||||
|
||||
class << self
|
||||
def serialize(result)
|
||||
result.merge(matches: result[:matches].map(&:to_h))
|
||||
end
|
||||
|
||||
def metadata(result)
|
||||
{
|
||||
match_count: result[:matches].length,
|
||||
top_semantic_distance: result[:matches].first&.semantic_distance
|
||||
}.compact
|
||||
end
|
||||
|
||||
def format_for_tool(result, no_results_message:, documentation_sufficiency: nil)
|
||||
return "#{no_results_message}\n\n#{no_results_instruction}" if result[:matches].empty?
|
||||
|
||||
[documentation_sufficiency_section(documentation_sufficiency), formatted_matches(result)].flatten.compact.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def formatted_matches(result)
|
||||
result[:matches].first(TOP_MATCHES_TO_FORMAT).map { |match| format_match(match) }
|
||||
end
|
||||
|
||||
def format_match(match)
|
||||
response = match.response
|
||||
lines = ['', "Question: #{response.question}", "Answer: #{response.answer}"]
|
||||
lines << "Source: #{response.documentable.external_link}" if response.documentable.present? && response.documentable.try(:external_link)
|
||||
"#{lines.join("\n")}\n"
|
||||
end
|
||||
|
||||
def documentation_sufficiency_section(documentation_sufficiency)
|
||||
decision = documentation_sufficiency && (documentation_sufficiency[:decision] || documentation_sufficiency['decision'])
|
||||
return if decision.blank?
|
||||
|
||||
if decision == 'sufficient'
|
||||
[
|
||||
'Documentation support: sufficient',
|
||||
'Instruction: Use only the retrieved documentation below to answer the user.'
|
||||
].join("\n")
|
||||
else
|
||||
[
|
||||
'Documentation support: insufficient',
|
||||
'Instruction: The retrieved documentation does not answer the user question.',
|
||||
'Do not answer the factual question from these results. Ask one clarifying question if useful, or offer a handoff.'
|
||||
].join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
def no_results_instruction
|
||||
[
|
||||
'Instruction: No documentation matched this query.',
|
||||
'Do not use documentation search results to make factual claims.',
|
||||
'Ask one useful follow-up question or offer a handoff.'
|
||||
].join(' ')
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(scope:, account_id: nil)
|
||||
@scope = scope
|
||||
@account_id = account_id
|
||||
end
|
||||
|
||||
def search(query)
|
||||
matches = @scope.search_with_metadata(query, account_id: @account_id)
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: matches
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
attr_reader :documentation_searches
|
||||
|
||||
def initialize(assistant: nil, conversation: nil, source: nil)
|
||||
super()
|
||||
@@ -11,6 +12,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
@documentation_searches = []
|
||||
@tools = build_tools
|
||||
end
|
||||
|
||||
@@ -21,16 +23,44 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
#
|
||||
# NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
|
||||
# positional ordering.
|
||||
def generate_response(additional_message: nil, message_history: [], role: 'user')
|
||||
def generate_response(additional_message: nil, message_history: [], role: 'user', &after_response)
|
||||
@messages += message_history
|
||||
@messages << { role: role, content: additional_message } if additional_message.present?
|
||||
@after_response = after_response
|
||||
request_chat_completion
|
||||
ensure
|
||||
@after_response = nil
|
||||
end
|
||||
|
||||
def generate_documentation_gap_response(message_history:)
|
||||
previous_messages = @messages
|
||||
previous_tools = @tools
|
||||
previous_after_response = @after_response
|
||||
|
||||
discard_deferred_llm_generations
|
||||
@messages = [system_message, documentation_gap_instruction] + message_history
|
||||
@tools = []
|
||||
@after_response = nil
|
||||
|
||||
request_chat_completion
|
||||
ensure
|
||||
@messages = previous_messages
|
||||
@tools = previous_tools
|
||||
@after_response = previous_after_response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_tools
|
||||
tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
tools = [
|
||||
Captain::Tools::SearchDocumentationService.new(
|
||||
@assistant,
|
||||
user: nil,
|
||||
on_search: ->(search) { @documentation_searches << search },
|
||||
message_history: -> { conversation_messages },
|
||||
conversation: @conversation
|
||||
)
|
||||
]
|
||||
return tools unless custom_tools_enabled?
|
||||
|
||||
tools + @assistant.account.captain_custom_tools.enabled.map do |ct|
|
||||
@@ -49,6 +79,20 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
}
|
||||
end
|
||||
|
||||
def documentation_gap_instruction
|
||||
{
|
||||
role: 'system',
|
||||
content: <<~PROMPT
|
||||
[Documentation Support]
|
||||
The retrieved documentation was not sufficient to answer the user's latest question.
|
||||
Do not answer the factual question or cite the retrieved documentation.
|
||||
Respond briefly in the user's language.
|
||||
Ask one clarifying question if that would help, or offer a handoff.
|
||||
Return the normal JSON response.
|
||||
PROMPT
|
||||
}
|
||||
end
|
||||
|
||||
def custom_tools_metadata
|
||||
return [] unless custom_tools_enabled?
|
||||
|
||||
@@ -81,4 +125,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
def feature_name
|
||||
'assistant'
|
||||
end
|
||||
|
||||
def after_chat_response(response)
|
||||
@after_response&.call(response)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
class Captain::Llm::DocumentationSufficiencyService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
MODEL = 'gpt-5.4-mini'.freeze
|
||||
MAX_CONTEXT_MESSAGES = 6
|
||||
MAX_SEARCHES = 3
|
||||
MAX_MATCHES_PER_SEARCH = 5
|
||||
MAX_ANSWER_CHARS = 700
|
||||
|
||||
def initialize(assistant:, conversation:)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@model = MODEL
|
||||
@temperature = 0.0
|
||||
end
|
||||
|
||||
def evaluate(message_history:, documentation_searches:)
|
||||
user_prompt = inspection_user_prompt(
|
||||
message_history: message_history,
|
||||
documentation_searches: documentation_searches
|
||||
)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params(user_prompt, documentation_searches)) do
|
||||
chat(model: @model, temperature: @temperature)
|
||||
.with_schema(Captain::DocumentationSufficiencySchema)
|
||||
.with_instructions(system_prompt)
|
||||
.ask(user_prompt)
|
||||
end
|
||||
|
||||
parsed = parse_response(response.content)
|
||||
normalize_response(parsed, response.content)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][DocumentationSufficiency] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
{ 'decision' => nil, 'error' => e.message, 'model' => @model }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are checking whether retrieved documentation can answer the user's latest question.
|
||||
|
||||
Use only the conversation context and retrieved documentation search results provided.
|
||||
Do not use outside knowledge.
|
||||
|
||||
Return "sufficient" only when the retrieved documentation directly answers the user's latest question.
|
||||
Return "insufficient" when the retrieved documentation is missing, unrelated, only loosely related, or does not cover the
|
||||
specific entity, product, platform, integration, account object, user intent, or constraint in the latest question.
|
||||
Treat prior assistant messages as claims, not evidence. They do not support the new answer by themselves.
|
||||
Conversation context can clarify the latest question, but it cannot supply missing documentation evidence.
|
||||
Check generic support dimensions:
|
||||
- same entity, product, platform, integration, or account object
|
||||
- same user intent, not just a nearby topic
|
||||
- requested constraints from the user
|
||||
- evidence specificity; broad docs are not enough for specific claims
|
||||
|
||||
Return only the decision. Do not write a reason or customer-facing fallback copy.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def inspection_user_prompt(message_history:, documentation_searches:)
|
||||
<<~PROMPT
|
||||
<conversation_context>
|
||||
#{format_conversation_context(message_history)}
|
||||
</conversation_context>
|
||||
|
||||
<retrieved_documentation>
|
||||
#{format_documentation_searches(documentation_searches)}
|
||||
</retrieved_documentation>
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def format_documentation_searches(searches)
|
||||
searches.to_a.last(MAX_SEARCHES).map.with_index(1) do |search, index|
|
||||
<<~SEARCH
|
||||
Search #{index}
|
||||
query: #{value(search, :query)}
|
||||
matches:
|
||||
#{format_documentation_matches(value(search, :matches).to_a)}
|
||||
SEARCH
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def format_documentation_matches(matches)
|
||||
matches.to_a.first(MAX_MATCHES_PER_SEARCH).map.with_index(1) do |match, index|
|
||||
<<~MATCH
|
||||
#{index}. question: #{value(match, :question)}
|
||||
answer: #{truncate_text(value(match, :answer))}
|
||||
source: #{value(match, :source)}
|
||||
MATCH
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def value(hash, key) = hash && (hash[key] || hash[key.to_s])
|
||||
|
||||
def normalize_messages(message_history)
|
||||
message_history.filter_map do |message|
|
||||
role = value(message, :role)
|
||||
next if role.blank?
|
||||
|
||||
{ role: role.to_s, content: normalize_content(value(message, :content)) }
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_content(content)
|
||||
return content if content.is_a?(String)
|
||||
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
|
||||
|
||||
content.to_s
|
||||
end
|
||||
|
||||
def text_part?(part)
|
||||
return false unless part.is_a?(Hash)
|
||||
|
||||
(part[:type] || part['type']).to_s == 'text'
|
||||
end
|
||||
|
||||
def format_conversation_context(messages)
|
||||
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
|
||||
content = message[:content].to_s.strip
|
||||
next if content.blank?
|
||||
|
||||
"#{role_label(message[:role])}: #{content}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def role_label(role) = { 'user' => 'User', 'assistant' => 'Assistant' }.fetch(role, role.to_s.titleize)
|
||||
|
||||
def parse_response(content)
|
||||
return content if content.is_a?(Hash)
|
||||
|
||||
JSON.parse(sanitize_json_response(content))
|
||||
rescue JSON::ParserError, TypeError
|
||||
{}
|
||||
end
|
||||
|
||||
def normalize_response(parsed, raw_content)
|
||||
decision = parsed['decision'].to_s
|
||||
return invalid_response(raw_content) unless Captain::DocumentationSufficiencySchema::DECISIONS.include?(decision)
|
||||
|
||||
{
|
||||
'decision' => decision,
|
||||
'raw_response' => raw_content,
|
||||
'model' => @model
|
||||
}
|
||||
end
|
||||
|
||||
def invalid_response(raw_content)
|
||||
{
|
||||
'decision' => nil,
|
||||
'raw_response' => raw_content,
|
||||
'error' => 'invalid_documentation_sufficiency_response',
|
||||
'model' => @model
|
||||
}
|
||||
end
|
||||
|
||||
def instrumentation_params(user_prompt, documentation_searches)
|
||||
{
|
||||
span_name: 'llm.captain.documentation_sufficiency',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.display_id,
|
||||
feature_name: 'documentation_sufficiency',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
],
|
||||
metadata: {
|
||||
assistant_id: @assistant.id,
|
||||
channel_type: @conversation.inbox&.channel_type,
|
||||
source: 'response_builder'
|
||||
}.merge(search_metadata(documentation_searches))
|
||||
}
|
||||
end
|
||||
|
||||
def search_metadata(documentation_searches)
|
||||
searches = documentation_searches.to_a
|
||||
{
|
||||
search_count: searches.length,
|
||||
match_count: searches.sum { |search| value(search, :matches).to_a.length }
|
||||
}
|
||||
end
|
||||
|
||||
def truncate_text(text)
|
||||
text = text.to_s
|
||||
return text if text.length <= MAX_ANSWER_CHARS
|
||||
|
||||
"#{text.first(MAX_ANSWER_CHARS)}..."
|
||||
end
|
||||
end
|
||||
@@ -190,6 +190,7 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
[Error Handling]
|
||||
- If the required information is not found in the provided context, respond with an appropriate message indicating that no relevant data is available.
|
||||
- If documentation search has no results, do not make factual claims from it. Say briefly that you do not have that information, ask one clarifying question if useful, or offer a human handoff.
|
||||
- Avoid speculating or providing unverified information.
|
||||
|
||||
[Available Actions]
|
||||
@@ -261,7 +262,9 @@ class Captain::Llm::SystemPromptsService
|
||||
response: '',
|
||||
}
|
||||
```
|
||||
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
|
||||
- For product facts, policies, account/service behavior, or how-to questions, call `search_documentation` before saying the answer is unavailable.
|
||||
- If `search_documentation` does not provide the answer, respond to the customer and ask whether they want to talk to another support agent. If they ask to chat with another agent, return `conversation_handoff' as the response in JSON response
|
||||
- If documentation search has no results, do not make factual claims from it. Say briefly that you do not have that information, ask one clarifying question if useful, or offer a human handoff.
|
||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
||||
|
||||
#{build_tools_section(custom_tools)}
|
||||
|
||||
@@ -3,8 +3,24 @@ module Captain::Tools::Instrumentation
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def execute(**args)
|
||||
return super unless self.class.instrument_tool_execution?
|
||||
|
||||
instrument_tool_call(name, args) do
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def self.prepended(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def instrument_tool_execution?
|
||||
@instrument_tool_execution != false
|
||||
end
|
||||
|
||||
def skip_tool_execution_instrumentation
|
||||
@instrument_tool_execution = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
||||
include Integrations::LlmInstrumentation
|
||||
skip_tool_execution_instrumentation
|
||||
|
||||
def initialize(assistant, user: nil, on_search: nil, message_history: nil, conversation: nil)
|
||||
super(assistant, user: user)
|
||||
@on_search = on_search
|
||||
@message_history = message_history
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def self.name
|
||||
'search_documentation'
|
||||
end
|
||||
@@ -13,26 +23,70 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
||||
.new(account: assistant.account)
|
||||
.translate(query, target_language: assistant.account.locale_english_name)
|
||||
|
||||
responses = assistant.responses.approved.search(translated_query)
|
||||
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
|
||||
responses.map { |response| format_response(response) }.join
|
||||
instrument_documentation_search(query: translated_query, original_query: query) do
|
||||
format_search_result(search_documentation(translated_query))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
def instrument_documentation_search(query:, original_query:, &)
|
||||
arguments = { query: query }
|
||||
arguments[:original_query] = original_query if original_query != query
|
||||
|
||||
formatted_response
|
||||
instrument_tool_call('search_documentation', arguments, &)
|
||||
end
|
||||
|
||||
def write_search_metadata(result, documentation_sufficiency = nil)
|
||||
span = OpenTelemetry::Trace.current_span
|
||||
metadata = Captain::DocumentationSearchService.metadata(result)
|
||||
decision = documentation_sufficiency && (documentation_sufficiency[:decision] || documentation_sufficiency['decision'])
|
||||
metadata[:documentation_sufficiency] = decision if decision
|
||||
metadata.each do |key, value|
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
|
||||
span.set_attribute(format(ATTR_LANGFUSE_OBSERVATION_METADATA, key), value.to_s)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "#{self.class.name}: Failed to write search metadata: #{e.message}"
|
||||
end
|
||||
|
||||
def search_documentation(query)
|
||||
Captain::DocumentationSearchService.new(
|
||||
scope: assistant.responses.approved,
|
||||
account_id: assistant.account_id
|
||||
).search(query)
|
||||
end
|
||||
|
||||
def format_search_result(result)
|
||||
serialized_result = Captain::DocumentationSearchService.serialize(result)
|
||||
documentation_sufficiency = evaluate_documentation_sufficiency(serialized_result)
|
||||
serialized_result[:documentation_sufficiency] = documentation_sufficiency if documentation_sufficiency.present?
|
||||
write_search_metadata(result, documentation_sufficiency)
|
||||
@on_search&.call(serialized_result)
|
||||
|
||||
Captain::DocumentationSearchService.format_for_tool(
|
||||
result,
|
||||
no_results_message: 'No documentation found for the given query',
|
||||
documentation_sufficiency: documentation_sufficiency
|
||||
)
|
||||
end
|
||||
|
||||
def evaluate_documentation_sufficiency(search)
|
||||
return unless documentation_sufficiency_enabled?
|
||||
return { 'decision' => 'insufficient', 'model' => nil } if search[:matches].blank?
|
||||
|
||||
Captain::Llm::DocumentationSufficiencyService.new(
|
||||
assistant: assistant,
|
||||
conversation: @conversation
|
||||
).evaluate(
|
||||
message_history: @message_history.call,
|
||||
documentation_searches: [search]
|
||||
)
|
||||
end
|
||||
|
||||
def documentation_sufficiency_enabled?
|
||||
@conversation.present? &&
|
||||
@message_history.respond_to?(:call) &&
|
||||
ActiveModel::Type::Boolean.new.cast(assistant.config['documentation_sufficiency_gate_enabled'])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,9 +5,10 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
|
||||
param :query, desc: 'Search Query', required: true
|
||||
|
||||
def initialize(account:, assistant: nil)
|
||||
def initialize(account:, assistant: nil, on_search: nil)
|
||||
@account = account
|
||||
@assistant = assistant
|
||||
@on_search = on_search
|
||||
super()
|
||||
end
|
||||
|
||||
@@ -22,25 +23,20 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
.new(account: @account)
|
||||
.translate(query, target_language: @account.locale_english_name)
|
||||
|
||||
responses = search_responses(translated_query)
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
result = Captain::DocumentationSearchService.new(
|
||||
scope: search_scope,
|
||||
account_id: @account.id
|
||||
).search(translated_query)
|
||||
@on_search&.call(Captain::DocumentationSearchService.serialize(result))
|
||||
|
||||
responses.map { |response| format_response(response) }.join
|
||||
Captain::DocumentationSearchService.format_for_tool(result, no_results_message: 'No FAQs found for the given query')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_responses(query)
|
||||
if @assistant.present?
|
||||
@assistant.responses.approved.search(query, account_id: @account.id)
|
||||
else
|
||||
@account.captain_assistant_responses.approved.search(query, account_id: @account.id)
|
||||
end
|
||||
end
|
||||
def search_scope
|
||||
return @assistant.responses.approved if @assistant.present?
|
||||
|
||||
def format_response(response)
|
||||
result = "\nQuestion: #{response.question}\nAnswer: #{response.answer}\n"
|
||||
result += "Source: #{response.documentable.external_link}\n" if response.documentable.present? && response.documentable.try(:external_link)
|
||||
result
|
||||
@account.captain_assistant_responses.approved
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Captain Documentation Answerability Handoff
|
||||
|
||||
We investigated traces where Captain answered even though documentation search was weak, missing, or unrelated.
|
||||
|
||||
## What Failed
|
||||
|
||||
The main pattern was not just "no docs found". It was:
|
||||
|
||||
> Captain retrieved weak or nearby docs, then answered as if they supported the answer.
|
||||
|
||||
We saw three failure types:
|
||||
|
||||
- The model did not call `search_documentation`.
|
||||
- The tool returned loosely related docs.
|
||||
- The model treated "some result exists" as enough evidence.
|
||||
|
||||
## What We Tried First
|
||||
|
||||
We first tried a deterministic pgvector distance threshold.
|
||||
|
||||
For cosine distance in pgvector:
|
||||
|
||||
- Lower is better.
|
||||
- `0` means very similar.
|
||||
- Higher values mean less similar.
|
||||
|
||||
That failed as a product guardrail. Some useful matches had higher distances, and some bad matches shared enough words to look plausible. A single threshold would need constant tuning across accounts, languages, and writing styles.
|
||||
|
||||
We also avoided keyword/stopword rules because they quickly become language-specific and account-specific.
|
||||
|
||||
## Current Approach
|
||||
|
||||
Search and answerability are now separate.
|
||||
|
||||
After `search_documentation` retrieves docs, a small LLM check asks:
|
||||
|
||||
> Do these docs answer the latest user question?
|
||||
|
||||
It returns only `sufficient` or `insufficient`. It does not write customer-facing copy and does not judge the assistant's draft answer.
|
||||
|
||||
The decision is added to the tool output. The final assistant generation then uses it:
|
||||
|
||||
- `sufficient`: answer from the retrieved docs.
|
||||
- `insufficient`: do not answer the factual question; ask a clarifying question or offer handoff.
|
||||
|
||||
A post-response backstop still exists for the case where the model never called the documentation tool.
|
||||
|
||||
## Next Step
|
||||
|
||||
Replay weak-documentation traces against this flow and compare the `sufficient` / `insufficient` decisions with the manually reviewed golden set.
|
||||
|
||||
Hybrid or keyword search should be added before the answerability check later. Better retrieval should improve the docs we pass into the same check.
|
||||
@@ -0,0 +1,7 @@
|
||||
class Captain::DocumentationSufficiencySchema < RubyLLM::Schema
|
||||
DECISIONS = %w[sufficient insufficient].freeze
|
||||
|
||||
string :decision,
|
||||
enum: DECISIONS,
|
||||
description: 'Use sufficient only when retrieved documentation directly answers the latest user question'
|
||||
end
|
||||
@@ -11,6 +11,7 @@ Don't digress away from your instructions, and use all the available tools at yo
|
||||
# Core Rules
|
||||
- Do not use your own understanding or training data to provide answers. Base responses strictly on the information available through your tools and provided context.
|
||||
- Do not share anything outside of the context provided.
|
||||
- If FAQ lookup has no results, do not make factual claims from it. Say briefly that you do not have that information, ask one clarifying question if useful, or use handoff.
|
||||
- Be concise and relevant: most of your responses should be a sentence or two, unless a more detailed explanation is necessary.
|
||||
- Always detect the language from the user's input and reply in the same language.
|
||||
- When there is ambiguity, ask clarifying questions rather than make assumptions.
|
||||
|
||||
@@ -2,47 +2,30 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
def perform(tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
# Use existing vector search on approved responses
|
||||
responses = @assistant.responses.approved.search(query).to_a
|
||||
result = Captain::DocumentationSearchService.new(
|
||||
scope: @assistant.responses.approved,
|
||||
account_id: @assistant.account_id
|
||||
).search(query)
|
||||
record_documentation_search(tool_context, result)
|
||||
|
||||
if responses.empty?
|
||||
if result[:matches].empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
"No relevant FAQs found for: #{query}"
|
||||
else
|
||||
log_tool_usage('found_results', { query: query, count: responses.size })
|
||||
format_responses(responses)
|
||||
log_tool_usage('completed', { query: query, count: result[:matches].size })
|
||||
end
|
||||
|
||||
Captain::DocumentationSearchService.format_for_tool(result, no_results_message: "No relevant FAQs found for: #{query}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_responses(responses)
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
def record_documentation_search(tool_context, result)
|
||||
searches = tool_context&.state&.dig(:documentation_searches)
|
||||
return unless searches
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if should_show_source?(response)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
|
||||
formatted_response
|
||||
end
|
||||
|
||||
def should_show_source?(response)
|
||||
return false if response.documentable.blank?
|
||||
return false unless response.documentable.try(:external_link)
|
||||
|
||||
# Don't show source if it's a PDF placeholder
|
||||
external_link = response.documentable.external_link
|
||||
!external_link.start_with?('PDF:')
|
||||
searches << Captain::DocumentationSearchService.serialize(result)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,19 +21,4 @@ RSpec.describe RoomChannel do
|
||||
expect(subscription).to have_stream_for(user.pubsub_token)
|
||||
expect(subscription).to have_stream_for("account_#{account.id}")
|
||||
end
|
||||
|
||||
it 'transmits the account cache keys to user subscribers' do
|
||||
subscribe(user_id: user.id, pubsub_token: user.pubsub_token, account_id: account.id)
|
||||
|
||||
cache_event = transmissions.find { |message| message['event'] == 'account.cache_invalidated' }
|
||||
expect(cache_event['data']['account_id']).to eq(account.id)
|
||||
expect(cache_event['data']['cache_keys'].keys).to match_array(%w[label inbox team canned_response account_user custom_attribute_definition])
|
||||
end
|
||||
|
||||
it 'does not transmit cache keys to contact subscribers' do
|
||||
subscribe(pubsub_token: contact_inbox.pubsub_token)
|
||||
|
||||
cache_event = transmissions.find { |message| message['event'] == 'account.cache_invalidated' }
|
||||
expect(cache_event).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -213,18 +213,17 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['cache_keys'].keys).to match_array(%w[account_user canned_response custom_attribute_definition inbox label team])
|
||||
expect(response.parsed_body['cache_keys'].keys).to match_array(%w[label inbox team])
|
||||
end
|
||||
|
||||
it 'does not allow cached cache key responses' do
|
||||
it 'sets the appropriate cache headers' do
|
||||
get "/api/v1/accounts/#{account.id}/cache_keys",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response.headers['Cache-Control']).to include('max-age=0')
|
||||
expect(response.headers['Cache-Control']).to include('max-age=10')
|
||||
expect(response.headers['Cache-Control']).to include('private')
|
||||
expect(response.headers['Cache-Control']).to include('must-revalidate')
|
||||
expect(response.headers['Cache-Control']).not_to include('stale-while-revalidate')
|
||||
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'shows the list of accounts' do
|
||||
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team, :canned_response, :account_user, :custom_attribute_definition)
|
||||
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team)
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
now_timestamp = Time.now.utc.to_i
|
||||
|
||||
@@ -216,6 +216,16 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:config][:feature_citation]).to be(false)
|
||||
end
|
||||
|
||||
it 'updates documentation sufficiency gate config' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: { assistant: { config: { documentation_sufficiency_gate_enabled: true } } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:config][:documentation_sufficiency_gate_enabled]).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,13 +11,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
|
||||
let(:mock_documentation_sufficiency_service) { instance_double(Captain::Llm::DocumentationSufficiencyService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
|
||||
allow(inbox).to receive(:captain_active?).and_return(true)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do |**_args, &block|
|
||||
response = { 'response' => 'Hey, welcome to Captain Specs' }
|
||||
block&.call(response)
|
||||
response
|
||||
end
|
||||
allow(mock_llm_chat_service).to receive(:generate_documentation_gap_response).and_return(
|
||||
{ 'response' => 'I do not have enough information in the documentation. Would you like me to connect you with support?' }
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([])
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||
@@ -152,6 +161,116 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when documentation sufficiency gate is enabled' do
|
||||
let(:empty_search_result) do
|
||||
{
|
||||
query: 'current plan limits',
|
||||
queries: ['current plan limits'],
|
||||
matches: []
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
assistant.update!(config: { 'documentation_sufficiency_gate_enabled' => true })
|
||||
allow(Captain::Llm::DocumentationSufficiencyService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
conversation: conversation
|
||||
).and_return(mock_documentation_sufficiency_service)
|
||||
end
|
||||
|
||||
it 'regenerates an unsupported answer with a documentation-gap instruction' do
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([empty_search_result])
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do |**_args, &block|
|
||||
response = { 'response' => 'Your current plan has unlimited usage.' }
|
||||
block&.call(response)
|
||||
response
|
||||
end
|
||||
expect(mock_llm_chat_service).to receive(:generate_documentation_gap_response).with(
|
||||
message_history: [{ content: 'Hello', role: 'user' }]
|
||||
).and_return(
|
||||
{ 'response' => 'I do not have enough information in the documentation. Would you like me to connect you with support?' }
|
||||
)
|
||||
allow(mock_documentation_sufficiency_service).to receive(:evaluate).and_return(
|
||||
{
|
||||
'decision' => 'insufficient',
|
||||
'model' => 'gpt-4.1'
|
||||
}
|
||||
)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.content).to eq(
|
||||
'I do not have enough information in the documentation. Would you like me to connect you with support?'
|
||||
)
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
it 'keeps the assistant response when the gate finds enough support' do
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([empty_search_result])
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do |**_args, &block|
|
||||
response = { 'response' => 'Billing settings show current plan usage.' }
|
||||
block&.call(response)
|
||||
response
|
||||
end
|
||||
allow(mock_documentation_sufficiency_service).to receive(:evaluate).and_return(
|
||||
{ 'decision' => 'sufficient', 'model' => 'gpt-4.1' }
|
||||
)
|
||||
expect(mock_llm_chat_service).not_to receive(:generate_documentation_gap_response)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Billing settings show current plan usage.')
|
||||
end
|
||||
|
||||
it 'does not run a second repair when documentation support was already checked inside the tool' do
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return(
|
||||
[empty_search_result.merge(documentation_sufficiency: { 'decision' => 'insufficient', 'model' => 'gpt-5.4-mini' })]
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do |**_args, &block|
|
||||
response = { 'response' => 'I do not have that information in the documentation. Would you like a handoff?' }
|
||||
block&.call(response)
|
||||
response
|
||||
end
|
||||
expect(mock_documentation_sufficiency_service).not_to receive(:evaluate)
|
||||
expect(mock_llm_chat_service).not_to receive(:generate_documentation_gap_response)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.content).to eq(
|
||||
'I do not have that information in the documentation. Would you like a handoff?'
|
||||
)
|
||||
end
|
||||
|
||||
it 'checks answers even when no documentation search was recorded' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do |**_args, &block|
|
||||
response = { 'response' => 'Your current plan costs $99 per agent per month.' }
|
||||
block&.call(response)
|
||||
response
|
||||
end
|
||||
expect(mock_documentation_sufficiency_service).to receive(:evaluate).with(
|
||||
message_history: [{ content: 'Hello', role: 'user' }],
|
||||
documentation_searches: [
|
||||
{
|
||||
query: 'Hello',
|
||||
queries: ['Hello'],
|
||||
matches: []
|
||||
}
|
||||
]
|
||||
).and_return(
|
||||
{
|
||||
'decision' => 'insufficient',
|
||||
'model' => 'gpt-4.1'
|
||||
}
|
||||
)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.content).to eq(
|
||||
'I do not have enough information in the documentation. Would you like me to connect you with support?'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send a response when the conversation is no longer pending' do
|
||||
conversation.open!
|
||||
|
||||
@@ -366,6 +485,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
|
||||
allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([])
|
||||
end
|
||||
|
||||
context 'when ActiveStorage::FileNotFoundError occurs' do
|
||||
@@ -478,6 +598,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([])
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
|
||||
@@ -4,16 +4,14 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
let(:documentation_searches) { [] }
|
||||
let(:tool_context) { Struct.new(:state).new({ documentation_searches: documentation_searches }) }
|
||||
let(:documentation_search_service) { instance_double(Captain::DocumentationSearchService) }
|
||||
|
||||
before do
|
||||
# Create installation config for OpenAI API key to avoid errors
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
|
||||
# Mock embedding service to avoid actual API calls
|
||||
embedding_service = instance_double(Captain::Llm::EmbeddingService)
|
||||
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1))
|
||||
allow(Captain::DocumentationSearchService).to receive(:new)
|
||||
.with(scope: anything, account_id: assistant.account_id)
|
||||
.and_return(documentation_search_service)
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
@@ -32,11 +30,24 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
def search_match(response)
|
||||
Captain::AssistantResponse::SearchMatch.new(response: response, semantic_distance: 0.2)
|
||||
end
|
||||
|
||||
def search_result(query:, matches:)
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: matches
|
||||
}
|
||||
end
|
||||
|
||||
context 'when FAQs exist' do
|
||||
let(:document) { create(:captain_document, assistant: assistant) }
|
||||
let!(:response1) do
|
||||
create(:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
question: 'How to reset password?',
|
||||
answer: 'Click on forgot password link',
|
||||
documentable: document,
|
||||
@@ -45,15 +56,16 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
let!(:response2) do
|
||||
create(:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
question: 'How to change email?',
|
||||
answer: 'Go to settings and update email',
|
||||
status: 'approved')
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock nearest_neighbors to return our test responses
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(
|
||||
Captain::AssistantResponse.where(id: [response1.id, response2.id])
|
||||
matches = [response1, response2].map { |response| search_match(response) }
|
||||
allow(documentation_search_service).to receive(:search).and_return(
|
||||
search_result(query: 'password reset', matches: matches)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -64,6 +76,7 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
expect(result).to include('Answer: Click on forgot password link')
|
||||
expect(result).to include('Question: How to change email?')
|
||||
expect(result).to include('Answer: Go to settings and update email')
|
||||
expect(documentation_searches.first[:matches].first[:question]).to eq('How to reset password?')
|
||||
end
|
||||
|
||||
it 'includes source link when document has external_link' do
|
||||
@@ -76,7 +89,10 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
it 'logs tool usage for search' do
|
||||
expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' })
|
||||
expect(tool).to receive(:log_tool_usage).with('found_results', { query: 'password reset', count: 2 })
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'completed',
|
||||
{ query: 'password reset', count: 2 }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
end
|
||||
@@ -84,13 +100,15 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
context 'when no FAQs found' do
|
||||
before do
|
||||
# Return empty result set
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none)
|
||||
allow(documentation_search_service).to receive(:search).and_return(
|
||||
search_result(query: 'nonexistent topic', matches: [])
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns no results message' do
|
||||
result = tool.perform(tool_context, query: 'nonexistent topic')
|
||||
expect(result).to eq('No relevant FAQs found for: nonexistent topic')
|
||||
expect(result).to include('No relevant FAQs found for: nonexistent topic')
|
||||
expect(result).to include('No documentation matched this query')
|
||||
end
|
||||
|
||||
it 'logs tool usage for no results' do
|
||||
@@ -103,11 +121,12 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
context 'with blank query' do
|
||||
it 'handles empty query' do
|
||||
# Return empty result set
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none)
|
||||
allow(documentation_search_service).to receive(:search).and_return(
|
||||
search_result(query: '', matches: [])
|
||||
)
|
||||
|
||||
result = tool.perform(tool_context, query: '')
|
||||
expect(result).to eq('No relevant FAQs found for: ')
|
||||
expect(result).to include('No relevant FAQs found for: ')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,19 +9,4 @@ RSpec.describe CustomRole, type: :model do
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
end
|
||||
|
||||
describe 'account_user cache invalidation' do
|
||||
let(:custom_role) { create(:custom_role) }
|
||||
|
||||
it 'bumps the account_user cache key after update' do
|
||||
expect(custom_role.account).to receive(:update_cache_key).with('account_user')
|
||||
custom_role.update(name: 'New Name')
|
||||
end
|
||||
|
||||
it 'bumps the account_user cache key after destroy' do
|
||||
custom_role
|
||||
expect(custom_role.account).to receive(:update_cache_key).with('account_user')
|
||||
custom_role.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -167,7 +167,17 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
|
||||
expect(result).to include('response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false)
|
||||
end
|
||||
|
||||
it 'includes documentation searches from runner state' do
|
||||
searches = [{ query: 'billing', matches: [] }]
|
||||
runner_context = { state: { documentation_searches: searches } }
|
||||
allow(mock_result).to receive(:context).and_return(runner_context)
|
||||
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result['documentation_searches']).to eq(searches)
|
||||
end
|
||||
|
||||
context 'when handoff tool was called during agent execution' do
|
||||
@@ -179,11 +189,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'includes handoff_tool_called flag in response' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'Let me connect you',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
expect(result).to include('response' => 'Let me connect you', 'agent_name' => nil, 'handoff_tool_called' => true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -208,12 +214,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'formats string response correctly' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
expect(result).to include(
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::DocumentationSearchService do
|
||||
let(:scope_class) do
|
||||
Class.new do
|
||||
def search_with_metadata(*)
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
let(:scope) { scope_class.new }
|
||||
let(:service) { described_class.new(scope: scope, account_id: 1) }
|
||||
let(:response) do
|
||||
instance_double(
|
||||
Captain::AssistantResponse,
|
||||
id: 1,
|
||||
question: 'How do plan limits work?',
|
||||
answer: 'Monthly limits are shown in billing settings.',
|
||||
documentable: nil
|
||||
)
|
||||
end
|
||||
|
||||
def search_match(semantic_distance:, response_record: response)
|
||||
Captain::AssistantResponse::SearchMatch.new(
|
||||
response: response_record,
|
||||
semantic_distance: semantic_distance
|
||||
)
|
||||
end
|
||||
|
||||
def search_result(matches:, query: 'billing')
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: matches
|
||||
}
|
||||
end
|
||||
|
||||
describe '#search' do
|
||||
it 'returns semantic matches without assigning retrieval quality' do
|
||||
query = 'How do I check limits for my current monthly plan?'
|
||||
match = search_match(semantic_distance: 0.9)
|
||||
|
||||
allow(scope).to receive(:search_with_metadata).with(query, account_id: 1).and_return([match])
|
||||
|
||||
result = service.search(query)
|
||||
|
||||
expect(result[:matches]).to eq([match])
|
||||
expect(result[:queries]).to eq([query])
|
||||
end
|
||||
|
||||
it 'returns an empty match list when no documentation matches' do
|
||||
query = 'Where do I find billing settings?'
|
||||
|
||||
allow(scope).to receive(:search_with_metadata).with(query, account_id: 1).and_return([])
|
||||
|
||||
result = service.search(query)
|
||||
|
||||
expect(result[:matches]).to eq([])
|
||||
expect(result[:queries]).to eq([query])
|
||||
end
|
||||
end
|
||||
|
||||
describe '.format_for_tool' do
|
||||
it 'adds a bounded-answer instruction when no documentation is found' do
|
||||
result = search_result(query: 'unknown topic', matches: [])
|
||||
|
||||
formatted_result = described_class.format_for_tool(result, no_results_message: 'No FAQs found')
|
||||
|
||||
expect(formatted_result).to include('No FAQs found')
|
||||
expect(formatted_result).to include('No documentation matched this query')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.serialize' do
|
||||
it 'formats search metadata for the response-level support gate' do
|
||||
result = search_result(matches: [search_match(semantic_distance: 0.2)])
|
||||
|
||||
serialized_result = described_class.serialize(result)
|
||||
|
||||
expect(serialized_result[:matches].first[:semantic_distance]).to eq(0.2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.metadata' do
|
||||
it 'returns compact search metadata' do
|
||||
result = search_result(matches: [search_match(semantic_distance: 0.2)])
|
||||
|
||||
expect(described_class.metadata(result)).to eq(
|
||||
{
|
||||
match_count: 1,
|
||||
top_semantic_distance: 0.2
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,43 @@ RSpec.describe Captain::Llm::AssistantChatService do
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
|
||||
end
|
||||
|
||||
it 'runs the response hook before the agent session span closes' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
hook_calls = []
|
||||
|
||||
expect(service).to receive(:instrument_agent_session).and_wrap_original do |original, params, &block|
|
||||
original.call(params) do
|
||||
result = block.call
|
||||
hook_calls << :after_hook
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }]) do |response|
|
||||
hook_calls << :hook
|
||||
response
|
||||
end
|
||||
|
||||
expect(hook_calls).to eq(%i[hook after_hook])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_documentation_gap_response' do
|
||||
it 'generates a constrained response without calling tools' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
|
||||
expect(mock_chat).not_to receive(:with_tool)
|
||||
expect(mock_chat).to receive(:with_instructions).with(a_string_including('[Documentation Support]')).and_return(mock_chat)
|
||||
expect(mock_chat).to receive(:ask).with('Do your documents auto refresh?').and_return(mock_response)
|
||||
|
||||
response = service.generate_documentation_gap_response(
|
||||
message_history: [{ role: 'user', content: 'Do your documents auto refresh?' }]
|
||||
)
|
||||
|
||||
expect(response['response']).to eq('I can see the image shows a pricing table')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'image analysis' do
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::DocumentationSufficiencyService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'decision' => 'sufficient' }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#evaluate' do
|
||||
it 'uses the documentation support model instead of the global Captain model' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-5.5')
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-5.4-mini').and_return(mock_chat)
|
||||
|
||||
result = service.evaluate(
|
||||
message_history: [{ role: 'user', content: 'Who is your mascot?' }],
|
||||
documentation_searches: [
|
||||
{
|
||||
query: 'mascot',
|
||||
matches: [{ question: 'Who is the brand mascot?', answer: 'Robin the bird.' }]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(result).to include('decision' => 'sufficient', 'model' => 'gpt-5.4-mini')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,10 +26,14 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
let(:documentation_search_service) { instance_double(Captain::DocumentationSearchService) }
|
||||
let(:documentation_sufficiency_service) { instance_double(Captain::Llm::DocumentationSufficiencyService) }
|
||||
let(:translate_query_service) { instance_double(Captain::Llm::TranslateQueryService) }
|
||||
let!(:response) do
|
||||
create(
|
||||
:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
question: question,
|
||||
answer: answer,
|
||||
status: 'approved'
|
||||
@@ -37,29 +41,99 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
|
||||
end
|
||||
|
||||
let(:documentable) { create(:captain_document, external_link: external_link) }
|
||||
let(:recorded_searches) { [] }
|
||||
let(:service) { described_class.new(assistant, on_search: ->(search) { recorded_searches << search }) }
|
||||
let(:match) do
|
||||
Captain::AssistantResponse::SearchMatch.new(
|
||||
response: response,
|
||||
semantic_distance: 0.2
|
||||
)
|
||||
end
|
||||
|
||||
def search_result(matches:)
|
||||
{
|
||||
query: question,
|
||||
queries: [question],
|
||||
matches: matches
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::TranslateQueryService).to receive(:new).and_return(translate_query_service)
|
||||
allow(translate_query_service).to receive(:translate).and_return(question)
|
||||
allow(Captain::DocumentationSearchService).to receive(:new)
|
||||
.with(scope: anything, account_id: assistant.account_id)
|
||||
.and_return(documentation_search_service)
|
||||
end
|
||||
|
||||
context 'when matching responses exist' do
|
||||
before do
|
||||
response.update(documentable: documentable)
|
||||
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([response])
|
||||
end
|
||||
|
||||
it 'returns formatted responses for the search query' do
|
||||
response.update(documentable: documentable)
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(
|
||||
search_result(matches: [match])
|
||||
)
|
||||
|
||||
result = service.execute(query: question)
|
||||
|
||||
expect(result).to include(question)
|
||||
expect(result).to include(answer)
|
||||
expect(result).to include(external_link)
|
||||
expect(recorded_searches.first[:matches].first[:question]).to eq(question)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no matching responses exist' do
|
||||
before do
|
||||
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([])
|
||||
it 'returns a bounded no-results instruction' do
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(
|
||||
search_result(matches: [])
|
||||
)
|
||||
|
||||
result = service.execute(query: question)
|
||||
|
||||
expect(result).to include('No documentation found for the given query')
|
||||
expect(result).to include('No documentation matched this query')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when documentation sufficiency gate is enabled' do
|
||||
let(:conversation) { create(:conversation, account: assistant.account) }
|
||||
let(:message_history) { [{ role: 'user', content: 'Who is your mascot?' }] }
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
assistant,
|
||||
on_search: ->(search) { recorded_searches << search },
|
||||
message_history: -> { message_history },
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns an empty string' do
|
||||
expect(service.execute(query: question)).to eq('No FAQs found for the given query')
|
||||
before do
|
||||
assistant.update!(config: assistant.config.merge('documentation_sufficiency_gate_enabled' => true))
|
||||
allow(Captain::Llm::DocumentationSufficiencyService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
conversation: conversation
|
||||
).and_return(documentation_sufficiency_service)
|
||||
end
|
||||
|
||||
it 'returns insufficient documentation support to the final assistant generation' do
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(
|
||||
search_result(matches: [match])
|
||||
)
|
||||
allow(documentation_sufficiency_service).to receive(:evaluate).with(
|
||||
message_history: message_history,
|
||||
documentation_searches: [
|
||||
hash_including(
|
||||
query: question,
|
||||
matches: [hash_including(question: question, answer: answer)]
|
||||
)
|
||||
]
|
||||
).and_return({ 'decision' => 'insufficient', 'model' => 'gpt-5.4-mini' })
|
||||
|
||||
result = service.execute(query: question)
|
||||
|
||||
expect(result).to include('Documentation support: insufficient')
|
||||
expect(result).to include('Do not answer the factual question from these results')
|
||||
expect(recorded_searches.first[:documentation_sufficiency]).to include('decision' => 'insufficient')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -150,22 +150,4 @@ RSpec.describe Portal do
|
||||
expect(portal.display_title).to eq('Help Center | Acme')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'inbox cache invalidation' do
|
||||
# Portal name/slug are embedded as help_center into the cached inbox
|
||||
# payload (api/v1/models/_inbox.json.jbuilder), so portal changes must bump
|
||||
# the account's inbox cache key.
|
||||
let(:account) { create(:account) }
|
||||
let!(:portal) { create(:portal, account: account) }
|
||||
|
||||
it 'bumps the inbox cache key after update' do
|
||||
expect(account).to receive(:update_cache_key).with('inbox')
|
||||
portal.update!(name: 'Renamed Portal')
|
||||
end
|
||||
|
||||
it 'bumps the inbox cache key after destroy' do
|
||||
expect(account).to receive(:update_cache_key).with('inbox')
|
||||
portal.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,20 +5,4 @@ RSpec.describe TeamMember do
|
||||
it { is_expected.to belong_to(:team) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'team cache invalidation' do
|
||||
let(:team) { create(:team) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'bumps the team cache key after create' do
|
||||
expect(team.account).to receive(:update_cache_key).with('team')
|
||||
create(:team_member, team: team, user: user)
|
||||
end
|
||||
|
||||
it 'bumps the team cache key after destroy' do
|
||||
team_member = create(:team_member, team: team, user: user)
|
||||
expect(team.account).to receive(:update_cache_key).with('team')
|
||||
team_member.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user