diff --git a/README.md b/README.md index 99f4cc4..a7ba6bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CI](https://github.com/Arlikhozhaev/ReviewLens/actions/workflows/ci.yml/badge.svg)](https://github.com/Arlikhozhaev/ReviewLens/actions/workflows/ci.yml) [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org/) [![Next.js 14](https://img.shields.io/badge/Next.js-14-black)](https://nextjs.org/) -[![Tests](https://img.shields.io/badge/tests-51%20unit-success)](https://github.com/Arlikhozhaev/ReviewLens/blob/main/README.md#testing--ci) +[![Tests](https://img.shields.io/badge/tests-93%20unit-success)](https://github.com/Arlikhozhaev/ReviewLens/blob/main/README.md#testing--ci) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) **[Live app](https://review-lens-app.vercel.app/)** · **[Try sample data](https://review-lens-app.vercel.app/analyze)** · **[Report an issue](mailto:arlikhozhaevca@gmail.com)** @@ -21,7 +21,7 @@ |--------|-------| | Time to insight | **< 60s** from CSV upload to themed report | | Reviews per upload | **500+** supported | -| Automated unit tests | **51** (Vitest — no DB/network in CI) | +| Automated unit tests | **93** (Vitest — no DB/network in CI) | | E2E specs | **4** (Playwright — auth gating + golden path) | | CI gates on every merge | **Lint · type-check · test · production build** | | AI pipeline stages | **4** (embed → cluster → summarize → executive summary) | @@ -37,7 +37,7 @@ | Product teams drown in unstructured review text | **Clustered AI themes** with sentiment and an executive summary — not a wall of individual reviews | | Manual theming doesn't scale past a few dozen reviews | **Embedding + k-means pipeline** groups semantically similar feedback automatically | | Stakeholders need reports, not repo access | **Shareable dashboard links** with optional password and expiry — no account required for viewers | -| AI pipelines fail silently in production | **Atomic job claiming**, structured JSON logs, health checks, and **51 unit tests** guarding core logic | +| AI pipelines fail silently in production | **Atomic job claiming**, structured JSON logs, health checks, and **93 unit tests** guarding core logic | | Long-running analysis blocks the UI | **Inngest background jobs** with Vercel `waitUntil` fallback — API returns immediately, status polls live | **In one sentence:** ReviewLens accepts a CSV of product reviews, runs an embeddings → clustering → LLM summarization pipeline, and delivers a stakeholder-ready insight report with PDF/CSV export and password-protected sharing. @@ -60,7 +60,7 @@ - **Reduced duplicate pipeline runs in concurrent requests**, measured by zero double-processing on the same session, by atomically claiming jobs with `updateMany WHERE status = PENDING`. - **Kept stakeholder handoff friction near zero**, measured by view-only share links requiring no login, by shipping password/expiry gates with scrypt hashing and HMAC-signed httpOnly cookies (12h). -- **Maintained release confidence as features grew**, measured by **51 passing unit tests** and GitHub Actions CI on every push to `main`, by testing CSV detection, validation, share crypto, and API contracts without a live database in CI. +- **Maintained release confidence as features grew**, measured by **93 passing unit tests** and GitHub Actions CI on every push to `main`, by testing CSV detection, validation, share crypto, and API contracts without a live database in CI. - **Chose share-first collaboration over email invites**, measured by zero custom-domain email dependencies on Vercel, by deferring team-inbox UI while shipping PDF/CSV export and `mailto:` share drafts. - **Made ingestion flexible without brittle schemas**, measured by automatic column mapping across common CSV formats, by building header-detection and paste-to-review parsers with dedicated test coverage. @@ -202,7 +202,7 @@ npx inngest-cli dev -u http://localhost:3000/api/inngest ## Testing & CI ```bash -npm test # 51 Vitest unit tests +npm test # 93 Vitest unit tests npm run test:watch npm run test:e2e # 4 Playwright specs (golden path + auth gating) ``` @@ -271,7 +271,7 @@ npm run test:e2e # Playwright 1. **Problem** — Unstructured review text doesn't scale; manual theming breaks past dozens of rows. 2. **Approach** — Embeddings + k-means + LLM summarization with atomic job claiming and share-gated read-only reports. 3. **Tradeoff** — Built org/tenant models but shipped **share-link collaboration** instead of email invites (no custom domain on Vercel free tier). -4. **Reliability** — Inngest + `waitUntil` fallback, Upstash rate limits, `/api/health`, **51 unit tests**, Playwright e2e, GitHub Actions CI. +4. **Reliability** — Inngest + `waitUntil` fallback, Upstash rate limits, `/api/health`, **93 unit tests**, Playwright e2e, GitHub Actions CI. 5. **Outcome** — CSV → themed report in **< 60s**, PDF/CSV export, password-protected links for stakeholders. --- diff --git a/features/analysis/pipeline.test.ts b/features/analysis/pipeline.test.ts new file mode 100644 index 0000000..775379a --- /dev/null +++ b/features/analysis/pipeline.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Prisma } from "@prisma/client"; +import { + chatJsonResponse, + chatTextResponse, + embeddingResponse, + mockChatCreate, + mockEmbeddingsCreate, + mockSleep, +} from "./test-helpers"; + +const mockFindMany = vi.fn(); +const mockResultCreate = vi.fn(); +const mockReviewUpdateMany = vi.fn(); +const mockSessionUpdate = vi.fn(); +const mockSessionUpdateMany = vi.fn(); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + review: { + findMany: (...args: unknown[]) => mockFindMany(...args), + updateMany: (...args: unknown[]) => mockReviewUpdateMany(...args), + }, + analysisResult: { + create: (...args: unknown[]) => mockResultCreate(...args), + }, + analysisSession: { + update: (...args: unknown[]) => mockSessionUpdate(...args), + updateMany: (...args: unknown[]) => mockSessionUpdateMany(...args), + }, + }, +})); + +vi.mock("@/lib/openai", () => ({ + openai: { + embeddings: { + create: (...args: unknown[]) => mockEmbeddingsCreate(...args), + }, + chat: { + completions: { + create: (...args: unknown[]) => mockChatCreate(...args), + }, + }, + }, +})); + +vi.mock("@/lib/utils", async () => { + const actual = await vi.importActual("@/lib/utils"); + return { ...actual, sleep: (...args: unknown[]) => mockSleep(...args) }; +}); + +vi.mock("@/lib/logger", () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + stage: vi.fn(), + openaiUsage: vi.fn(), + }), +})); + +import { runAnalysisPipeline } from "./pipeline"; + +const SESSION_ID = "sess_pipeline_1"; + +function makeDbReviews(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `review-${index}`, + text: `Review ${index} about battery and screen quality`, + rating: index % 2 === 0 ? 4 : 2, + })); +} + +function mockOpenAiHappyPath(reviewCount: number) { + mockEmbeddingsCreate.mockImplementation(async (args: { input: string[] }) => + embeddingResponse(args.input.length) + ); + + mockChatCreate.mockImplementation(async (args: { + response_format?: { type: string }; + }) => { + if (args.response_format?.type === "json_object") { + return chatJsonResponse({ + label: "Product quality", + description: "Mixed feedback on quality.", + sentiment: "mixed", + }); + } + return chatTextResponse("Executive summary for product reviews."); + }); + + mockFindMany.mockResolvedValue(makeDbReviews(reviewCount)); + mockResultCreate.mockResolvedValue({ id: "result-1" }); + mockReviewUpdateMany.mockResolvedValue({ count: reviewCount }); + mockSessionUpdate.mockResolvedValue({ id: SESSION_ID, status: "COMPLETED" }); + mockSessionUpdateMany.mockResolvedValue({ count: 1 }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSleep.mockResolvedValue(undefined); +}); + +describe("runAnalysisPipeline", () => { + it("completes the happy path and marks the session COMPLETED", async () => { + mockOpenAiHappyPath(20); + + await runAnalysisPipeline(SESSION_ID, { requestId: "req-1" }); + + expect(mockFindMany).toHaveBeenCalledWith({ + where: { sessionId: SESSION_ID }, + select: { id: true, text: true, rating: true }, + }); + expect(mockEmbeddingsCreate).toHaveBeenCalled(); + expect(mockChatCreate).toHaveBeenCalled(); + expect(mockResultCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + sessionId: SESSION_ID, + executiveSummary: expect.any(String), + processingMs: expect.any(Number), + }), + }) + ); + expect(mockSessionUpdate).toHaveBeenCalledWith({ + where: { id: SESSION_ID }, + data: { status: "COMPLETED" }, + }); + }); + + it("fails when the session has no reviews", async () => { + mockFindMany.mockResolvedValue([]); + mockSessionUpdateMany.mockResolvedValue({ count: 1 }); + + await expect(runAnalysisPipeline(SESSION_ID)).rejects.toThrow( + "No reviews found in session" + ); + + expect(mockSessionUpdateMany).toHaveBeenCalledWith({ + where: { id: SESSION_ID, status: { not: "COMPLETED" } }, + data: { status: "FAILED" }, + }); + expect(mockResultCreate).not.toHaveBeenCalled(); + }); + + it("exits cleanly when a result row already exists (idempotent claim)", async () => { + mockOpenAiHappyPath(12); + mockResultCreate.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "6.0.0", + }) + ); + + await expect( + runAnalysisPipeline(SESSION_ID) + ).resolves.toBeUndefined(); + + expect(mockSessionUpdate).not.toHaveBeenCalled(); + }); + + it("marks the session FAILED when embedding generation fails", async () => { + mockFindMany.mockResolvedValue(makeDbReviews(3)); + mockEmbeddingsCreate.mockRejectedValue(new Error("OpenAI unavailable")); + mockSessionUpdateMany.mockResolvedValue({ count: 1 }); + + await expect(runAnalysisPipeline(SESSION_ID)).rejects.toThrow( + /failed after 3 attempts/ + ); + + expect(mockSessionUpdateMany).toHaveBeenCalledWith({ + where: { id: SESSION_ID, status: { not: "COMPLETED" } }, + data: { status: "FAILED" }, + }); + }); + + it("uses capped k for large review sets (n > 120)", async () => { + mockOpenAiHappyPath(130); + + await runAnalysisPipeline(SESSION_ID); + + const themeCalls = mockChatCreate.mock.calls.filter( + (call) => call[0]?.response_format?.type === "json_object" + ); + expect(themeCalls.length).toBeLessThanOrEqual(8); + expect(themeCalls.length).toBeGreaterThan(0); + }); + + it("uses minimum k=2 for small review sets (n < 15)", async () => { + mockOpenAiHappyPath(8); + + await runAnalysisPipeline(SESSION_ID); + + const themeCalls = mockChatCreate.mock.calls.filter( + (call) => call[0]?.response_format?.type === "json_object" + ); + expect(themeCalls).toHaveLength(2); + }); +}); diff --git a/features/analysis/test-helpers.ts b/features/analysis/test-helpers.ts new file mode 100644 index 0000000..aa2a866 --- /dev/null +++ b/features/analysis/test-helpers.ts @@ -0,0 +1,68 @@ +import { vi } from "vitest"; +import type { ThemeAnalysis } from "./types"; + +export const mockEmbeddingsCreate = vi.fn(); +export const mockChatCreate = vi.fn(); +export const mockSleep = vi.fn().mockResolvedValue(undefined); + +export function mockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + stage: vi.fn(), + openaiUsage: vi.fn(), + }; +} + +export function embeddingResponse( + count: number, + dim = 4 +): { + data: { embedding: number[]; index: number }[]; + usage: { total_tokens: number; prompt_tokens: number }; +} { + return { + data: Array.from({ length: count }, (_, index) => ({ + embedding: Array.from({ length: dim }, (_, axis) => index + axis * 0.01), + index, + })), + usage: { total_tokens: count * 2, prompt_tokens: count }, + }; +} + +export function chatJsonResponse(payload: unknown, tokens = 42) { + return { + choices: [{ message: { content: JSON.stringify(payload) } }], + usage: { + total_tokens: tokens, + prompt_tokens: tokens - 10, + completion_tokens: 10, + }, + }; +} + +export function chatTextResponse(text: string, tokens = 55) { + return { + choices: [{ message: { content: text } }], + usage: { + total_tokens: tokens, + prompt_tokens: tokens - 15, + completion_tokens: 15, + }, + }; +} + +export function sampleTheme(overrides: Partial = {}): ThemeAnalysis { + return { + clusterId: 0, + label: "Battery life", + description: "Customers mention short battery life.", + sentiment: "negative", + reviewCount: 5, + percentage: 50, + exampleQuotes: ["Battery dies fast"], + ...overrides, + }; +} diff --git a/features/analysis/utils/clustering.test.ts b/features/analysis/utils/clustering.test.ts new file mode 100644 index 0000000..3015d2f --- /dev/null +++ b/features/analysis/utils/clustering.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + clusterReviews, + determineK, + getRepresentativeTexts, +} from "./clustering"; +import type { EmbeddedReview } from "../types"; + +function makeEmbedded(count: number, dim = 3): EmbeddedReview[] { + return Array.from({ length: count }, (_, index) => ({ + id: `review-${index}`, + text: `Review text ${index}`, + embedding: Array.from({ length: dim }, (__, axis) => { + if (axis === 0) return index < count / 2 ? 0 : 10; + return index * 0.1 + axis; + }), + })); +} + +describe("determineK", () => { + it("returns minimum k=2 for small datasets (n < 15)", () => { + expect(determineK(1)).toBe(2); + expect(determineK(10)).toBe(2); + expect(determineK(14)).toBe(2); + }); + + it("scales k with review count (1 cluster per 15 reviews)", () => { + expect(determineK(30)).toBe(2); + expect(determineK(45)).toBe(3); + expect(determineK(75)).toBe(5); + }); + + it("caps k at 8 for large datasets (n > 120)", () => { + expect(determineK(120)).toBe(8); + expect(determineK(200)).toBe(8); + expect(determineK(500)).toBe(8); + }); +}); + +describe("clusterReviews", () => { + it("assigns every review to a non-empty cluster", () => { + const embedded = makeEmbedded(12); + const clusters = clusterReviews(embedded, determineK(embedded.length)); + + expect(clusters.length).toBeGreaterThan(0); + const assigned = clusters.reduce((sum, c) => sum + c.reviewIds.length, 0); + expect(assigned).toBe(embedded.length); + expect(clusters.every((c) => c.reviewIds.length > 0)).toBe(true); + }); + + it("handles n < 15 with k=2 without empty output", () => { + const embedded = makeEmbedded(8); + const clusters = clusterReviews(embedded, 2); + + expect(clusters).toHaveLength(2); + expect(clusters.reduce((sum, c) => sum + c.reviewIds.length, 0)).toBe(8); + }); +}); + +describe("getRepresentativeTexts", () => { + it("returns texts closest to the cluster centroid", () => { + const cluster = { + id: 0, + reviewIds: ["a", "b", "c"], + embeddings: [ + [0, 0], + [5, 5], + [0.1, 0.1], + ], + texts: ["far", "farthest", "near"], + centroid: [0, 0], + }; + + const reps = getRepresentativeTexts(cluster, 2); + + expect(reps).toHaveLength(2); + expect(reps[0]).toBe("far"); + expect(reps).toContain("near"); + }); +}); diff --git a/features/analysis/utils/embeddings.test.ts b/features/analysis/utils/embeddings.test.ts new file mode 100644 index 0000000..7cb79ec --- /dev/null +++ b/features/analysis/utils/embeddings.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OPENAI_MODELS, EMBEDDING_BATCH_SIZE } from "@/lib/constants"; +import { + embeddingResponse, + mockEmbeddingsCreate, + mockLogger, + mockSleep, +} from "../test-helpers"; + +vi.mock("@/lib/openai", () => ({ + openai: { + embeddings: { create: (...args: unknown[]) => mockEmbeddingsCreate(...args) }, + chat: { completions: { create: vi.fn() } }, + }, +})); + +vi.mock("@/lib/utils", async () => { + const actual = await vi.importActual("@/lib/utils"); + return { ...actual, sleep: (...args: unknown[]) => mockSleep(...args) }; +}); + +import { generateEmbeddings } from "./embeddings"; + +describe("generateEmbeddings", () => { + const log = mockLogger(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSleep.mockResolvedValue(undefined); + mockEmbeddingsCreate.mockResolvedValue(embeddingResponse(2)); + }); + + it("calls OpenAI with the embedding model and batched input", async () => { + const reviews = [ + { id: "r1", text: "Great product" }, + { id: "r2", text: "Poor support" }, + ]; + + const result = await generateEmbeddings(reviews, log); + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + model: OPENAI_MODELS.EMBEDDING, + input: ["Great product", "Poor support"], + encoding_format: "float", + }); + expect(result.reviews).toHaveLength(2); + expect(result.reviews[0]?.id).toBe("r1"); + expect(result.totalTokens).toBeGreaterThan(0); + }); + + it("retries on 429 rate limit before succeeding", async () => { + const rateLimitError = Object.assign(new Error("Rate limited"), { + status: 429, + }); + + mockEmbeddingsCreate + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce(embeddingResponse(1)); + + const result = await generateEmbeddings( + [{ id: "r1", text: "One review" }], + log + ); + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2); + expect(mockSleep).toHaveBeenCalled(); + expect(result.reviews).toHaveLength(1); + }); + + it("throws after exhausting embedding retries", async () => { + mockEmbeddingsCreate.mockRejectedValue(new Error("OpenAI unavailable")); + + await expect( + generateEmbeddings([{ id: "r1", text: "Fails" }], log) + ).rejects.toThrow(/failed after 3 attempts/); + }); + + it("chunks large inputs by EMBEDDING_BATCH_SIZE", async () => { + const reviews = Array.from({ length: EMBEDDING_BATCH_SIZE + 5 }, (_, i) => ({ + id: `r-${i}`, + text: `Review ${i}`, + })); + + mockEmbeddingsCreate.mockImplementation(async (args: { input: string[] }) => + embeddingResponse(args.input.length) + ); + + const result = await generateEmbeddings(reviews, log); + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2); + expect(result.reviews).toHaveLength(reviews.length); + }); +}); diff --git a/features/analysis/utils/summarization.test.ts b/features/analysis/utils/summarization.test.ts new file mode 100644 index 0000000..36ac85e --- /dev/null +++ b/features/analysis/utils/summarization.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OPENAI_MODELS } from "@/lib/constants"; +import type { Cluster } from "../types"; +import { + chatJsonResponse, + chatTextResponse, + mockChatCreate, + mockLogger, + mockSleep, + sampleTheme, +} from "../test-helpers"; + +vi.mock("@/lib/openai", () => ({ + openai: { + embeddings: { create: vi.fn() }, + chat: { + completions: { + create: (...args: unknown[]) => mockChatCreate(...args), + }, + }, + }, +})); + +vi.mock("@/lib/utils", async () => { + const actual = await vi.importActual("@/lib/utils"); + return { ...actual, sleep: (...args: unknown[]) => mockSleep(...args) }; +}); + +import { + computeSentimentBreakdown, + generateExecutiveSummary, + summarizeCluster, +} from "./summarization"; + +function makeCluster(overrides: Partial = {}): Cluster { + return { + id: 0, + reviewIds: ["r1", "r2"], + embeddings: [ + [0, 0], + [0.2, 0.1], + ], + texts: ["Battery dies quickly", "Needs charging twice a day"], + centroid: [0, 0], + ...overrides, + }; +} + +describe("summarizeCluster", () => { + const log = mockLogger(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSleep.mockResolvedValue(undefined); + }); + + it("parses JSON theme labels from the chat completion", async () => { + mockChatCreate.mockResolvedValue( + chatJsonResponse({ + label: "Battery life", + description: "Users report short battery life.", + sentiment: "negative", + }) + ); + + const { theme, tokensUsed } = await summarizeCluster(makeCluster(), 10, log); + + expect(mockChatCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: OPENAI_MODELS.SUMMARIZATION, + response_format: { type: "json_object" }, + }) + ); + expect(theme.label).toBe("Battery life"); + expect(theme.sentiment).toBe("negative"); + expect(theme.reviewCount).toBe(2); + expect(tokensUsed).toBeGreaterThan(0); + }); + + it("falls back to neutral theme labels when JSON is invalid", async () => { + mockChatCreate.mockResolvedValue({ + choices: [{ message: { content: "not-json" } }], + usage: { total_tokens: 12, prompt_tokens: 8, completion_tokens: 4 }, + }); + + const { theme } = await summarizeCluster(makeCluster(), 4, log); + + expect(theme.label).toBe("Theme 1"); + expect(theme.sentiment).toBe("neutral"); + }); + + it("returns a fallback theme after LLM errors are exhausted", async () => { + mockChatCreate.mockRejectedValue(new Error("OpenAI down")); + + const { theme, tokensUsed } = await summarizeCluster(makeCluster(), 4, log); + + expect(mockChatCreate).toHaveBeenCalledTimes(3); + expect(theme.label).toBe("Theme 1"); + expect(tokensUsed).toBe(0); + expect(log.error).toHaveBeenCalled(); + }); +}); + +describe("generateExecutiveSummary", () => { + const log = mockLogger(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns trimmed prose from the executive summary completion", async () => { + mockChatCreate.mockResolvedValue( + chatTextResponse(" Customers love the display but worry about battery. ") + ); + + const { summary, tokensUsed } = await generateExecutiveSummary( + [sampleTheme()], + 10, + 4.2, + log + ); + + expect(summary).toBe( + "Customers love the display but worry about battery." + ); + expect(tokensUsed).toBeGreaterThan(0); + expect(mockChatCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: OPENAI_MODELS.SUMMARIZATION }) + ); + }); + + it("returns a safe fallback when executive summary generation fails", async () => { + mockChatCreate.mockRejectedValue(new Error("timeout")); + + const { summary, tokensUsed } = await generateExecutiveSummary( + [sampleTheme()], + 10, + undefined, + log + ); + + expect(summary).toContain("Analysis complete"); + expect(tokensUsed).toBe(0); + }); +}); + +describe("computeSentimentBreakdown", () => { + it("returns zeroed percentages when there are no themes", () => { + expect(computeSentimentBreakdown([])).toEqual({ + positive: 0, + negative: 0, + neutral: 0, + mixed: 0, + }); + }); + + it("weights sentiment by review counts across themes", () => { + const breakdown = computeSentimentBreakdown([ + sampleTheme({ sentiment: "negative", reviewCount: 60 }), + sampleTheme({ + clusterId: 1, + sentiment: "positive", + reviewCount: 40, + }), + ]); + + expect(breakdown.negative).toBe(60); + expect(breakdown.positive).toBe(40); + expect( + breakdown.positive + + breakdown.negative + + breakdown.neutral + + breakdown.mixed + ).toBe(100); + }); +});