Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand All @@ -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) |
Expand All @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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.

---
Expand Down
199 changes: 199 additions & 0 deletions features/analysis/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
68 changes: 68 additions & 0 deletions features/analysis/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): ThemeAnalysis {
return {
clusterId: 0,
label: "Battery life",
description: "Customers mention short battery life.",
sentiment: "negative",
reviewCount: 5,
percentage: 50,
exampleQuotes: ["Battery dies fast"],
...overrides,
};
}
Loading
Loading