Skip to content

Commit 015e4a4

Browse files
authored
Merge pull request #709 from Kiln-AI/leonard/kil-128-the-add-document-screen-should-let-me-add-tags
feat: allow picking tags when uploading documents
2 parents d9d9292 + f0cd24c commit 015e4a4

File tree

6 files changed

+226
-43
lines changed

6 files changed

+226
-43
lines changed

app/web_ui/src/lib/api_schema.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,6 +1969,8 @@ export interface components {
19691969
files?: string[] | null;
19701970
/** Names */
19711971
names?: string[] | null;
1972+
/** Tags */
1973+
tags?: string[] | null;
19721974
};
19731975
/** Body_edit_tags_api_projects__project_id__documents_edit_tags_post */
19741976
Body_edit_tags_api_projects__project_id__documents_edit_tags_post: {

app/web_ui/src/lib/stores/document_tag_store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const document_tags_errors_by_project_id = writable<
1717

1818
export async function load_document_tags(
1919
project_id: string,
20+
options?: { invalidate_cache: boolean },
2021
): Promise<DocumentTagCounts> {
2122
try {
2223
// Return early if already loading
@@ -32,7 +33,7 @@ export async function load_document_tags(
3233
const existing_tag_counts = get(document_tag_store_by_project_id)[
3334
project_id
3435
]
35-
if (existing_tag_counts) {
36+
if (existing_tag_counts && !options?.invalidate_cache) {
3637
return existing_tag_counts
3738
}
3839
const { data, error } = await client.GET(

app/web_ui/src/lib/ui/tag_dropdown.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,16 @@
124124
switch (example_tag_set) {
125125
case "doc": {
126126
if (project_id !== null) {
127-
current_tag_counts = document_tag_counts[project_id] || {}
127+
// fallback here in case the document_tag_counts is still loading
128+
current_tag_counts = document_tag_counts?.[project_id] ?? {}
128129
}
129130
default_tags = DEFAULT_DOC_TAGS
130131
break
131132
}
132133
case "task_run": {
133134
if (task_id !== null) {
134-
current_tag_counts = task_tag_counts[task_id] || {}
135+
// fallback here in case the task_tag_counts is still loading
136+
current_tag_counts = task_tag_counts?.[task_id] ?? {}
135137
}
136138
default_tags = DEFAULT_TASK_TAGS
137139
break

app/web_ui/src/routes/(app)/docs/library/[project_id]/upload_file_dialog.svelte

Lines changed: 121 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
import Dialog from "$lib/ui/dialog.svelte"
55
import TrashIcon from "$lib/ui/icons/trash_icon.svelte"
66
import UploadIcon from "$lib/ui/icons/upload_icon.svelte"
7+
import TagDropdown from "$lib/ui/tag_dropdown.svelte"
78
import { ragProgressStore } from "$lib/stores/rag_progress_store"
9+
import { load_document_tags } from "$lib/stores/document_tag_store"
810
import type { BulkCreateDocumentsResponse } from "$lib/types"
911
import posthog from "posthog-js"
12+
import { createKilnError, KilnError } from "$lib/utils/error_handlers"
13+
import FormElement from "$lib/utils/form_element.svelte"
1014
1115
export let onUploadCompleted: () => void
1216
17+
let upload_error: KilnError | null = null
1318
let selected_files: File[] = []
1419
let file_input: HTMLInputElement
1520
let drag_over = false
@@ -97,7 +102,12 @@
97102
let unsupported_files_count = 0
98103
let show_success_dialog = false
99104
105+
// tags
106+
let selected_tags: Set<string> = new Set()
107+
let current_tag = ""
108+
100109
async function handleUpload(): Promise<boolean> {
110+
upload_error = null
101111
upload_in_progress = true
102112
upload_progress = 0
103113
upload_total = selected_files.length
@@ -112,6 +122,9 @@
112122
return false
113123
}
114124
return success
125+
} catch (e) {
126+
upload_error = createKilnError(e)
127+
return false
115128
} finally {
116129
upload_in_progress = false
117130
upload_progress = 0
@@ -131,6 +144,12 @@
131144
formData.append(`names`, file.name)
132145
})
133146
147+
if (selected_tags.size > 0) {
148+
Array.from(selected_tags).forEach((tag) => {
149+
formData.append("tags", tag)
150+
})
151+
}
152+
134153
const { data, error } = await client.POST(
135154
"/api/projects/{project_id}/documents/bulk",
136155
{
@@ -158,6 +177,11 @@
158177
selected_files = []
159178
onUploadCompleted()
160179
180+
// reload document tags for the project - because total counts have changed
181+
// and we cannot know which ones due to partial upload rejection possibly
182+
// happening on the backend
183+
await load_document_tags(project_id, { invalidate_cache: true })
184+
161185
ragProgressStore.run_all_rag_configs(project_id).catch((error) => {
162186
console.error("Error running all rag configs", error)
163187
})
@@ -244,6 +268,8 @@
244268
show_upload_result = false
245269
show_success_dialog = false
246270
unsupported_files_count = 0
271+
selected_tags = new Set()
272+
current_tag = ""
247273
}
248274
249275
export function close() {
@@ -253,6 +279,8 @@
253279
show_upload_result = false
254280
show_success_dialog = false
255281
unsupported_files_count = 0
282+
selected_tags = new Set()
283+
current_tag = ""
256284
return true
257285
}
258286
@@ -329,51 +357,98 @@
329357
{/if}
330358

331359
{#if !show_success_dialog}
332-
<!-- Dropzone -->
333-
<div
334-
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer {drag_over
335-
? 'border-primary bg-primary/5'
336-
: 'border-gray-300 hover:border-gray-400'}"
337-
on:dragover={handleDragOver}
338-
on:dragleave={handleDragLeave}
339-
on:drop={handleDrop}
340-
on:click={openFileDialog}
341-
role="button"
342-
tabindex="0"
343-
on:keydown={(e) => {
344-
if (e.key === "Enter" || e.key === " ") {
345-
e.preventDefault()
346-
openFileDialog()
347-
}
348-
}}
349-
>
350-
<div class="space-y-2">
351-
<div class="w-10 h-10 mx-auto text-gray-500">
352-
<UploadIcon />
353-
</div>
354-
<div>
355-
<p class="text-gray-500">Drop files here or click to select</p>
360+
<div class="pb-2">
361+
<!-- Dropzone -->
362+
<div
363+
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer {drag_over
364+
? 'border-primary bg-primary/5'
365+
: 'border-gray-300 hover:border-gray-400'}"
366+
on:dragover={handleDragOver}
367+
on:dragleave={handleDragLeave}
368+
on:drop={handleDrop}
369+
on:click={openFileDialog}
370+
role="button"
371+
tabindex="0"
372+
on:keydown={(e) => {
373+
if (e.key === "Enter" || e.key === " ") {
374+
e.preventDefault()
375+
openFileDialog()
376+
}
377+
}}
378+
>
379+
<div class="space-y-2">
380+
<div class="w-10 h-10 mx-auto text-gray-500">
381+
<UploadIcon />
382+
</div>
383+
<div>
384+
<p class="text-gray-500">Drop files here or click to select</p>
385+
</div>
356386
</div>
357387
</div>
388+
389+
<!-- Hidden file input -->
390+
<input
391+
bind:this={file_input}
392+
type="file"
393+
multiple
394+
class="hidden"
395+
on:change={handleFileSelect}
396+
accept={supported_file_types.join(",")}
397+
/>
398+
399+
{#if unsupported_files_count > 0}
400+
<div class="text-error text-sm">
401+
{unsupported_files_count} file{unsupported_files_count === 1
402+
? ""
403+
: "s"} skipped due to unsupported format
404+
</div>
405+
{/if}
358406
</div>
359407

360-
<!-- Hidden file input -->
361-
<input
362-
bind:this={file_input}
363-
type="file"
364-
multiple
365-
class="hidden"
366-
on:change={handleFileSelect}
367-
accept={supported_file_types.join(",")}
368-
/>
369-
370-
{#if unsupported_files_count > 0}
371-
<div class="text-error text-sm">
372-
{unsupported_files_count} file{unsupported_files_count === 1
373-
? ""
374-
: "s"} skipped due to unsupported format
408+
<!-- Tag selection -->
409+
<div>
410+
<FormElement
411+
inputType="header_only"
412+
label="Tags"
413+
id="tags_section"
414+
description="Add tags to organize your documents"
415+
info_description="Any tags set here will be added to each document you add. Tags can be used to filter your document set."
416+
optional={true}
417+
value=""
418+
/>
419+
{#if selected_tags.size > 0}
420+
<div class="flex flex-row flex-wrap gap-2 my-2">
421+
{#each Array.from(selected_tags).sort() as tag}
422+
<div
423+
class="badge bg-gray-200 text-gray-500 py-3 px-3 max-w-full"
424+
>
425+
<span class="truncate">{tag}</span>
426+
<button
427+
class="pl-3 font-medium shrink-0"
428+
on:click={() => {
429+
selected_tags.delete(tag)
430+
selected_tags = selected_tags
431+
}}>✕</button
432+
>
433+
</div>
434+
{/each}
435+
</div>
436+
{/if}
437+
<div class="flex flex-row gap-2 items-center">
438+
<TagDropdown
439+
bind:tag={current_tag}
440+
{project_id}
441+
example_tag_set="doc"
442+
on_select={(tag) => {
443+
selected_tags.add(tag)
444+
selected_tags = selected_tags
445+
current_tag = ""
446+
}}
447+
on_escape={() => {}}
448+
focus_on_mount={false}
449+
/>
375450
</div>
376-
{/if}
451+
</div>
377452

378453
{#if show_upload_result && upload_result}
379454
{#if upload_result.created_documents.length > 0}
@@ -447,6 +522,12 @@
447522
</div>
448523
{/if}
449524
{/if}
525+
526+
{#if upload_error}
527+
<div class="text-error text-sm">
528+
{upload_error.getMessage() || "An unknown error occurred"}
529+
</div>
530+
{/if}
450531
</div>
451532
</div>
452533
</Dialog>

libs/server/kiln_server/document_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ async def create_documents_bulk(
598598
project_id: str,
599599
files: Annotated[List[UploadFile] | None, File()] = None,
600600
names: Annotated[List[str] | None, Form()] = None,
601+
tags: Annotated[List[str] | None, Form()] = None,
601602
) -> BulkCreateDocumentsResponse:
602603
project = project_from_id(project_id)
603604

@@ -652,6 +653,7 @@ async def create_documents_bulk(
652653
name_override=document_name,
653654
description="", # No description support in bulk upload
654655
kind=kind,
656+
tags=tags if tags else [],
655657
original_file=FileInfo(
656658
filename=file.filename,
657659
mime_type=mime_type,

libs/server/kiln_server/test_document_api.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3871,6 +3871,101 @@ async def test_create_rag_config_invalid_tool_fields(
38713871
assert "error_messages" in error_detail
38723872

38733873

3874+
@pytest.mark.parametrize(
3875+
"tags",
3876+
[
3877+
[],
3878+
["tag1"],
3879+
["tag1", "tag2"],
3880+
["tag1", "tag2", "tag3"],
3881+
],
3882+
)
3883+
async def test_create_documents_bulk_with_tags_success(client, mock_project, tags):
3884+
"""Test successful bulk upload with various tag combinations"""
3885+
project = mock_project
3886+
test_content_1 = b"test file content 1"
3887+
test_content_2 = b"test file content 2"
3888+
3889+
with (
3890+
patch("kiln_server.document_api.project_from_id") as mock_project_from_id,
3891+
):
3892+
mock_project_from_id.return_value = project
3893+
3894+
files = [
3895+
("files", ("test1.txt", io.BytesIO(test_content_1), "text/plain")),
3896+
("files", ("test2.txt", io.BytesIO(test_content_2), "text/plain")),
3897+
]
3898+
data = {"names": ["Custom Name 1", "Custom Name 2"]}
3899+
3900+
# Add tags to the data
3901+
for tag in tags:
3902+
data.setdefault("tags", []).append(tag)
3903+
3904+
response = client.post(
3905+
f"/api/projects/{project.id}/documents/bulk", files=files, data=data
3906+
)
3907+
3908+
assert response.status_code == 200, response.text
3909+
result = response.json()
3910+
assert "created_documents" in result
3911+
assert "failed_files" in result
3912+
assert len(result["created_documents"]) == 2
3913+
assert len(result["failed_files"]) == 0
3914+
3915+
# Check that both documents have all the tags
3916+
for doc in result["created_documents"]:
3917+
assert "tags" in doc
3918+
assert doc["tags"] == tags
3919+
assert len(doc["tags"]) == len(tags)
3920+
assert sorted(tags) == sorted(doc["tags"])
3921+
3922+
3923+
@pytest.mark.parametrize(
3924+
"invalid_tags",
3925+
[
3926+
["tag with spaces"],
3927+
["tag with spaces", "valid_tag"],
3928+
["", "valid_tag"],
3929+
[" ", "valid_tag"],
3930+
],
3931+
)
3932+
async def test_create_documents_bulk_with_invalid_tags_failure(
3933+
client, mock_project, invalid_tags
3934+
):
3935+
"""Test bulk upload failure due to invalid tags (spaces, empty strings)"""
3936+
project = mock_project
3937+
test_content = b"test file content"
3938+
3939+
with (
3940+
patch("kiln_server.document_api.project_from_id") as mock_project_from_id,
3941+
):
3942+
mock_project_from_id.return_value = project
3943+
3944+
files = [
3945+
("files", ("test.txt", io.BytesIO(test_content), "text/plain")),
3946+
]
3947+
data = {"names": ["Custom Name"]}
3948+
3949+
# Add invalid tags to the data
3950+
for tag in invalid_tags:
3951+
data.setdefault("tags", []).append(tag)
3952+
3953+
response = client.post(
3954+
f"/api/projects/{project.id}/documents/bulk", files=files, data=data
3955+
)
3956+
3957+
# Should return 422 for invalid tags
3958+
assert response.status_code == 422, response.text
3959+
result = response.json()
3960+
assert "message" in result
3961+
assert "failed_files" in result["message"]
3962+
assert len(result["message"]["failed_files"]) == 1
3963+
assert (
3964+
"Tags cannot contain spaces" in result["message"]["failed_files"][0]
3965+
or "Tags cannot be empty strings" in result["message"]["failed_files"][0]
3966+
)
3967+
3968+
38743969
@pytest.mark.asyncio
38753970
async def test_delete_extraction_extractor_config_not_found(
38763971
client, mock_project, mock_document

0 commit comments

Comments
 (0)