From f35afdca2cce19d3e0278d5206f90309eb2f2dea Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 10 Apr 2026 21:49:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(#75):=20My=20Documents=20screen=20?= =?UTF-8?q?=E2=80=94=20ingestion=20store,=20API=20client,=20i18n=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/features/ingestion/api.test.ts | 78 +++++++++++++++++++++ frontend/src/features/ingestion/api.ts | 39 +++++++++++ frontend/src/features/ingestion/store.ts | 72 +++++++++++++++++++ frontend/src/shared/i18n.ts | 32 +++++++++ 4 files changed, 221 insertions(+) create mode 100644 frontend/src/features/ingestion/api.test.ts create mode 100644 frontend/src/features/ingestion/api.ts create mode 100644 frontend/src/features/ingestion/store.ts diff --git a/frontend/src/features/ingestion/api.test.ts b/frontend/src/features/ingestion/api.test.ts new file mode 100644 index 0000000..d631fff --- /dev/null +++ b/frontend/src/features/ingestion/api.test.ts @@ -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) + }) +}) diff --git a/frontend/src/features/ingestion/api.ts b/frontend/src/features/ingestion/api.ts new file mode 100644 index 0000000..d0272d8 --- /dev/null +++ b/frontend/src/features/ingestion/api.ts @@ -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 { + 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 { + 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 { + const resp = await fetch('/api/ingestion/status') + if (!resp.ok) { + return { available: false, reason: `HTTP ${resp.status}` } + } + return resp.json() +} diff --git a/frontend/src/features/ingestion/store.ts b/frontend/src/features/ingestion/store.ts new file mode 100644 index 0000000..599d269 --- /dev/null +++ b/frontend/src/features/ingestion/store.ts @@ -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>({}) + + /** 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(null) + + async function checkAvailability(): Promise { + try { + const status = await fetchIngestionStatus() + available.value = status.available + } catch { + available.value = false + } + } + + async function ingest(jobId: string, docId: string): Promise { + 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 { + 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, + } +}) diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index d56832e..abc8d47 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -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',