diff --git a/src/application/hooks/rag/useClassicalQuery.ts b/src/application/hooks/rag/useClassicalQuery.ts index cc5bb72..97b9173 100644 --- a/src/application/hooks/rag/useClassicalQuery.ts +++ b/src/application/hooks/rag/useClassicalQuery.ts @@ -25,4 +25,4 @@ export function useClassicalQuery() { mode: request.mode, }), }); -} \ No newline at end of file +} diff --git a/src/application/hooks/rag/useRagQuery.ts b/src/application/hooks/rag/useRagQuery.ts index 2a42670..522f735 100644 --- a/src/application/hooks/rag/useRagQuery.ts +++ b/src/application/hooks/rag/useRagQuery.ts @@ -17,4 +17,4 @@ export function useRagQuery() { top_k: request.topK, }), }); -} \ No newline at end of file +} diff --git a/src/domain/entities/rag/chunkResponse.ts b/src/domain/entities/rag/chunkResponse.ts new file mode 100644 index 0000000..1501e6f --- /dev/null +++ b/src/domain/entities/rag/chunkResponse.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +/** + * Schema Zod pour un chunk retourné par LightRAG. + * Correspond au modèle backend ChunkResponse. + */ +export const ChunkResponseSchema = z.object({ + reference_id: z.string().nullable().optional(), + content: z.string(), + file_path: z.string(), + chunk_id: z.string(), +}); + +/** + * Type TypeScript dérivé du schema Zod. + */ +export type ChunkResponse = z.infer; diff --git a/src/domain/entities/rag/classicalQueryResponse.ts b/src/domain/entities/rag/classicalQueryResponse.ts new file mode 100644 index 0000000..44545fe --- /dev/null +++ b/src/domain/entities/rag/classicalQueryResponse.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +/** + * Schema Zod pour un chunk retourné par Classical RAG. + * Correspond au modèle backend ClassicalChunkResponse. + */ +export const ClassicalChunkResponseSchema = z.object({ + chunk_id: z.string(), + content: z.string(), + file_path: z.string(), + relevance_score: z.number(), + metadata: z.any().default({}), + bm25_score: z.number().optional(), + vector_score: z.number().optional(), + combined_score: z.number().optional(), +}); + +/** + * Type TypeScript dérivé du schema Zod. + */ +export type ClassicalChunkResponse = z.infer; + +/** + * Schema Zod pour la réponse complète de Classical RAG. + * Correspond au modèle backend ClassicalQueryResponse. + */ +export const ClassicalQueryResponseSchema = z.object({ + status: z.string(), + message: z.string().default(""), + queries: z.array(z.string()).default([]), + chunks: z.array(ClassicalChunkResponseSchema).default([]), + mode: z.string().default("vector"), +}); + +/** + * Type TypeScript dérivé du schema Zod. + */ +export type ClassicalQueryResponse = z.infer; diff --git a/src/domain/entities/rag/queryRequest.ts b/src/domain/entities/rag/queryRequest.ts index 08d7887..60b09ac 100644 --- a/src/domain/entities/rag/queryRequest.ts +++ b/src/domain/entities/rag/queryRequest.ts @@ -28,9 +28,3 @@ export interface ClassicalQueryRequest { mode: ClassicalQueryMode; } -export interface RagQueryResponse { - status: string; - message?: string; - data: unknown; - metadata?: unknown; -} \ No newline at end of file diff --git a/src/domain/ports/rag/ragQueryPort.ts b/src/domain/ports/rag/ragQueryPort.ts index 76871d0..b8733bb 100644 --- a/src/domain/ports/rag/ragQueryPort.ts +++ b/src/domain/ports/rag/ragQueryPort.ts @@ -1,10 +1,8 @@ -import type { - LightRAGQueryRequest, - ClassicalQueryRequest, - RagQueryResponse, -} from "@/domain/entities/rag/queryRequest"; +import type { LightRAGQueryRequest, ClassicalQueryRequest } from "@/domain/entities/rag/queryRequest"; +import type { ChunkResponse } from "@/domain/entities/rag/chunkResponse"; +import type { ClassicalQueryResponse } from "@/domain/entities/rag/classicalQueryResponse"; export interface IRagQueryPort { - queryLightRAG(request: LightRAGQueryRequest): Promise; - queryClassical(request: ClassicalQueryRequest): Promise; -} \ No newline at end of file + queryLightRAG(request: LightRAGQueryRequest): Promise; + queryClassical(request: ClassicalQueryRequest): Promise; +} diff --git a/src/infrastructure/api/rag/ragQueryApi.ts b/src/infrastructure/api/rag/ragQueryApi.ts index 6112e24..ecc62d0 100644 --- a/src/infrastructure/api/rag/ragQueryApi.ts +++ b/src/infrastructure/api/rag/ragQueryApi.ts @@ -1,15 +1,7 @@ import { ragApiClient } from "@/infrastructure/api/ragAxiosInstance"; import type { IRagQueryPort } from "@/domain/ports/rag/ragQueryPort"; - -function mapMetadata(metadata: Record | undefined): Record | undefined { - if (!metadata) return undefined; - const mapped: Record = {}; - for (const [key, value] of Object.entries(metadata)) { - const camelKey = key.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase()); - mapped[camelKey] = value; - } - return mapped; -} +import { ChunkResponseSchema } from "@/domain/entities/rag/chunkResponse"; +import { ClassicalQueryResponseSchema } from "@/domain/entities/rag/classicalQueryResponse"; export const ragQueryApi: IRagQueryPort = { async queryLightRAG(request) { @@ -19,16 +11,8 @@ export const ragQueryApi: IRagQueryPort = { mode: request.mode, top_k: request.top_k, }); - if (Array.isArray(response.data)) { - return { status: "success", data: response.data, message: undefined, metadata: undefined }; - } - const data = response.data as Record; - return { - status: data.status as string, - message: data.message as string | undefined, - data: data.data, - metadata: mapMetadata(data.metadata as Record | undefined), - }; + const parsed = ChunkResponseSchema.array().parse(response.data); + return parsed; }, async queryClassical(request) { @@ -45,15 +29,7 @@ export const ragQueryApi: IRagQueryPort = { body.vector_distance_threshold = request.vector_distance_threshold; } const response = await ragApiClient.post("/api/v1/classical/query", body); - if (Array.isArray(response.data)) { - return { status: "success", data: response.data, message: undefined, metadata: undefined }; - } - const data = response.data as Record; - return { - status: data.status as string, - message: data.message as string | undefined, - data: data.data, - metadata: mapMetadata(data.metadata as Record | undefined), - }; + const parsed = ClassicalQueryResponseSchema.parse(response.data); + return parsed; }, -}; \ No newline at end of file +}; diff --git a/tests/unit/infrastructure/api/rag/ragQueryApi.test.ts b/tests/unit/infrastructure/api/rag/ragQueryApi.test.ts index 3209cbb..ccf738e 100644 --- a/tests/unit/infrastructure/api/rag/ragQueryApi.test.ts +++ b/tests/unit/infrastructure/api/rag/ragQueryApi.test.ts @@ -19,12 +19,14 @@ describe("ragQueryApi", () => { describe("queryLightRAG", () => { it("sends correct POST to /api/v1/query with body", async () => { const mockResponse = { - data: { - status: "success", - message: "Query completed", - data: "LightRAG response text", - metadata: { source_count: 5, elapsed_time_ms: 1200 }, - }, + data: [ + { + reference_id: "ref-1", + content: "This is a LightRAG chunk", + file_path: "docs/rag.md", + chunk_id: "chunk-1", + }, + ], }; vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); @@ -43,43 +45,32 @@ describe("ragQueryApi", () => { mode: "hybrid", top_k: 5, }); - expect(result).toEqual({ - status: "success", - message: "Query completed", - data: "LightRAG response text", - metadata: { sourceCount: 5, elapsedTimeMs: 1200 }, - }); - }); - - it("maps snake_case response to camelCase", async () => { - const mockResponse = { - data: { - status: "success", - data: "some result", - metadata: { source_count: 3, elapsed_time_ms: 800 }, + expect(result).toEqual([ + { + reference_id: "ref-1", + content: "This is a LightRAG chunk", + file_path: "docs/rag.md", + chunk_id: "chunk-1", }, - }; - vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); - - const request = { - working_dir: "/data/project", - query: "test query", - mode: "local" as const, - top_k: 10, - }; - - const result = await ragQueryApi.queryLightRAG(request); - - expect(result.metadata?.sourceCount).toBe(3); - expect(result.metadata?.elapsedTimeMs).toBe(800); + ]); }); - it("handles response without optional message and metadata", async () => { + it("parses multiple chunks from backend", async () => { const mockResponse = { - data: { - status: "success", - data: "LightRAG result", - }, + data: [ + { + reference_id: "ref-1", + content: "Chunk one", + file_path: "doc1.md", + chunk_id: "c1", + }, + { + reference_id: null, + content: "Chunk two", + file_path: "doc2.md", + chunk_id: "c2", + }, + ], }; vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); @@ -92,10 +83,9 @@ describe("ragQueryApi", () => { const result = await ragQueryApi.queryLightRAG(request); - expect(result.status).toBe("success"); - expect(result.data).toBe("LightRAG result"); - expect(result.message).toBeUndefined(); - expect(result.metadata).toBeUndefined(); + expect(result).toHaveLength(2); + expect(result[0].content).toBe("Chunk one"); + expect(result[1].reference_id).toBeNull(); }); it("propagates error when axios rejects", async () => { @@ -114,6 +104,22 @@ describe("ragQueryApi", () => { "Internal server error", ); }); + + it("throws ZodError when response does not match schema", async () => { + const mockResponse = { + data: { invalid: "response" }, + }; + vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); + + const request = { + working_dir: "/data/project", + query: "test", + mode: "hybrid" as const, + top_k: 5, + }; + + await expect(ragQueryApi.queryLightRAG(request)).rejects.toThrow(); + }); }); describe("queryClassical", () => { @@ -122,8 +128,20 @@ describe("ragQueryApi", () => { data: { status: "success", message: "Classical query completed", - data: "Classical RAG response", - metadata: { source_count: 4, elapsed_time_ms: 950 }, + queries: ["Explain vector search", "vector search explanation"], + chunks: [ + { + chunk_id: "c1", + content: "Classical chunk content", + file_path: "docs/vector.md", + relevance_score: 8.5, + metadata: {}, + bm25_score: 0.85, + vector_score: 0.92, + combined_score: 0.88, + }, + ], + mode: "hybrid", }, }; vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); @@ -152,19 +170,20 @@ describe("ragQueryApi", () => { mode: "hybrid", }, ); - expect(result).toEqual({ - status: "success", - message: "Classical query completed", - data: "Classical RAG response", - metadata: { sourceCount: 4, elapsedTimeMs: 950 }, - }); + expect(result.status).toBe("success"); + expect(result.chunks).toHaveLength(1); + expect(result.chunks[0].relevance_score).toBe(8.5); + expect(result.queries).toEqual(["Explain vector search", "vector search explanation"]); + expect(result.mode).toBe("hybrid"); }); it("sends optional vector_distance_threshold when provided", async () => { const mockResponse = { data: { status: "success", - data: "result text", + queries: [], + chunks: [], + mode: "vector", }, }; vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); @@ -177,7 +196,7 @@ describe("ragQueryApi", () => { relevance_threshold: 0.5, vector_distance_threshold: 0.8, enable_llm_judge: false, - mode: "local" as const, + mode: "hybrid" as const, }; await ragQueryApi.queryClassical(request); @@ -194,7 +213,9 @@ describe("ragQueryApi", () => { const mockResponse = { data: { status: "success", - data: "result", + queries: [], + chunks: [], + mode: "vector", }, }; vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); @@ -206,7 +227,7 @@ describe("ragQueryApi", () => { num_variations: 2, relevance_threshold: 0.5, enable_llm_judge: false, - mode: "local" as const, + mode: "hybrid" as const, }; await ragQueryApi.queryClassical(request); @@ -218,12 +239,21 @@ describe("ragQueryApi", () => { expect(calledBody).not.toHaveProperty("vector_distance_threshold"); }); - it("maps snake_case response to camelCase", async () => { + it("parses chunks with optional scores", async () => { const mockResponse = { data: { status: "success", - data: "classical result", - metadata: { source_count: 7, elapsed_time_ms: 2100 }, + queries: ["test query"], + chunks: [ + { + chunk_id: "c1", + content: "content", + file_path: "doc.md", + relevance_score: 7.0, + metadata: { key: "value" }, + }, + ], + mode: "vector", }, }; vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); @@ -235,13 +265,14 @@ describe("ragQueryApi", () => { num_variations: 2, relevance_threshold: 0.6, enable_llm_judge: true, - mode: "hybrid" as const, + mode: "vector" as const, }; const result = await ragQueryApi.queryClassical(request); - expect(result.metadata?.sourceCount).toBe(7); - expect(result.metadata?.elapsedTimeMs).toBe(2100); + expect(result.chunks[0].bm25_score).toBeUndefined(); + expect(result.chunks[0].vector_score).toBeUndefined(); + expect(result.chunks[0].combined_score).toBeUndefined(); }); it("propagates error when axios rejects", async () => { @@ -256,12 +287,31 @@ describe("ragQueryApi", () => { num_variations: 2, relevance_threshold: 0.5, enable_llm_judge: false, - mode: "local" as const, + mode: "hybrid" as const, }; await expect(ragQueryApi.queryClassical(request)).rejects.toThrow( "Service unavailable", ); }); + + it("throws ZodError when response does not match schema", async () => { + const mockResponse = { + data: { invalid: "response" }, + }; + vi.mocked(ragApiClient.post).mockResolvedValue(mockResponse); + + const request = { + working_dir: "/data/project", + query: "test", + top_k: 5, + num_variations: 2, + relevance_threshold: 0.5, + enable_llm_judge: false, + mode: "hybrid" as const, + }; + + await expect(ragQueryApi.queryClassical(request)).rejects.toThrow(); + }); }); -}); \ No newline at end of file +});