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:
Pier-Jean Malandrino 2026-04-10 21:49:09 +02:00
parent d341851818
commit f35afdca2c
4 changed files with 221 additions and 0 deletions

View 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)
})
})

View 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()
}

View 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,
}
})

View file

@ -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',