A litigation partner at a 5-attorney firm faces discovery requests on cases with modest budgets. Manual review of thousands of documents is uneconomic, often forcing the firm to settle or go pro se. The partner spends weekends reviewing docs, burning out and missing key evidence. Small matters become unprofitable due to the high cost of discovery.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
Small litigation firms face a brutal math problem: a 5-attorney firm handling document review for a $50k case can’t afford 200 hours of associate time. This recipe builds an AI-powered Discovery Doc Review pipeline that automates OCR, summarization, privilege analysis, cost tracking, and golden-comparison validation — using the Vercel AI SDK with OpenAI on Next.js 16+ App Router. By the end, you’ll have a working document pipeline you can POST documents to and get back structured review results with cost breakdowns.
Upstash Vector account — get a URL and token for UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN
Basic familiarity with Next.js App Router, TypeScript, and the Vercel AI SDK
You’ll build this project incrementally, pasting code blocks as you go. Each file is self-contained — when you’re done, run pnpm test and pnpm typecheck to verify everything works.
Step 1: Scaffold the project
Create a Next.js 16+ project with App Router, TypeScript, and ESLint. Run this in an empty directory:
The flags answer every prompt, so the command runs without interaction. It adds next, react, react-dom, eslint, and typescript to your dependencies.
Next, install the remaining packages. The recipe uses five @reaatech packages for document extraction, golden evaluation, cost telemetry, MCP tools, and RAG evaluation. Pin every version exactly — no ^ or ~:
Add the test script to package.json’s scripts block:
json
"test": "vitest run --coverage --reporter=json --outputFile=vitest-report.json"
Expected output:pnpm typecheck and pnpm lint both exit 0 on the bare scaffold.
Step 2: Configure environment variables
Create .env.example with placeholders for every runtime variable the pipeline reads:
env
# Env vars used by agnostic-discovery-doc-review-assistant.# Keep placeholders only — never commit real values.NODE_ENV=development# Vercel AI SDK — OpenAI providerOPENAI_API_KEY=<your-openai-key># Upstash Vector — document storage and retrievalUPSTASH_VECTOR_REST_URL=<your-upstash-vector-url>UPSTASH_VECTOR_REST_TOKEN=<your-upstash-vector-token># LLM Cost TelemetryDEFAULT_DAILY_BUDGET=100.0OTEL_SERVICE_NAME=discovery-doc-review
Copy it to .env and fill in your real keys:
terminal
cp .env.example .env
Expected output:.env exists with your OPENAI_API_KEY and Upstash credentials filled in.
Step 3: Create shared types, errors, and validation
The pipeline needs shared types for documents, privilege flags, and results. Create src/lib/types.ts:
Next, add error classes in src/lib/errors.ts. The pipeline uses typed errors that carry HTTP status codes and structured details so API routes can return appropriate responses:
Now create input validation schemas with Zod in src/lib/validation.ts:
ts
import { z } from "zod";export const DocumentUploadSchema = z.object({ artifactId: z.string().min(1, "artifactId is required"), caseRef: z.string().optional(),});export const ProcessDocumentSchema = z.object({ artifactId: z.string().min(1, "artifactId is required"), provider: z.string().optional(),});export const PrivilegeAnalysisSchema = z.object({ category: z.enum(["attorney-client", "work-product", "none"]), excerpt: z.string(), confidence: z.number().min(0).max(1),});export type DocumentUploadInput = z.infer<typeof DocumentUploadSchema>;export type ProcessDocumentInput = z.infer<typeof ProcessDocumentSchema>;export type PrivilegeAnalysisOutput = z.infer<typeof PrivilegeAnalysisSchema>;
Finally, add constants in src/lib/constants.ts:
ts
export const DEFAULT_MODEL = "gpt-5.2";export const DEFAULT_MAX_TOKENS = 2048;export const PRIVILEGE_ANALYSIS_PROMPT = `You are a legal document reviewer. Analyze the following document text and determine if it contains:1. Attorney-client privileged communications (confidential legal advice between attorney and client)2. Work-product protection (documents prepared in anticipation of litigation)3. NeitherRespond with the appropriate category, the relevant excerpt, and your confidence level (0-1).`;
Expected output:pnpm typecheck still passes with zero errors.
Step 4: Build the document extraction service and artifact store
The @reaatech/media-pipeline-mcp-doc-extraction package provides OCR, field extraction, summarization, and table extraction operations. First, create an artifact store and registry to manage document data. Create src/services/artifact-store.ts:
Now create the DocumentExtractionService in src/services/document-extraction.ts. It wraps the createDocumentExtractionOperations factory and exposes typed methods:
The extraction service needs a provider to do the actual AI work. Create an adapter that implements the MediaProvider interface using the Vercel AI SDK’s @ai-sdk/openai. Create src/services/openai-provider-adapter.ts:
ts
import { openai } from "@ai-sdk/openai";import { generateText } from "ai";import { MediaProvider, type ProviderInput, type ProviderOutput, type CostEstimate } from "@reaatech/media-pipeline-mcp-provider-core";import { DEFAULT_MODEL } from "../lib/constants.js";export class OpenAIProviderAdapter extends MediaProvider { readonly name: string; readonly supportedOperations: string[] = ["document.ocr", "document.extract_fields", "document.summarize"]; constructor(name: string = "openai") { super(); this.name = name; } estimateCost(_input: ProviderInput): Promise<CostEstimate> { return Promise.resolve({ costUsd: 0.01, currency: "USD" }); } async execute(input: ProviderInput): Promise<ProviderOutput> { const text = typeof input.params.prompt === "string" ? input.params.prompt : "Process this document"; const result = await generateText({ model: openai(DEFAULT_MODEL), system: "You are a document processing assistant. Extract text, tables, and structure from document images accurately. Preserve original formatting where possible.", messages: [{ role: "user", content: text }], }); return { data: Buffer.from(result.text), mimeType: "text/plain", metadata: { operation: input.operation }, }; } getProviderName(): string { return this.name; } async processDocument(args: { artifactId: string; operation: string; input?: string; }): Promise<{ text: string }> { const result = await generateText({ model: openai(DEFAULT_MODEL), system: "You are a document processing assistant. Extract text, tables, and structure from document images accurately.", messages: [{ role: "user", content: args.input ?? "Process this document" }], }); return { text: result.text }; }}
Note the vision-instructing system prompt in the execute method — it tells the model to extract text, tables, and structure from document images accurately.
Expected output: No new type errors. The adapter is a concrete MediaProvider that can be registered with any DocumentExtractionService.
Step 6: Implement privilege analysis with structured output
The core AI feature — analyzing document text for attorney-client privilege and work-product protection — uses the Vercel AI SDK’s structured output. Create src/services/privilege-analyzer.ts:
Output.object({ schema: PrivilegeAnalysisSchema }) tells the AI SDK to return structured JSON matching the Zod schema. The function returns early with a safe default if the input text is empty, avoiding an unnecessary API call.
Expected output:pnpm typecheck passes. The function accepts a string and returns a typed PrivilegeAnalysisOutput.
Step 7: Add cost telemetry tracking
Every LLM call costs money, and small firms need to stay within budget. The @reaatech/llm-cost-telemetry package provides span tracking and budget config. Create src/services/cost-telemetry-service.ts:
ts
import { generateId, now, loadConfig, calculateCostFromTokens, CostSpanSchema, type CostSpan, type TelemetryContext, type BudgetConfig, type BudgetStatus, type CostBreakdown,} from "@reaatech/llm-cost-telemetry";export function createCostSpan( provider: string, model: string, inputTokens: number, outputTokens: number, tenant: string, feature: string,): CostSpan { return { id: generateId(), provider, model, inputTokens, outputTokens, costUsd: calculateCostFromTokens(inputTokens + outputTokens, 30), tenant, feature, timestamp: now(), } as CostSpan;}export function loadTelemetryConfig(): ReturnType<typeof loadConfig> { return loadConfig();}export function validateCostSpan(raw: unknown): CostSpan { return CostSpanSchema.parse(raw);}export type { BudgetConfig, BudgetStatus, CostBreakdown, TelemetryContext };
Also create a pipeline session factory in src/services/pipeline-session.ts that reads the budget config and creates a session with cost tracking:
Expected output:createCostSpan("openai", "gpt-5.2", 500, 200, "case-1", "review") returns a CostSpan with a non-empty id, a number timestamp, and costUsd > 0.
Step 8: Create the golden evaluation and tool registry services
Quality assurance for AI pipelines requires comparing outputs against golden (reference) trajectories. The @reaatech/agent-eval-harness-golden package handles this. Create src/services/golden-evaluation-service.ts:
ts
import { createGolden, compareAgainstGolden, quickCreateGolden, batchCompare,} from "@reaatech/agent-eval-harness-golden";export type TrajectoryComparisonResult = ReturnType<typeof compareAgainstGolden>;export type Regression = TrajectoryComparisonResult["regressions"][number];export type ComparisonConfig = NonNullable<Parameters<typeof compareAgainstGolden>[2]>;export function createGoldenTrajectory( trajectory: unknown, description: string, tags: string[],): ReturnType<typeof quickCreateGolden> { return quickCreateGolden( trajectory as Parameters<typeof quickCreateGolden>[0], description, tags, );}export function createGoldenFromTrajectory( trajectory: unknown, options?: Record<string, unknown>,): ReturnType<typeof createGolden> { return createGolden( trajectory as Parameters<typeof createGolden>[0], options as Parameters<typeof createGolden>[1], );}export function compareWithGolden( golden: unknown, candidate: unknown, config?: ComparisonConfig,): TrajectoryComparisonResult { return compareAgainstGolden( golden as Parameters<typeof compareAgainstGolden>[0], candidate as Parameters<typeof compareAgainstGolden>[1], config, );}export function batchCompareGoldens( golden: unknown, candidates: unknown[], config?: ComparisonConfig,): TrajectoryComparisonResult[] { const results = batchCompare( golden as Parameters<typeof batchCompare>[0], candidates as Parameters<typeof batchCompare>[1], config, ); return results.map((r) => r.result);}
Next, create the tool registry in src/services/tool-registry-service.ts. The @reaatech/mcp-server-tools package provides a tool management system — define review-document and check-privilege tools:
ts
import { defineTool, registerTool, getTools, getTool, discoverTools, clearTools } from "@reaatech/mcp-server-tools";import type { ToolDefinition } from "@reaatech/mcp-server-tools";import { z } from "zod";const reviewDocumentTool = defineTool({ name: "review-document", description: "Summarize and flag privilege in a discovery document", inputSchema: z.object({ artifactId: z.string(), caseRef: z.string().optional(), }), handler: (args: Record<string, unknown>) => { return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ status: "reviewed", artifactId: args.artifactId }) }] }); },});const checkPrivilegeTool = defineTool({ name: "check-privilege", description: "Determine if document content is privileged", inputSchema: z.object({ text: z.string(), }), handler: () => { return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ category: "none", confidence: 1.0 }) }] }); },});export function registerDocumentTools(): void { registerTool(reviewDocumentTool); registerTool(checkPrivilegeTool);}export function listTools(): ToolDefinition[] { return getTools();}export function lookupTool(name: string): ToolDefinition | undefined { return getTool(name);}export async function discoverAndRegisterTools(): Promise<ToolDefinition[]> { return discoverTools();}export function clearRegistry(): void { clearTools();}
Also create a curation service in src/services/curation-service.ts that walks through the full golden trajectory lifecycle:
ts
import { GoldenCurator } from "@reaatech/agent-eval-harness-golden";export function curateGoldenTrajectory(trajectory: unknown): string { const curator = new GoldenCurator( trajectory as ConstructorParameters<typeof GoldenCurator>[0], ); curator.start({}); curator.autoAnnotate(); curator.runQualityChecks(); curator.validate(); curator.publish(); return curator.exportJSONL();}
Expected output:registerDocumentTools() registers both tools, and listTools().length === 2.
Step 9: Build the pipeline orchestrator
Now wire everything together in the DocumentPipeline — the main orchestrator that runs OCR, summarization, privilege analysis, and cost tracking in sequence. Create src/services/document-pipeline.ts:
This orchestrator validates the input, runs OCR and summarization through the extraction service, passes the summary through privilege analysis, creates a cost span tracking total tokens consumed, runs a golden comparison for quality validation, and returns a structured result.
Expected output:new DocumentPipeline() creates an instance without errors. processDocument("doc-1") returns { summary, privilegeFlags, cost }.
Step 10: Add vector storage, evaluation service, and public exports
The pipeline also needs vector search for document similarity and an evaluation service for RAG validation. Create src/services/vector-store-service.ts:
ts
import { Index } from "@upstash/vector";export class VectorStoreService { private index: Index; constructor() { const url = process.env.UPSTASH_VECTOR_REST_URL ?? ""; const token = process.env.UPSTASH_VECTOR_REST_TOKEN ?? ""; if (!url || !token) { throw new Error("UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN must be set"); } this.index = new Index({ url, token }); } async upsertDocument(id: string, vector: number[], metadata: Record<string, string>): Promise<void> { await this.index.upsert({ id, vector, metadata }); } async searchSimilar(vector: number[], topK: number = 5): Promise<Array<{ id: string; score: number; metadata?: Record<string, string> }>> { const results = await this.index.query({ topK: Math.max(1, topK), vector, includeMetadata: true }); return results as Array<{ id: string; score: number; metadata?: Record<string, string> }>; }}
Create src/services/evaluation-service.ts for RAG evaluation sample and config validation:
ts
import { EvaluationSampleSchema, EvalSuiteConfigSchema, type EvaluationSample, type EvalSuiteConfig, type GateConfig, type JudgeConfig, type SampleEvalResult,} from "@reaatech/rag-eval-core";export function validateEvaluationSample(raw: unknown): EvaluationSample { return EvaluationSampleSchema.parse(raw);}export function validateSuiteConfig(raw: unknown): EvalSuiteConfig { return EvalSuiteConfigSchema.parse(raw);}export type { GateConfig, JudgeConfig, SampleEvalResult };
Finally, wire everything together in src/index.ts — the public entry point that re-exports every service, type, and REAA primitive:
ts
export type { DocumentMetadata, ReviewResult, PrivilegeFlag, PipelineStatus, DocumentStatus, PrivilegeCategory, CostBreakdown, ProcessedDocument,} from "./lib/types.js";export { PipelineError, DocumentNotFoundError, ExtractionFailedError, ValidationError,} from "./lib/errors.js";export { DocumentExtractionService } from "./services/document-extraction.js";export { validateEvaluationSample, validateSuiteConfig } from "./services/evaluation-service.js";export { createGoldenTrajectory, compareWithGolden, batchCompareGoldens,} from "./services/golden-evaluation-service.js";export { createCostSpan, loadTelemetryConfig, validateCostSpan,} from "./services/cost-telemetry-service.js";export { registerDocumentTools, listTools, lookupTool, clearRegistry,} from "./services/tool-registry-service.js";export { analyzePrivilege } from "./services/privilege-analyzer.js";export { VectorStoreService } from "./services/vector-store-service.js";export { DocumentPipeline } from "./services/document-pipeline.js";export { createPipelineSession } from "./services/pipeline-session.js";export { curateGoldenTrajectory } from "./services/curation-service.js";export { defineTool, registerTool, getTools, getTool, clearTools,} from "@reaatech/mcp-server-tools";export { createGolden, compareAgainstGolden, quickCreateGolden, batchCompare,} from "@reaatech/agent-eval-harness-golden";export { EvaluationSampleSchema, EvalSuiteConfigSchema,} from "@reaatech/rag-eval-core";export { generateId, now, loadConfig, calculateCostFromTokens,} from "@reaatech/llm-cost-telemetry";export * as z from "zod";
Expected output:pnpm typecheck still passes, and every export is resolvable.
Step 11: Create Next.js API routes
Next.js App Router API routes expose the pipeline over HTTP. Create the documents CRUD routes first — app/api/documents/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import crypto from "node:crypto";import { z } from "zod";import { DocumentUploadSchema } from "@/src/lib/validation.js";import type { DocumentStatus } from "@/src/lib/types.js";const documents = new Map<string, { id: string; status: DocumentStatus; createdAt: Date }>();export async function POST(req: NextRequest) { try { const body: unknown = await req.json(); DocumentUploadSchema.parse(body); const id = crypto.randomUUID(); documents.set(id, { id, status: "pending", createdAt: new Date() }); return NextResponse.json({ id, status: "pending" }, { status: 201 }); } catch (error: unknown) { if (error instanceof z.ZodError) { return NextResponse.json({ error: "Invalid document data", details: error.issues }, { status: 400 }); } throw error; }}export function GET(req: NextRequest) { const status = req.nextUrl.searchParams.get("status"); let result = Array.from(documents.values()); if (status) { result = result.filter((doc) => doc.status === status); } return NextResponse.json(result);}
Expected output: 105 tests pass (all suites green), coverage thresholds met. Runs in under 60 seconds.
Step 13: Create frontend pages
Update the layout metadata in app/layout.tsx:
tsx
import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css";const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"],});const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"],});export const metadata: Metadata = { title: "AI Discovery Doc Review", description: "Automated document summarization and privilege flagging for small litigation firms",};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode;}>) { return ( <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body> </html> );}
Replace the home page in app/page.tsx:
tsx
import Link from "next/link";export default function Home() { return ( <div> <h1>AI Discovery Doc Review</h1> <p>Make document review economic for small cases</p> <p> Small litigation firms face prohibitive costs when reviewing discovery documents. This tool automates summarization and privilege flagging using AI, making document review accessible and affordable for small cases. Upload documents, get instant privilege analysis, and track costs — all within budget. </p> <ul> <li><Link href="/api/health">Health Check</Link></li> <li><Link href="/api/review">Reviews</Link></li> </ul> </div> );}
Expected output: Running pnpm dev and opening http://localhost:3000 shows the landing page with links to Health Check and Reviews.
Next steps
Add authentication — integrate NextAuth.js or Clerk to restrict document access to authorized firm members and enforce per-case access controls
Build a document upload UI — create a drag-and-drop upload form in app/upload/page.tsx that sends documents to POST /api/documents and then triggers processing
Add batch processing — extend DocumentPipeline to accept multiple artifactId values and run them concurrently, merging results into a single review report
Implement golden regression monitoring — set up a cron job that compares today’s pipeline outputs against a curated golden set and alerts when passesThreshold drops below 0.9