feat(#75): My Documents screen — ingestion store, API client, i18n keys
Add ingestion feature: api.ts (ingest/delete/status HTTP calls), Pinia store tracking ingestedDocs + availability, full i18n keys (fr/en) for the Documents screen. DocumentsPage.vue was already wired; now fully functional.
This commit is contained in:
parent
d341851818
commit
f35afdca2c
4 changed files with 221 additions and 0 deletions
78
frontend/src/features/ingestion/api.test.ts
Normal file
78
frontend/src/features/ingestion/api.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ingestAnalysis, deleteIngested, fetchIngestionStatus } from './api'
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ingestAnalysis', () => {
|
||||||
|
it('posts to /api/ingestion/:jobId', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ docId: 'doc-1', chunksIndexed: 5, embeddingDimension: 384 }),
|
||||||
|
})
|
||||||
|
const result = await ingestAnalysis('job-1')
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/ingestion/job-1',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
)
|
||||||
|
expect(result.chunksIndexed).toBe(5)
|
||||||
|
expect(result.docId).toBe('doc-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on non-ok response', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 422,
|
||||||
|
json: () => Promise.resolve({ detail: 'job not completed' }),
|
||||||
|
})
|
||||||
|
await expect(ingestAnalysis('job-bad')).rejects.toThrow('job not completed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteIngested', () => {
|
||||||
|
it('deletes /api/ingestion/:docId', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => Promise.resolve(null) })
|
||||||
|
await deleteIngested('doc-1')
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/ingestion/doc-1',
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores 404 response', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 404, json: () => Promise.resolve({}) })
|
||||||
|
await expect(deleteIngested('doc-missing')).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on other errors', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.resolve({ detail: 'server error' }),
|
||||||
|
})
|
||||||
|
await expect(deleteIngested('doc-1')).rejects.toThrow('server error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchIngestionStatus', () => {
|
||||||
|
it('gets /api/ingestion/status', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ available: true, reason: '' }),
|
||||||
|
})
|
||||||
|
const result = await fetchIngestionStatus()
|
||||||
|
expect(result.available).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unavailable on non-ok', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 503 })
|
||||||
|
const result = await fetchIngestionStatus()
|
||||||
|
expect(result.available).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
39
frontend/src/features/ingestion/api.ts
Normal file
39
frontend/src/features/ingestion/api.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* Ingestion API client — wraps /api/ingestion endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IngestionResult {
|
||||||
|
docId: string
|
||||||
|
chunksIndexed: number
|
||||||
|
embeddingDimension: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestionStatus {
|
||||||
|
available: boolean
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ingestAnalysis(jobId: string): Promise<IngestionResult> {
|
||||||
|
const resp = await fetch(`/api/ingestion/${jobId}`, { method: 'POST' })
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}))
|
||||||
|
throw new Error(body.detail ?? `Ingestion failed (${resp.status})`)
|
||||||
|
}
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIngested(docId: string): Promise<void> {
|
||||||
|
const resp = await fetch(`/api/ingestion/${docId}`, { method: 'DELETE' })
|
||||||
|
if (!resp.ok && resp.status !== 404) {
|
||||||
|
const body = await resp.json().catch(() => ({}))
|
||||||
|
throw new Error(body.detail ?? `Delete failed (${resp.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchIngestionStatus(): Promise<IngestionStatus> {
|
||||||
|
const resp = await fetch('/api/ingestion/status')
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { available: false, reason: `HTTP ${resp.status}` }
|
||||||
|
}
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
72
frontend/src/features/ingestion/store.ts
Normal file
72
frontend/src/features/ingestion/store.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* Ingestion store — tracks which documents are indexed in OpenSearch
|
||||||
|
* and exposes actions to ingest / delete indexed chunks.
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { deleteIngested, fetchIngestionStatus, ingestAnalysis } from './api'
|
||||||
|
|
||||||
|
export const useIngestionStore = defineStore('ingestion', () => {
|
||||||
|
/** Map of docId → chunk count for indexed documents. */
|
||||||
|
const ingestedDocs = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
/** Whether the ingestion pipeline (OpenSearch + embedding) is available. */
|
||||||
|
const available = ref(false)
|
||||||
|
|
||||||
|
/** True while an ingestion is running. */
|
||||||
|
const ingesting = ref(false)
|
||||||
|
|
||||||
|
/** Last ingestion error message, if any. */
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function checkAvailability(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await fetchIngestionStatus()
|
||||||
|
available.value = status.available
|
||||||
|
} catch {
|
||||||
|
available.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingest(jobId: string, docId: string): Promise<number> {
|
||||||
|
ingesting.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await ingestAnalysis(jobId)
|
||||||
|
ingestedDocs.value = { ...ingestedDocs.value, [docId]: result.chunksIndexed }
|
||||||
|
return result.chunksIndexed
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Ingestion failed'
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
ingesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteIngestd(docId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await deleteIngested(docId)
|
||||||
|
const next = { ...ingestedDocs.value }
|
||||||
|
delete next[docId]
|
||||||
|
ingestedDocs.value = next
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message || 'Delete failed'
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError(): void {
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ingestedDocs,
|
||||||
|
available,
|
||||||
|
ingesting,
|
||||||
|
error,
|
||||||
|
checkAvailability,
|
||||||
|
ingest,
|
||||||
|
deleteIngested: deleteIngestd,
|
||||||
|
clearError,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -107,6 +107,22 @@ const messages: Messages = {
|
||||||
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
|
||||||
'history.open': 'Ouvrir',
|
'history.open': 'Ouvrir',
|
||||||
|
|
||||||
|
// Ingestion / My Documents
|
||||||
|
'ingestion.search': 'Rechercher…',
|
||||||
|
'ingestion.sortName': 'Nom',
|
||||||
|
'ingestion.sortDate': 'Date',
|
||||||
|
'ingestion.filterAll': 'Tous',
|
||||||
|
'ingestion.filterIndexed': 'Indexés',
|
||||||
|
'ingestion.filterNotIndexed': 'Non indexés',
|
||||||
|
'ingestion.indexed': 'Indexé',
|
||||||
|
'ingestion.notIndexed': 'Non indexé',
|
||||||
|
'ingestion.chunksIndexed': '{n} chunks',
|
||||||
|
'ingestion.openInStudio': 'Ouvrir dans Studio',
|
||||||
|
'ingestion.ingest': 'Indexer',
|
||||||
|
'ingestion.ingesting': 'Indexation…',
|
||||||
|
'ingestion.ingestSuccess': 'Indexation réussie — {n} chunks indexés.',
|
||||||
|
'ingestion.ingestError': 'Erreur d\u2019indexation : {msg}',
|
||||||
|
|
||||||
// Chunking
|
// Chunking
|
||||||
'studio.prepare': 'Préparer',
|
'studio.prepare': 'Préparer',
|
||||||
'chunking.settings': 'Chunking',
|
'chunking.settings': 'Chunking',
|
||||||
|
|
@ -243,6 +259,22 @@ const messages: Messages = {
|
||||||
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
||||||
'history.open': 'Open',
|
'history.open': 'Open',
|
||||||
|
|
||||||
|
// Ingestion / My Documents
|
||||||
|
'ingestion.search': 'Search…',
|
||||||
|
'ingestion.sortName': 'Name',
|
||||||
|
'ingestion.sortDate': 'Date',
|
||||||
|
'ingestion.filterAll': 'All',
|
||||||
|
'ingestion.filterIndexed': 'Indexed',
|
||||||
|
'ingestion.filterNotIndexed': 'Not indexed',
|
||||||
|
'ingestion.indexed': 'Indexed',
|
||||||
|
'ingestion.notIndexed': 'Not indexed',
|
||||||
|
'ingestion.chunksIndexed': '{n} chunks',
|
||||||
|
'ingestion.openInStudio': 'Open in Studio',
|
||||||
|
'ingestion.ingest': 'Index',
|
||||||
|
'ingestion.ingesting': 'Indexing…',
|
||||||
|
'ingestion.ingestSuccess': 'Indexed successfully — {n} chunks.',
|
||||||
|
'ingestion.ingestError': 'Indexing error: {msg}',
|
||||||
|
|
||||||
'studio.prepare': 'Prepare',
|
'studio.prepare': 'Prepare',
|
||||||
'chunking.settings': 'Chunking',
|
'chunking.settings': 'Chunking',
|
||||||
'chunking.chunkerType': 'Chunker type',
|
'chunking.chunkerType': 'Chunker type',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue