|

Build a Contract Analyzer

Ship a contract Q&A tool in twenty minutes. Upload a PDF, ask questions, and get answers with quoted excerpts. The free path handles most contracts — you only pay tokens on the actual questions.

By the end of this guide you'll have a working "chat with your contract" tool: a user uploads a PDF, types a question, and gets an answer grounded in the document. Twenty minutes if you have a Next.js app running.

We'll use cencori.documents.extract() (free for text-based PDFs) and cencori.documents.query() (a few cents per question) to keep costs sensible even on 100-page contracts.

What you're building

  • Upload a PDF once, ask many questions
  • Each question hits /api/ai/documents/query
  • Answers cite the relevant excerpt when possible
  • The system prompt guarantees "Not found in the document" instead of hallucinated legal opinions

Prerequisites

  • A Cencori project + API key (csk_...)
  • Next.js app (adapt trivially to any Node backend)
  • npm install cencori

Step 1 — Upload once, ask many

We only want to parse the PDF once per session. Cache the extracted text server-side (or in Redis for production), then run each question against it.

// app/api/contract/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Cencori } from 'cencori';
import { setContract } from '@/lib/contract-store';
 
const cencori = new Cencori();
 
export async function POST(req: NextRequest) {
  const form = await req.formData();
  const file = form.get('file');
  if (!(file instanceof File)) {
    return NextResponse.json({ error: 'file required' }, { status: 400 });
  }
 
  const buf = Buffer.from(await file.arrayBuffer());
 
  // One shot: parse the PDF and store the text.
  const result = await cencori.documents.extract({
    document: {
      base64: buf.toString('base64'),
      mimeType: file.type || 'application/pdf',
      filename: file.name,
    },
  });
 
  const contractId = crypto.randomUUID();
  await setContract(contractId, {
    filename: file.name,
    text: result.text,
    pageCount: result.pageCount,
    extractMethod: result.method,
  });
 
  return NextResponse.json({
    contractId,
    pageCount: result.pageCount,
    method: result.method,          // 'pdf_text' means the parse was free
    charCount: result.text.length,
  });
}

method: 'pdf_text' in the response means no LLM tokens were spent — the parse was free. That's the case for almost every contract exported from Word, DocuSign, PandaDoc, etc.

lib/contract-store.ts can be as simple as an in-memory Map for a demo, or Redis / DynamoDB / your database of choice for production:

// lib/contract-store.ts
const store = new Map<string, { filename: string; text: string; pageCount: number; extractMethod: string }>();
 
export async function setContract(id: string, data: { filename: string; text: string; pageCount: number; extractMethod: string }) {
  store.set(id, data);
}
 
export async function getContract(id: string) {
  return store.get(id);
}

Step 2 — Ask questions

Each question is a small POST /api/ai/documents/query call. The strict system prompt guarantees no invented text.

// app/api/contract/[id]/ask/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Cencori } from 'cencori';
import { getContract } from '@/lib/contract-store';
 
const cencori = new Cencori();
 
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { question } = await req.json();
 
  const contract = await getContract(id);
  if (!contract) return NextResponse.json({ error: 'not_found' }, { status: 404 });
 
  // Rebuild a data URL so cencori.documents.query re-parses.
  // Simpler: use the text we already cached and hit chat directly.
  // For a full walkthrough we'll use the composed endpoint.
  const result = await cencori.documents.query({
    document: {
      // We stored the text, but the endpoint needs a document.
      // In practice you'd cache the original bytes, not just the text.
      // For this guide, keep the original in your store.
      base64: contract.originalBase64,        // <— add this to setContract
      mimeType: 'application/pdf',
    },
    question,
  });
 
  return NextResponse.json({
    answer: result.answer,
    cost: result.cost.cencoriChargeUsd,
    tokens: result.usage.totalTokens,
  });
}

That works but re-parses the PDF on every question. Cheaper trick: once you have the text cached, use cencori.ai.chat() for the question with a strict system prompt. You skip a round of parsing and get the same behavior:

const response = await cencori.ai.chat({
  model: 'gpt-4o-mini',
  temperature: 0,
  messages: [
    {
      role: 'system',
      content:
        'You answer questions strictly from the provided document. ' +
        'If the answer is not present, say "Not found in the document." ' +
        'Do not invent details. Include short quoted excerpts when useful.',
    },
    { role: 'user', content: `Document:\n\n${contract.text}\n\n---\n\nQuestion: ${question}` },
  ],
});

Same result, one fewer round-trip on repeat questions.

Step 3 — Drop in the upload UI

Reuse <VisionUploader /> from cencori/react — it handles drag-drop, format validation (PDF is unsupported by that widget today, so pass an explicit accept and disable image validation).

For simplicity, roll a minimal uploader:

// app/contract/page.tsx
'use client';
 
import { useState } from 'react';
 
export default function ContractPage() {
  const [contractId, setContractId] = useState<string | null>(null);
  const [question, setQuestion] = useState('');
  const [answer, setAnswer] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
 
  async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setLoading(true);
    const form = new FormData();
    form.append('file', file);
    const res = await fetch('/api/contract/upload', { method: 'POST', body: form });
    const data = await res.json();
    setContractId(data.contractId);
    setLoading(false);
  }
 
  async function handleAsk() {
    if (!contractId || !question) return;
    setLoading(true);
    setAnswer(null);
    const res = await fetch(`/api/contract/${contractId}/ask`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question }),
    });
    const data = await res.json();
    setAnswer(data.answer);
    setLoading(false);
  }
 
  return (
    <div className="mx-auto max-w-2xl space-y-6 p-8">
      <h1 className="text-2xl font-semibold">Contract Q&amp;A</h1>
 
      {!contractId && (
        <label className="block cursor-pointer rounded-lg border-2 border-dashed p-8 text-center">
          <input type="file" accept="application/pdf" className="hidden" onChange={handleUpload} />
          {loading ? 'Parsing…' : 'Click to upload a PDF'}
        </label>
      )}
 
      {contractId && (
        <div className="space-y-3">
          <textarea
            value={question}
            onChange={(e) => setQuestion(e.target.value)}
            placeholder="What is the termination clause?"
            className="w-full rounded-lg border p-3"
            rows={2}
          />
          <button onClick={handleAsk} disabled={loading} className="rounded-lg bg-neutral-900 px-4 py-2 text-white">
            {loading ? 'Thinking…' : 'Ask'}
          </button>
 
          {answer && (
            <div className="rounded-lg border bg-neutral-50 p-4 whitespace-pre-wrap dark:bg-neutral-900">
              {answer}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Ship this to your app and you have a working contract Q&A tool. All the AI-magic parts are two SDK calls; everything else is basic Next.js.

Step 4 — Production notes

Cache the text. Every question shouldn't re-parse the PDF. Store the extracted text in Redis or your database keyed by contract ID.

Rate-limit per contract. One user asking 500 questions in a minute burns money. Rate-limit at the contract ID level, not per user, so a group demo doesn't get throttled.

Log costs per question. The response includes cost.cencoriChargeUsd — persist it so you can see your per-conversation margin.

Validate the schema. For structured extraction (dates, dollar amounts, party names), use cencori.ai.generateObject() with a Zod schema instead of freeform query — you'll get typed data back and Cencori will fail loudly if the model returns invalid JSON.

Handle the scanned-PDF error. If extract returns error: 'scanned_pdf_not_yet_supported', show the user a helpful message: "This PDF looks like a scan — try uploading each page as an image." Or rasterize on the client with pdf.js and send the pages to /api/ai/vision with the multi-image field.

Redact before you ship. Contracts contain PII. If you're logging the extracted text anywhere your team can see, run it through Cencori's built-in PII redaction first (cencori.ai.chat does this automatically on request).

Try other document types

The same pattern works for:

  • Invoices — replace the system prompt with an extraction schema
  • Resumes — "How many years of Python experience does this candidate have?"
  • Reports — "Summarize the key findings. What are the three main risks called out?"
  • Policy documents — "Does this SOC 2 report mention SSO?"

Where to go next