UX: Display the indexing progress for RAG uploads (#557)
This commit is contained in:
parent
35fbf5c836
commit
aa8918911d
|
@ -5,7 +5,8 @@ module DiscourseAi
|
||||||
class AiPersonasController < ::Admin::AdminController
|
class AiPersonasController < ::Admin::AdminController
|
||||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
before_action :find_ai_persona, only: %i[show update destroy create_user]
|
before_action :find_ai_persona,
|
||||||
|
only: %i[show update destroy create_user indexing_status_check]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
ai_personas =
|
ai_personas =
|
||||||
|
@ -90,6 +91,10 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexing_status_check
|
||||||
|
render json: RagDocumentFragment.indexing_status(@ai_persona, @ai_persona.uploads)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def find_ai_persona
|
def find_ai_persona
|
||||||
|
|
|
@ -41,6 +41,11 @@ module ::Jobs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RagDocumentFragment.publish_status(
|
||||||
|
upload,
|
||||||
|
{ total: fragment_ids.size, indexed: 0, left: fragment_ids.size },
|
||||||
|
)
|
||||||
|
|
||||||
fragment_ids.each_slice(50) do |slice|
|
fragment_ids.each_slice(50) do |slice|
|
||||||
Jobs.enqueue(:generate_rag_embeddings, fragment_ids: slice)
|
Jobs.enqueue(:generate_rag_embeddings, fragment_ids: slice)
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,6 +14,13 @@ module ::Jobs
|
||||||
# generate_representation_from checks compares the digest value to make sure
|
# generate_representation_from checks compares the digest value to make sure
|
||||||
# the embedding is only generated once per fragment unless something changes.
|
# the embedding is only generated once per fragment unless something changes.
|
||||||
fragments.map { |fragment| vector_rep.generate_representation_from(fragment) }
|
fragments.map { |fragment| vector_rep.generate_representation_from(fragment) }
|
||||||
|
|
||||||
|
last_fragment = fragments.last
|
||||||
|
ai_persona = last_fragment.ai_persona
|
||||||
|
upload = last_fragment.upload
|
||||||
|
|
||||||
|
indexing_status = RagDocumentFragment.indexing_status(ai_persona, [upload])[upload.id]
|
||||||
|
RagDocumentFragment.publish_status(upload, indexing_status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -29,6 +29,40 @@ class RagDocumentFragment < ActiveRecord::Base
|
||||||
link_persona_and_uploads(persona, upload_ids)
|
link_persona_and_uploads(persona, upload_ids)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexing_status(persona, uploads)
|
||||||
|
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
|
||||||
|
vector_rep =
|
||||||
|
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
||||||
|
|
||||||
|
embeddings_table = vector_rep.rag_fragments_table_name
|
||||||
|
|
||||||
|
results = DB.query(<<~SQL, persona_id: persona.id, upload_ids: uploads.map(&:id))
|
||||||
|
SELECT
|
||||||
|
uploads.id,
|
||||||
|
SUM(CASE WHEN (rdf.upload_id IS NOT NULL) THEN 1 ELSE 0 END) AS total,
|
||||||
|
SUM(CASE WHEN (eft.rag_document_fragment_id IS NOT NULL) THEN 1 ELSE 0 END) as indexed,
|
||||||
|
SUM(CASE WHEN (rdf.upload_id IS NOT NULL AND eft.rag_document_fragment_id IS NULL) THEN 1 ELSE 0 END) as left
|
||||||
|
FROM uploads
|
||||||
|
LEFT OUTER JOIN rag_document_fragments rdf ON uploads.id = rdf.upload_id AND rdf.ai_persona_id = :persona_id
|
||||||
|
LEFT OUTER JOIN #{embeddings_table} eft ON rdf.id = eft.rag_document_fragment_id
|
||||||
|
WHERE uploads.id IN (:upload_ids)
|
||||||
|
GROUP BY uploads.id
|
||||||
|
SQL
|
||||||
|
|
||||||
|
results.reduce({}) do |acc, r|
|
||||||
|
acc[r.id] = { total: r.total, indexed: r.indexed, left: r.left }
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish_status(upload, status)
|
||||||
|
MessageBus.publish(
|
||||||
|
"/discourse-ai/ai-persona-rag/#{upload.id}",
|
||||||
|
status,
|
||||||
|
user_ids: [upload.user_id],
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { ajax } from "discourse/lib/ajax";
|
||||||
import RestModel from "discourse/models/rest";
|
import RestModel from "discourse/models/rest";
|
||||||
|
|
||||||
const ATTRIBUTES = [
|
const ATTRIBUTES = [
|
||||||
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
"commands",
|
"commands",
|
||||||
|
@ -24,6 +25,7 @@ const ATTRIBUTES = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const SYSTEM_ATTRIBUTES = [
|
const SYSTEM_ATTRIBUTES = [
|
||||||
|
"id",
|
||||||
"allowed_group_ids",
|
"allowed_group_ids",
|
||||||
"enabled",
|
"enabled",
|
||||||
"system",
|
"system",
|
||||||
|
|
|
@ -37,6 +37,7 @@ export default class PersonaEditor extends Component {
|
||||||
@tracked editingModel = null;
|
@tracked editingModel = null;
|
||||||
@tracked showDelete = false;
|
@tracked showDelete = false;
|
||||||
@tracked maxPixelsValue = null;
|
@tracked maxPixelsValue = null;
|
||||||
|
@tracked ragIndexingStatuses = null;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateModel() {
|
updateModel() {
|
||||||
|
@ -84,7 +85,7 @@ export default class PersonaEditor extends Component {
|
||||||
try {
|
try {
|
||||||
await this.args.model.save();
|
await this.args.model.save();
|
||||||
this.#sortPersonas();
|
this.#sortPersonas();
|
||||||
if (isNew) {
|
if (isNew && this.args.model.rag_uploads.length === 0) {
|
||||||
this.args.personas.addObject(this.args.model);
|
this.args.personas.addObject(this.args.model);
|
||||||
this.router.transitionTo(
|
this.router.transitionTo(
|
||||||
"adminPlugins.show.discourse-ai.ai-personas.show",
|
"adminPlugins.show.discourse-ai.ai-personas.show",
|
||||||
|
@ -442,6 +443,7 @@ export default class PersonaEditor extends Component {
|
||||||
{{#if this.siteSettings.ai_embeddings_enabled}}
|
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<PersonaRagUploader
|
<PersonaRagUploader
|
||||||
|
@persona={{this.editingModel}}
|
||||||
@ragUploads={{this.editingModel.rag_uploads}}
|
@ragUploads={{this.editingModel.rag_uploads}}
|
||||||
@onAdd={{this.addUpload}}
|
@onAdd={{this.addUpload}}
|
||||||
@onRemove={{this.removeUpload}}
|
@onRemove={{this.removeUpload}}
|
||||||
|
|
|
@ -5,10 +5,12 @@ import { on } from "@ember/modifier";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import UppyUploadMixin from "discourse/mixins/uppy-upload";
|
import UppyUploadMixin from "discourse/mixins/uppy-upload";
|
||||||
import icon from "discourse-common/helpers/d-icon";
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
import discourseDebounce from "discourse-common/lib/debounce";
|
import discourseDebounce from "discourse-common/lib/debounce";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
import RagUploadProgress from "./rag-upload-progress";
|
||||||
|
|
||||||
export default class PersonaRagUploader extends Component.extend(
|
export default class PersonaRagUploader extends Component.extend(
|
||||||
UppyUploadMixin
|
UppyUploadMixin
|
||||||
|
@ -17,6 +19,7 @@ export default class PersonaRagUploader extends Component.extend(
|
||||||
|
|
||||||
@tracked term = null;
|
@tracked term = null;
|
||||||
@tracked filteredUploads = null;
|
@tracked filteredUploads = null;
|
||||||
|
@tracked ragIndexingStatuses = null;
|
||||||
id = "discourse-ai-persona-rag-uploader";
|
id = "discourse-ai-persona-rag-uploader";
|
||||||
maxFiles = 20;
|
maxFiles = 20;
|
||||||
uploadUrl = "/admin/plugins/discourse-ai/ai-personas/files/upload";
|
uploadUrl = "/admin/plugins/discourse-ai/ai-personas/files/upload";
|
||||||
|
@ -30,6 +33,14 @@ export default class PersonaRagUploader extends Component.extend(
|
||||||
}
|
}
|
||||||
|
|
||||||
this.filteredUploads = this.ragUploads || [];
|
this.filteredUploads = this.ragUploads || [];
|
||||||
|
|
||||||
|
if (this.ragUploads?.length) {
|
||||||
|
ajax(
|
||||||
|
`/admin/plugins/discourse-ai/ai-personas/${this.persona.id}/files/status.json`
|
||||||
|
).then((statuses) => {
|
||||||
|
this.set("ragIndexingStatuses", statuses);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadDone(uploadedFile) {
|
uploadDone(uploadedFile) {
|
||||||
|
@ -97,9 +108,12 @@ export default class PersonaRagUploader extends Component.extend(
|
||||||
<span class="persona-rag-uploader__rag-file-icon">{{icon
|
<span class="persona-rag-uploader__rag-file-icon">{{icon
|
||||||
"file"
|
"file"
|
||||||
}}</span>
|
}}</span>
|
||||||
{{upload.original_filename}}</td>
|
{{upload.original_filename}}
|
||||||
<td class="persona-rag-uploader__upload-status">{{icon "check"}}
|
</td>
|
||||||
{{I18n.t "discourse_ai.ai_persona.uploads.complete"}}</td>
|
<RagUploadProgress
|
||||||
|
@upload={{upload}}
|
||||||
|
@ragIndexingStatuses={{this.ragIndexingStatuses}}
|
||||||
|
/>
|
||||||
<td class="persona-rag-uploader__remove-file">
|
<td class="persona-rag-uploader__remove-file">
|
||||||
<DButton
|
<DButton
|
||||||
@icon="times"
|
@icon="times"
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
import I18n from "discourse-i18n";
|
||||||
|
|
||||||
|
export default class RagUploadProgress extends Component {
|
||||||
|
@service messageBus;
|
||||||
|
|
||||||
|
@tracked updatedProgress = null;
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
this.messageBus.unsubscribe(
|
||||||
|
`/discourse-ai/ai-persona-rag/${this.args.upload.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
trackProgress() {
|
||||||
|
this.messageBus.subscribe(
|
||||||
|
`/discourse-ai/ai-persona-rag/${this.args.upload.id}`,
|
||||||
|
this.onIndexingUpdate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onIndexingUpdate(data) {
|
||||||
|
// Order not guaranteed. Discard old updates.
|
||||||
|
if (!this.updatedProgress || this.updatedProgress.left > data.left) {
|
||||||
|
this.updatedProgress = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get calculateProgress() {
|
||||||
|
return Math.ceil((this.progress.indexed * 100) / this.progress.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullyIndexed() {
|
||||||
|
return this.progress && this.progress.left === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get progress() {
|
||||||
|
if (this.updatedProgress) {
|
||||||
|
return this.updatedProgress;
|
||||||
|
} else if (this.args.ragIndexingStatuses) {
|
||||||
|
return this.args.ragIndexingStatuses[this.args.upload.id];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
class="persona-rag-uploader__upload-status"
|
||||||
|
{{didInsert this.trackProgress}}
|
||||||
|
>
|
||||||
|
{{#if this.progress}}
|
||||||
|
{{#if this.fullyIndexed}}
|
||||||
|
<span class="indexed">
|
||||||
|
{{icon "check"}}
|
||||||
|
{{I18n.t "discourse_ai.ai_persona.uploads.indexed"}}
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="indexing">
|
||||||
|
{{icon "robot"}}
|
||||||
|
{{I18n.t "discourse_ai.ai_persona.uploads.indexing"}}
|
||||||
|
{{this.calculateProgress}}%
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
{{else}}
|
||||||
|
<span class="uploaded">{{I18n.t
|
||||||
|
"discourse_ai.ai_persona.uploads.uploaded"
|
||||||
|
}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -129,9 +129,17 @@
|
||||||
&__upload-status {
|
&__upload-status {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|
||||||
|
.indexed {
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uploaded,
|
||||||
|
.indexing {
|
||||||
|
color: var(--primary-low-mid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__remove-file {
|
&__remove-file {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
|
@ -171,7 +171,9 @@ en:
|
||||||
hint: "To control where the file's content gets placed within the system prompt, include the {uploads} placeholder in the system prompt above."
|
hint: "To control where the file's content gets placed within the system prompt, include the {uploads} placeholder in the system prompt above."
|
||||||
button: "Add Files"
|
button: "Add Files"
|
||||||
filter: "Filter uploads"
|
filter: "Filter uploads"
|
||||||
complete: "Complete"
|
indexed: "Indexed"
|
||||||
|
indexing: "Indexing"
|
||||||
|
uploaded: "Ready to be indexed"
|
||||||
|
|
||||||
related_topics:
|
related_topics:
|
||||||
title: "Related Topics"
|
title: "Related Topics"
|
||||||
|
|
|
@ -43,5 +43,6 @@ Discourse::Application.routes.draw do
|
||||||
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
|
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
|
||||||
post "/ai-personas/files/upload", to: "discourse_ai/admin/ai_personas#upload_file"
|
post "/ai-personas/files/upload", to: "discourse_ai/admin/ai_personas#upload_file"
|
||||||
put "/ai-personas/:id/files/remove", to: "discourse_ai/admin/ai_personas#remove_file"
|
put "/ai-personas/:id/files/remove", to: "discourse_ai/admin/ai_personas#remove_file"
|
||||||
|
get "/ai-personas/:id/files/status", to: "discourse_ai/admin/ai_personas#indexing_status_check"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -34,5 +34,20 @@ RSpec.describe Jobs::GenerateRagEmbeddings do
|
||||||
|
|
||||||
expect(embeddings_count).to eq(expected_embeddings)
|
expect(embeddings_count).to eq(expected_embeddings)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Publishing progress updates" do
|
||||||
|
it "sends an update through mb after a batch finishes" do
|
||||||
|
updates =
|
||||||
|
MessageBus.track_publish(
|
||||||
|
"/discourse-ai/ai-persona-rag/#{rag_document_fragment_1.upload_id}",
|
||||||
|
) { subject.execute(fragment_ids: [rag_document_fragment_1.id]) }
|
||||||
|
|
||||||
|
upload_index_stats = updates.last.data
|
||||||
|
|
||||||
|
expect(upload_index_stats[:total]).to eq(1)
|
||||||
|
expect(upload_index_stats[:indexed]).to eq(1)
|
||||||
|
expect(upload_index_stats[:left]).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -73,4 +73,47 @@ RSpec.describe RagDocumentFragment do
|
||||||
).by(1)
|
).by(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".indexing_status" do
|
||||||
|
let(:truncation) { DiscourseAi::Embeddings::Strategies::Truncation.new }
|
||||||
|
let(:vector_rep) do
|
||||||
|
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
||||||
|
end
|
||||||
|
|
||||||
|
fab!(:rag_document_fragment_1) do
|
||||||
|
Fabricate(:rag_document_fragment, upload: upload_1, ai_persona: persona)
|
||||||
|
end
|
||||||
|
|
||||||
|
fab!(:rag_document_fragment_2) do
|
||||||
|
Fabricate(:rag_document_fragment, upload: upload_1, ai_persona: persona)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:expected_embedding) { [0.0038493] * vector_rep.dimensions }
|
||||||
|
|
||||||
|
before do
|
||||||
|
SiteSetting.ai_embeddings_enabled = true
|
||||||
|
SiteSetting.ai_embeddings_discourse_service_api_endpoint = "http://test.com"
|
||||||
|
|
||||||
|
WebMock.stub_request(
|
||||||
|
:post,
|
||||||
|
"#{SiteSetting.ai_embeddings_discourse_service_api_endpoint}/api/v1/classify",
|
||||||
|
).to_return(status: 200, body: JSON.dump(expected_embedding))
|
||||||
|
|
||||||
|
vector_rep.generate_representation_from(rag_document_fragment_1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns total, indexed and unindexed fragments for each upload" do
|
||||||
|
results = described_class.indexing_status(persona, [upload_1, upload_2])
|
||||||
|
|
||||||
|
upload_1_status = results[upload_1.id]
|
||||||
|
expect(upload_1_status[:total]).to eq(2)
|
||||||
|
expect(upload_1_status[:indexed]).to eq(1)
|
||||||
|
expect(upload_1_status[:left]).to eq(1)
|
||||||
|
|
||||||
|
upload_1_status = results[upload_2.id]
|
||||||
|
expect(upload_1_status[:total]).to eq(0)
|
||||||
|
expect(upload_1_status[:indexed]).to eq(0)
|
||||||
|
expect(upload_1_status[:left]).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -65,6 +65,7 @@ module("Discourse AI | Unit | Model | ai-persona", function () {
|
||||||
|
|
||||||
test("create properties", function (assert) {
|
test("create properties", function (assert) {
|
||||||
const properties = {
|
const properties = {
|
||||||
|
id: 1,
|
||||||
name: "Test",
|
name: "Test",
|
||||||
commands: ["CommandName"],
|
commands: ["CommandName"],
|
||||||
allowed_group_ids: [12],
|
allowed_group_ids: [12],
|
||||||
|
|
Loading…
Reference in New Issue