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.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
|
||||
'studio.prepare': 'Préparer',
|
||||
'chunking.settings': 'Chunking',
|
||||
|
|
@ -243,6 +259,22 @@ const messages: Messages = {
|
|||
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
|
||||
'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',
|
||||
'chunking.settings': 'Chunking',
|
||||
'chunking.chunkerType': 'Chunker type',
|
||||
|
|
|
|||
Loading…
Reference in a new issue