feat: Add support for the references in FAQs (#10699)

Currently, it’s unclear whether an FAQ item is generated from a
document, derived from a conversation, or added manually.

This PR resolves the issue by providing visibility into the source of
each FAQ. Users can now see whether an FAQ was generated or manually
added and, if applicable, by whom.

- Move the document_id to a polymorphic relation (documentable).
- Updated the APIs to accommodate the change.
- Update the service to add corresponding references. 
- Updated the specs.

<img width="1007" alt="Screenshot 2025-01-15 at 11 27 56 PM"
src="https://github.com/user-attachments/assets/7d58f798-19c0-4407-b3e2-748a919d14af"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2025-01-16 01:57:30 -08:00
committed by GitHub
parent 88f3b4de48
commit 0b4028b95d
17 changed files with 197 additions and 61 deletions

View File

@@ -42,7 +42,7 @@ const handlePageChange = event => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -67,7 +67,7 @@ const handlePageChange = event => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
</div>

View File

@@ -29,6 +29,10 @@ const props = defineProps({
type: String,
default: 'approved',
},
documentable: {
type: Object,
default: null,
},
assistant: {
type: Object,
default: () => ({}),
@@ -43,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['action']);
const emit = defineEmits(['action', 'navigate']);
const { t } = useI18n();
@@ -87,6 +91,13 @@ const handleAssistantAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleDocumentableClick = () => {
emit('navigate', {
id: props.documentable.id,
type: props.documentable.type,
});
};
</script>
<template>
@@ -119,22 +130,66 @@ const handleAssistantAction = ({ action, value }) => {
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<span v-if="!compact">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
<div class="inline-flex items-center">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
>
<span
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
{{ documentable.name }}
</span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span
class="max-w-96 truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="inline-flex items-center gap-1 group cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</span>
<span v-else />
</div>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
@@ -142,6 +197,6 @@ const handleAssistantAction = ({ action, value }) => {
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</span>
</div>
</CardLayout>
</template>

View File

@@ -409,8 +409,11 @@
}
},
"RESPONSES": {
"HEADER": "Generated FAQs",
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE" : {
"CONVERSATION": "Conversation #{id}"
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",

View File

@@ -4,9 +4,10 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
@@ -15,6 +16,7 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
@@ -100,6 +102,15 @@ const handleAction = ({ action, id }) => {
});
};
const handleNavigationAction = ({ id, type }) => {
if (type === 'Conversation') {
router.push({
name: 'inbox_conversation',
params: { conversation_id: id },
});
}
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
@@ -184,10 +195,12 @@ onMounted(() => {
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
@action="handleAction"
@navigate="handleNavigationAction"
/>
</div>

View File

@@ -0,0 +1,33 @@
class ConvertDocumentToPolymorphicAssociation < ActiveRecord::Migration[7.0]
def up
add_column :captain_assistant_responses, :documentable_type, :string
# rubocop:disable Rails/SkipsModelValidations
Captain::AssistantResponse
.where
.not(document_id: nil)
.update_all(documentable_type: 'Captain::Document')
# rubocop:enable Rails/SkipsModelValidations
remove_index :captain_assistant_responses, :document_id if index_exists?(
:captain_assistant_responses, :document_id
)
rename_column :captain_assistant_responses, :document_id, :documentable_id
add_index :captain_assistant_responses, [:documentable_id, :documentable_type],
name: 'idx_cap_asst_resp_on_documentable'
end
def down
if index_exists?(
:captain_assistant_responses, [:documentable_id, :documentable_type], name: 'idx_cap_asst_resp_on_documentable'
)
remove_index :captain_assistant_responses, name: 'idx_cap_asst_resp_on_documentable'
end
rename_column :captain_assistant_responses, :documentable_id, :document_id
remove_column :captain_assistant_responses, :documentable_type
add_index :captain_assistant_responses, :document_id unless index_exists?(
:captain_assistant_responses, :document_id
)
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_01_16_000103) do
ActiveRecord::Schema[7.0].define(version: 2025_01_16_061033) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -250,14 +250,15 @@ ActiveRecord::Schema[7.0].define(version: 2025_01_16_000103) do
t.text "answer", null: false
t.vector "embedding", limit: 1536
t.bigint "assistant_id", null: false
t.bigint "document_id"
t.bigint "documentable_id"
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 1, null: false
t.string "documentable_type"
t.index ["account_id"], name: "index_captain_assistant_responses_on_account_id"
t.index ["assistant_id"], name: "index_captain_assistant_responses_on_assistant_id"
t.index ["document_id"], name: "index_captain_assistant_responses_on_document_id"
t.index ["documentable_id", "documentable_type"], name: "idx_cap_asst_resp_on_documentable"
t.index ["embedding"], name: "vector_idx_knowledge_entries_embedding", using: :ivfflat
t.index ["status"], name: "index_captain_assistant_responses_on_status"
end

View File

@@ -12,7 +12,14 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
def index
base_query = @responses
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
base_query = base_query.where(document_id: permitted_params[:document_id]) if permitted_params[:document_id].present?
if permitted_params[:document_id].present?
base_query = base_query.where(
documentable_id: permitted_params[:document_id],
documentable_type: 'Captain::Document'
)
end
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
@responses_count = base_query.count
@@ -24,6 +31,7 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
def create
@response = Current.account.captain_assistant_responses.new(response_params)
@response.documentable = Current.user
@response.save!
end
@@ -43,7 +51,7 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
end
def set_responses
@responses = Current.account.captain_assistant_responses.includes(:assistant, :document).ordered
@responses = Current.account.captain_assistant_responses.includes(:assistant, :documentable).ordered
end
def set_response
@@ -62,7 +70,6 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
params.require(:assistant_response).permit(
:question,
:answer,
:document_id,
:assistant_id,
:status
)

View File

@@ -21,7 +21,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
question: faq['question'],
answer: faq['answer'],
assistant: document.assistant,
document: document
documentable: document
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Error in creating response document: #{e.message}"

View File

@@ -2,22 +2,23 @@
#
# Table name: captain_assistant_responses
#
# id :bigint not null, primary key
# answer :text not null
# embedding :vector(1536)
# question :string not null
# status :integer default("approved"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# document_id :bigint
# id :bigint not null, primary key
# answer :text not null
# documentable_type :string
# embedding :vector(1536)
# question :string not null
# status :integer default("approved"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# documentable_id :bigint
#
# Indexes
#
# idx_cap_asst_resp_on_documentable (documentable_id,documentable_type)
# index_captain_assistant_responses_on_account_id (account_id)
# index_captain_assistant_responses_on_assistant_id (assistant_id)
# index_captain_assistant_responses_on_document_id (document_id)
# index_captain_assistant_responses_on_status (status)
# vector_idx_knowledge_entries_embedding (embedding) USING ivfflat
#
@@ -26,7 +27,7 @@ class Captain::AssistantResponse < ApplicationRecord
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :account
belongs_to :document, optional: true, class_name: 'Captain::Document'
belongs_to :documentable, polymorphic: true, optional: true
has_neighbors :embedding, normalize: true
validates :question, presence: true

View File

@@ -23,7 +23,7 @@ class Captain::Document < ApplicationRecord
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
validates :external_link, presence: true

View File

@@ -5,6 +5,7 @@ module Enterprise::Concerns::Conversation
belongs_to :sla_policy, optional: true
has_one :applied_sla, dependent: :destroy_async
has_many :sla_events, dependent: :destroy_async
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
end

View File

@@ -3,6 +3,8 @@ module Enterprise::Concerns::User
included do
before_validation :ensure_installation_pricing_plan_quantity, on: :create
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
end
def ensure_installation_pricing_plan_quantity

View File

@@ -4,6 +4,7 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
def initialize(assistant, conversation, model = DEFAULT_MODEL)
super()
@assistant = assistant
@conversation = conversation
@content = conversation.to_llm_text
@model = model
end
@@ -19,7 +20,7 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
private
attr_reader :content
attr_reader :content, :conversation, :assistant
def find_and_separate_duplicates(faqs)
duplicate_faqs = []
@@ -41,7 +42,7 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
end
def find_similar_faqs(embedding)
similar_faqs = @assistant
similar_faqs = assistant
.responses
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
Rails.logger.debug(similar_faqs.map { |faq| [faq.question, faq.neighbor_distance] })
@@ -50,7 +51,12 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
def save_new_faqs(faqs)
faqs.map do |faq|
@assistant.responses.create!(question: faq['question'], answer: faq['answer'], status: 'pending')
assistant.responses.create!(
question: faq['question'],
answer: faq['answer'],
status: 'pending',
documentable: conversation
)
end
end

View File

@@ -4,13 +4,27 @@ json.assistant do
json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant
end
json.created_at resource.created_at.to_i
if resource.document
json.document do
json.id resource.document.id
json.external_link resource.document.external_link
json.name resource.document.name
if resource.documentable
json.documentable do
json.type resource.documentable_type
case resource.documentable_type
when 'Captain::Document'
json.id resource.documentable.id
json.external_link resource.documentable.external_link
json.name resource.documentable.name
when 'Conversation'
json.id resource.documentable.display_id
json.display_id resource.documentable.display_id
when 'User'
json.id resource.documentable.id
json.email resource.documentable.email
json.available_name resource.documentable.available_name
end
end
end
json.id resource.id
json.question resource.question
json.updated_at resource.updated_at.to_i

View File

@@ -19,7 +19,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
create_list(:captain_assistant_response, 30,
account: account,
assistant: assistant,
document: document)
documentable: document)
end
it 'returns first page of responses with default pagination' do
@@ -48,11 +48,11 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
create_list(:captain_assistant_response, 3,
account: account,
assistant: assistant,
document: document)
documentable: document)
create_list(:captain_assistant_response, 2,
account: account,
assistant: another_assistant,
document: document)
documentable: document)
end
it 'returns only responses for the specified assistant' do
@@ -72,11 +72,11 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
create_list(:captain_assistant_response, 3,
account: account,
assistant: assistant,
document: document)
documentable: document)
create_list(:captain_assistant_response, 2,
account: account,
assistant: assistant,
document: another_document)
documentable: another_document)
end
it 'returns only responses for the specified document' do
@@ -87,7 +87,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(3)
expect(json_response[:payload][0][:document][:id]).to eq(document.id)
expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
end
end
end

View File

@@ -21,7 +21,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
describe '#perform' do
context 'when processing a document' do
it 'deletes previous responses' do
existing_response = create(:captain_assistant_response, document: document)
existing_response = create(:captain_assistant_response, documentable: document)
described_class.new.perform(document)
@@ -40,7 +40,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
expect(first_response.question).to eq('What is Ruby?')
expect(first_response.answer).to eq('A programming language')
expect(first_response.assistant).to eq(assistant)
expect(first_response.document).to eq(document)
expect(first_response.documentable).to eq(document)
end
end
end

View File

@@ -49,10 +49,10 @@ RSpec.describe Captain::Llm::ConversationFaqService do
it 'saves the correct FAQ content' do
service.generate_and_deduplicate
expect(
captain_assistant.responses.pluck(:question, :answer, :status)
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
).to contain_exactly(
['What is the purpose?', 'To help users.', 'pending'],
['How does it work?', 'Through AI.', 'pending']
['What is the purpose?', 'To help users.', 'pending', conversation.id],
['How does it work?', 'Through AI.', 'pending', conversation.id]
)
end
end