Code examples:

Building an AI Contract Review Assistant

Part 1: Multi-Agent Architecture for Legal Document Analysis

January 21, 2026 13 min read By Jaffar Kazi
AI Development Legal Tech Multi-Agent
Python C# Azure

A $2M acquisition nearly fell through because a junior associate missed a change-of-control clause buried on page 47.

I heard this story from a legal ops director at a mid-sized firm. The clause would have triggered automatic termination of a key vendor contract upon acquisition. They caught it during due diligence—barely. The deal closed, but it cost them three weeks of renegotiation and $80,000 in additional legal fees.

This isn't an edge case. Legal teams spend 60-80% of their time on routine contract review. Manual review is slow (4-8 hours per contract), expensive ($500-2,000 per contract), and error-prone. Missing a single unfavorable clause can cost thousands—or millions.

In this article, I'll show you how to build an AI-powered contract review assistant using a multi-agent architecture. We'll extract clauses, analyze risks, and track obligations automatically—reducing review time from 6 hours to 45 minutes.

What You'll Learn

  • Why multi-agent architecture works for contract analysis
  • How to parse and chunk legal documents effectively
  • Building clause extraction with structured LLM outputs
  • Risk analysis and obligation tracking patterns
  • Full implementation in Python (LangGraph) and C# (Semantic Kernel)

Reading time: 13 minutes | Implementation time: 2-3 days

Current Approach & Limitations

Here's how contract review typically works today:

  1. Paralegal reads the entire document (2-4 hours)
  2. Flags issues in a spreadsheet or Word comments
  3. Attorney reviews the flags (1-2 hours)
  4. Redlines sent to counterparty
  5. Repeat 2-3 times until signing

The costs add up quickly:

Role Hourly Rate Hours per Contract Cost
Junior Associate $200-400 4-6 $800-2,400
Senior Associate $400-600 1-2 $400-1,200
Partner (complex) $800+ 0.5-1 $400-800
Total per contract $1,600-4,400

Beyond cost, there are quality issues:

  • Fatigue errors: Attention drops after page 30
  • Inconsistency: Different reviewers flag different risks
  • No memory: Each review starts from scratch, even for similar contracts
  • Bottlenecks: Senior attorneys become the constraint

Industries Most Affected

Industry Contract Types Key Pain Points
Legal Services All types Volume, billable hour pressure, consistency
Real Estate Leases, purchase agreements Repetitive terms, tight deal timelines
Healthcare Vendor, BAA, provider agreements HIPAA compliance, liability exposure
Financial Services Loan docs, service agreements Regulatory requirements, audit trails
Technology/SaaS MSAs, DPAs, NDAs IP protection, data privacy, liability caps
Manufacturing Supply chain, procurement Payment terms, force majeure, warranties
HR/Staffing Employment, contractor Non-compete, IP assignment, termination

The Solution: Multi-Agent Contract Review

Instead of one massive prompt trying to do everything, we use specialized agents that each handle one aspect of contract analysis:

Agent Responsibility Output
Document Parser Extract text, identify sections, handle PDFs/DOCX Structured sections with metadata
Clause Extractor Identify and categorize contract clauses Labeled clauses with page references
Risk Analyzer Flag unfavorable terms, unusual language Risk scores with explanations
Obligation Tracker Extract deadlines, payment terms, deliverables Obligation list with dates
Report Generator Assemble findings into executive summary Structured report

Why Multi-Agent Instead of One Prompt?

Complex prompts become unpredictable. When you ask one LLM to simultaneously parse structure, classify clauses, assess risk, and track obligations, you get inconsistent results. Each agent has:

  • Focused scope: One job, done well
  • Tunable prompts: Optimize each independently
  • Debuggable output: Trace issues to specific agents
  • Extensible design: Add new agents without rewriting everything

Tech stack we'll use:

  • Azure OpenAI GPT-4o — Reasoning and classification
  • Azure Document Intelligence — PDF/DOCX extraction with structure
  • Python + LangGraph — Agent orchestration (open source)
  • C# + Semantic Kernel — Enterprise .NET alternative
  • Azure AI Search — Optional: compare against clause library

Architecture Overview

Here's how the agents work together:

Flowchart diagram of multi-agent contract review assistant architecture

Multi-agent pipeline: each agent has a focused responsibility

The flow is sequential with a parallel branch:

  1. Parse: Document uploaded, converted to structured sections
  2. Extract: Each section analyzed for clause type and content
  3. Analyze (parallel): Risk assessment and obligation tracking run simultaneously
  4. Report: All findings consolidated into final output

Core Implementation

Let's build this step by step. First, we need to define our data models.

Contract State Model

The state flows through all agents and accumulates results:

models/contract_state.py
from pydantic import BaseModel
from typing import List, Optional
from enum import Enum

class ClauseType(str, Enum):
    INDEMNIFICATION = "indemnification"
    LIABILITY_CAP = "liability_cap"
    TERMINATION = "termination"
    IP_OWNERSHIP = "ip_ownership"
    CONFIDENTIALITY = "confidentiality"
    PAYMENT_TERMS = "payment_terms"
    FORCE_MAJEURE = "force_majeure"
    GOVERNING_LAW = "governing_law"
    DATA_PROTECTION = "data_protection"
    NON_COMPETE = "non_compete"
    WARRANTY = "warranty"
    LIMITATION_OF_LIABILITY = "limitation_of_liability"

class RiskLevel(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"

class ExtractedClause(BaseModel):
    clause_type: ClauseType
    text: str
    page_number: int
    section_reference: str
    risk_level: Optional[RiskLevel] = None
    risk_reason: Optional[str] = None
    recommendations: List[str] = []

class Obligation(BaseModel):
    description: str
    party: str  # "us" or "them" or party name
    due_date: Optional[str] = None
    is_recurring: bool = False
    source_clause: str

class ContractState(BaseModel):
    document_id: str
    filename: str
    contract_type: str  # NDA, MSA, Employment, etc.
    parties: List[str]
    effective_date: Optional[str] = None
    expiration_date: Optional[str] = None
    sections: List[dict] = []
    extracted_clauses: List[ExtractedClause] = []
    obligations: List[Obligation] = []
    overall_risk_score: Optional[float] = None
    risk_summary: Optional[str] = None
    executive_summary: Optional[str] = None
Models/ContractState.cs
using System.Text.Json.Serialization;

public enum ClauseType
{
    Indemnification,
    LiabilityCap,
    Termination,
    IpOwnership,
    Confidentiality,
    PaymentTerms,
    ForceMajeure,
    GoverningLaw,
    DataProtection,
    NonCompete,
    Warranty,
    LimitationOfLiability
}

public enum RiskLevel
{
    Low,
    Medium,
    High,
    Critical
}

public record ExtractedClause(
    ClauseType ClauseType,
    string Text,
    int PageNumber,
    string SectionReference,
    RiskLevel? RiskLevel = null,
    string? RiskReason = null)
{
    public List<string> Recommendations { get; init; } = new();
}

public record Obligation(
    string Description,
    string Party,
    string? DueDate = null,
    bool IsRecurring = false,
    string SourceClause = "");

public record ContractState
{
    public required string DocumentId { get; init; }
    public required string Filename { get; init; }
    public required string ContractType { get; init; }
    public List<string> Parties { get; init; } = new();
    public string? EffectiveDate { get; init; }
    public string? ExpirationDate { get; init; }
    public List<DocumentSection> Sections { get; init; } = new();
    public List<ExtractedClause> ExtractedClauses { get; init; } = new();
    public List<Obligation> Obligations { get; init; } = new();
    public double? OverallRiskScore { get; init; }
    public string? RiskSummary { get; init; }
    public string? ExecutiveSummary { get; init; }
}

Orchestrator: Wiring the Agents Together

The orchestrator defines the graph of agents and manages state flow:

orchestrator.py
from langgraph.graph import StateGraph, END
from typing import TypedDict, List
from agents import (
    parse_document_agent,
    clause_extractor_agent,
    risk_analyzer_agent,
    obligation_tracker_agent,
    report_generator_agent
)

class GraphState(TypedDict):
    """State passed between all agents."""
    contract: ContractState
    processing_errors: List[str]

def build_contract_review_graph() -> StateGraph:
    """Build the multi-agent contract review pipeline."""
    workflow = StateGraph(GraphState)

    # Add agent nodes
    workflow.add_node("parse_document", parse_document_agent)
    workflow.add_node("extract_clauses", clause_extractor_agent)
    workflow.add_node("analyze_risks", risk_analyzer_agent)
    workflow.add_node("track_obligations", obligation_tracker_agent)
    workflow.add_node("generate_report", report_generator_agent)

    # Define the flow
    workflow.set_entry_point("parse_document")

    # Sequential: parse -> extract
    workflow.add_edge("parse_document", "extract_clauses")

    # Parallel: after extraction, run risk + obligations
    workflow.add_edge("extract_clauses", "analyze_risks")
    workflow.add_edge("extract_clauses", "track_obligations")

    # Both feed into report generation
    workflow.add_edge("analyze_risks", "generate_report")
    workflow.add_edge("track_obligations", "generate_report")

    # End after report
    workflow.add_edge("generate_report", END)

    return workflow.compile()

# Usage
async def review_contract(document_bytes: bytes, filename: str):
    graph = build_contract_review_graph()

    initial_state = {
        "contract": ContractState(
            document_id=str(uuid.uuid4()),
            filename=filename,
            contract_type="unknown",
            parties=[]
        ),
        "processing_errors": []
    }

    result = await graph.ainvoke(initial_state)
    return result["contract"]
Orchestrator/ContractReviewOrchestrator.cs
using Microsoft.SemanticKernel;

public class ContractReviewOrchestrator
{
    private readonly Kernel _kernel;
    private readonly IDocumentParserAgent _documentParser;
    private readonly IClauseExtractorAgent _clauseExtractor;
    private readonly IRiskAnalyzerAgent _riskAnalyzer;
    private readonly IObligationTrackerAgent _obligationTracker;
    private readonly IReportGeneratorAgent _reportGenerator;

    public ContractReviewOrchestrator(
        Kernel kernel,
        IDocumentParserAgent documentParser,
        IClauseExtractorAgent clauseExtractor,
        IRiskAnalyzerAgent riskAnalyzer,
        IObligationTrackerAgent obligationTracker,
        IReportGeneratorAgent reportGenerator)
    {
        _kernel = kernel;
        _documentParser = documentParser;
        _clauseExtractor = clauseExtractor;
        _riskAnalyzer = riskAnalyzer;
        _obligationTracker = obligationTracker;
        _reportGenerator = reportGenerator;
    }

    public async Task<ContractState> ReviewContractAsync(
        Stream documentStream,
        string filename,
        CancellationToken ct = default)
    {
        // Step 1: Parse document structure
        var state = await _documentParser.ParseAsync(
            documentStream, filename, ct);

        // Step 2: Extract and classify clauses
        state = await _clauseExtractor.ExtractAsync(state, ct);

        // Step 3: Parallel analysis
        var riskTask = _riskAnalyzer.AnalyzeAsync(state, ct);
        var obligationTask = _obligationTracker.TrackAsync(state, ct);

        await Task.WhenAll(riskTask, obligationTask);

        // Merge results
        state = state with
        {
            ExtractedClauses = riskTask.Result.ExtractedClauses,
            Obligations = obligationTask.Result.Obligations,
            OverallRiskScore = riskTask.Result.OverallRiskScore
        };

        // Step 4: Generate final report
        state = await _reportGenerator.GenerateAsync(state, ct);

        return state;
    }
}

Challenge #1: Document Chunking

Contracts are 20-100+ pages. You can't send the entire document to an LLM in one request. But naive chunking breaks context—a clause might reference "Section 4.2" or "as defined above."

The Solution: Structure-Aware Chunking

Azure Document Intelligence extracts not just text, but document structure. We use this to chunk intelligently:

agents/document_parser.py
from azure.ai.documentintelligence import DocumentIntelligenceClient
from azure.core.credentials import AzureKeyCredential
from langchain.text_splitter import RecursiveCharacterTextSplitter

class DocumentParserAgent:
    def __init__(self, endpoint: str, key: str):
        self.client = DocumentIntelligenceClient(
            endpoint=endpoint,
            credential=AzureKeyCredential(key)
        )
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=2000,
            chunk_overlap=200,
            separators=["\n\n", "\n", ". ", " "]
        )

    async def parse(self, document: bytes, filename: str) -> ContractState:
        # Use prebuilt-contract model for legal documents
        poller = await self.client.begin_analyze_document(
            "prebuilt-contract",
            document
        )
        result = await poller.result()

        # Extract parties and dates from document fields
        parties = []
        if result.documents:
            doc = result.documents[0]
            if doc.fields.get("Parties"):
                parties = [p.value for p in doc.fields["Parties"].value]

        # Process sections with structure awareness
        sections = []
        for section in result.sections or []:
            # Chunk large sections while preserving metadata
            section_text = section.content
            if len(section_text) > 2000:
                chunks = self.splitter.split_text(section_text)
                for i, chunk in enumerate(chunks):
                    sections.append({
                        "title": section.title or f"Section {len(sections)+1}",
                        "content": chunk,
                        "page_numbers": list(section.bounding_regions[0].page_number
                                            if section.bounding_regions else []),
                        "chunk_index": i,
                        "is_continuation": i > 0
                    })
            else:
                sections.append({
                    "title": section.title or f"Section {len(sections)+1}",
                    "content": section_text,
                    "page_numbers": list(section.bounding_regions[0].page_number
                                        if section.bounding_regions else []),
                    "chunk_index": 0,
                    "is_continuation": False
                })

        return ContractState(
            document_id=str(uuid.uuid4()),
            filename=filename,
            contract_type=self._detect_contract_type(result),
            parties=parties,
            sections=sections
        )

    def _detect_contract_type(self, result) -> str:
        """Infer contract type from content."""
        text_lower = result.content.lower()[:5000]
        if "non-disclosure" in text_lower or "confidential information" in text_lower:
            return "NDA"
        elif "master service" in text_lower or "statement of work" in text_lower:
            return "MSA"
        elif "employment" in text_lower and "employee" in text_lower:
            return "Employment"
        elif "lease" in text_lower and ("landlord" in text_lower or "tenant" in text_lower):
            return "Lease"
        elif "software license" in text_lower or "saas" in text_lower:
            return "SaaS Agreement"
        return "General Contract"
Agents/DocumentParserAgent.cs
using Azure;
using Azure.AI.DocumentIntelligence;

public class DocumentParserAgent : IDocumentParserAgent
{
    private readonly DocumentIntelligenceClient _client;
    private const int MaxChunkSize = 2000;
    private const int ChunkOverlap = 200;

    public DocumentParserAgent(string endpoint, string key)
    {
        _client = new DocumentIntelligenceClient(
            new Uri(endpoint),
            new AzureKeyCredential(key));
    }

    public async Task<ContractState> ParseAsync(
        Stream document,
        string filename,
        CancellationToken ct = default)
    {
        var operation = await _client.AnalyzeDocumentAsync(
            WaitUntil.Completed,
            "prebuilt-contract",
            document,
            cancellationToken: ct);

        var result = operation.Value;

        // Extract parties from document fields
        var parties = new List<string>();
        if (result.Documents?.FirstOrDefault()?.Fields
            .TryGetValue("Parties", out var partiesField) == true)
        {
            parties = partiesField.Value
                .AsArray()
                .Select(p => p.AsString())
                .ToList();
        }

        // Process sections with chunking
        var sections = new List<DocumentSection>();
        foreach (var paragraph in result.Paragraphs ?? Array.Empty<DocumentParagraph>())
        {
            var content = paragraph.Content;
            var pageNumber = paragraph.BoundingRegions?
                .FirstOrDefault()?.PageNumber ?? 1;

            if (content.Length > MaxChunkSize)
            {
                var chunks = ChunkWithOverlap(content);
                for (int i = 0; i < chunks.Count; i++)
                {
                    sections.Add(new DocumentSection(
                        Title: paragraph.Role ?? $"Section {sections.Count + 1}",
                        Content: chunks[i],
                        PageNumber: pageNumber,
                        ChunkIndex: i,
                        IsContinuation: i > 0
                    ));
                }
            }
            else
            {
                sections.Add(new DocumentSection(
                    Title: paragraph.Role ?? $"Section {sections.Count + 1}",
                    Content: content,
                    PageNumber: pageNumber,
                    ChunkIndex: 0,
                    IsContinuation: false
                ));
            }
        }

        return new ContractState
        {
            DocumentId = Guid.NewGuid().ToString(),
            Filename = filename,
            ContractType = DetectContractType(result.Content),
            Parties = parties,
            Sections = sections
        };
    }

    private List<string> ChunkWithOverlap(string text)
    {
        var chunks = new List<string>();
        var sentences = text.Split(new[] { ". ", ".\n" },
            StringSplitOptions.RemoveEmptyEntries);

        var currentChunk = new StringBuilder();
        foreach (var sentence in sentences)
        {
            if (currentChunk.Length + sentence.Length > MaxChunkSize)
            {
                chunks.Add(currentChunk.ToString().Trim());
                // Keep overlap from end of previous chunk
                var overlap = currentChunk.ToString()
                    .TakeLast(ChunkOverlap).ToString();
                currentChunk.Clear();
                currentChunk.Append(overlap);
            }
            currentChunk.Append(sentence).Append(". ");
        }

        if (currentChunk.Length > 0)
            chunks.Add(currentChunk.ToString().Trim());

        return chunks;
    }

    private string DetectContractType(string content)
    {
        var textLower = content.ToLower()[..Math.Min(5000, content.Length)];

        return textLower switch
        {
            var t when t.Contains("non-disclosure") => "NDA",
            var t when t.Contains("master service") => "MSA",
            var t when t.Contains("employment") && t.Contains("employee") => "Employment",
            var t when t.Contains("lease") && t.Contains("landlord") => "Lease",
            var t when t.Contains("software license") || t.Contains("saas") => "SaaS Agreement",
            _ => "General Contract"
        };
    }
}

Why Structure-Aware Chunking Matters

Naive text splitting might cut a liability clause in half, making it impossible to assess. By using Document Intelligence's section detection, we keep logical units together. The is_continuation flag tells downstream agents when they're seeing part of a larger clause.

Challenge #2: Clause Classification

The same clause type can be worded dozens of different ways:

  • "shall indemnify and hold harmless" = Indemnification
  • "aggregate liability shall not exceed" = Liability Cap
  • "either party may terminate upon 30 days written notice" = Termination

We need reliable classification that works across contract types and drafting styles.

The Solution: Structured Output with Few-Shot Examples

agents/clause_extractor.py
from openai import AzureOpenAI
from pydantic import BaseModel
from typing import List

CLAUSE_CLASSIFICATION_PROMPT = """
You are a legal contract analyst. Classify the following clause into ONE of these categories:

Categories:
- indemnification: One party agrees to protect another from losses, claims, damages
- liability_cap: Limits on total damages or liability amounts
- termination: Conditions for ending the contract, notice periods
- ip_ownership: Who owns intellectual property, work product, inventions
- confidentiality: Protection of confidential or proprietary information
- payment_terms: Payment amounts, schedules, late fees, invoicing
- data_protection: GDPR, privacy, data handling, security requirements
- force_majeure: Unforeseeable circumstances, acts of God, pandemic clauses
- governing_law: Which jurisdiction's laws apply, venue for disputes
- non_compete: Restrictions on competitive activities during/after contract
- warranty: Guarantees about quality, performance, fitness for purpose
- limitation_of_liability: Exclusions of consequential, indirect damages
- other: Does not fit any category above

Examples:
- "Vendor shall indemnify Client against all claims arising from..." → indemnification
- "In no event shall either party's liability exceed the fees paid..." → liability_cap
- "This Agreement may be terminated by either party with 30 days notice" → termination
- "All inventions created during employment shall be owned by Company" → ip_ownership

Clause to classify:
{clause_text}

Respond with JSON only:
{{"clause_type": "", "confidence": <0.0-1.0>, "key_terms": ["term1", "term2"]}}
"""

class ClauseClassification(BaseModel):
    clause_type: str
    confidence: float
    key_terms: List[str]

class ClauseExtractorAgent:
    def __init__(self, azure_client: AzureOpenAI):
        self.client = azure_client

    async def extract(self, state: ContractState) -> ContractState:
        extracted_clauses = []

        for section in state.sections:
            # Skip very short sections (likely headers)
            if len(section["content"]) < 100:
                continue

            classification = await self._classify_clause(section["content"])

            # Only keep high-confidence classifications
            if classification.confidence >= 0.7:
                extracted_clauses.append(ExtractedClause(
                    clause_type=ClauseType(classification.clause_type),
                    text=section["content"],
                    page_number=section["page_numbers"][0] if section["page_numbers"] else 0,
                    section_reference=section["title"]
                ))

        state.extracted_clauses = extracted_clauses
        return state

    async def _classify_clause(self, text: str) -> ClauseClassification:
        response = await self.client.chat.completions.create(
            model="gpt-4o",
            messages=[{
                "role": "user",
                "content": CLAUSE_CLASSIFICATION_PROMPT.format(clause_text=text[:1500])
            }],
            response_format={"type": "json_object"},
            temperature=0,  # Deterministic for consistency
            max_tokens=100
        )

        result = json.loads(response.choices[0].message.content)
        return ClauseClassification(**result)
Agents/ClauseExtractorAgent.cs
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using System.Text.Json;

public class ClauseExtractorAgent : IClauseExtractorAgent
{
    private readonly IChatCompletionService _chat;
    private const string ClassificationPrompt = """
        You are a legal contract analyst. Classify the following clause into ONE category.

        Categories:
        - indemnification: Party protects another from losses/claims
        - liability_cap: Limits on total damages or liability
        - termination: Conditions for ending contract, notice periods
        - ip_ownership: Ownership of intellectual property, work product
        - confidentiality: Protection of confidential information
        - payment_terms: Payment amounts, schedules, late fees
        - data_protection: GDPR, privacy, data handling requirements
        - force_majeure: Unforeseeable circumstances clauses
        - governing_law: Jurisdiction, venue for disputes
        - non_compete: Restrictions on competitive activities
        - warranty: Guarantees about quality, performance
        - limitation_of_liability: Exclusions of consequential damages
        - other: Does not fit any category

        Clause to classify:
        {0}

        Respond with JSON only:
        {{"clause_type": "", "confidence": <0.0-1.0>, "key_terms": ["term1", "term2"]}}
        """;

    public ClauseExtractorAgent(IChatCompletionService chat)
    {
        _chat = chat;
    }

    public async Task<ContractState> ExtractAsync(
        ContractState state,
        CancellationToken ct = default)
    {
        var extractedClauses = new List<ExtractedClause>();

        foreach (var section in state.Sections)
        {
            // Skip short sections (likely headers)
            if (section.Content.Length < 100)
                continue;

            var classification = await ClassifyClauseAsync(
                section.Content, ct);

            // Only keep high-confidence classifications
            if (classification.Confidence >= 0.7)
            {
                var clauseType = Enum.Parse<ClauseType>(
                    classification.ClauseType, ignoreCase: true);

                extractedClauses.Add(new ExtractedClause(
                    ClauseType: clauseType,
                    Text: section.Content,
                    PageNumber: section.PageNumber,
                    SectionReference: section.Title
                ));
            }
        }

        return state with { ExtractedClauses = extractedClauses };
    }

    private async Task<ClauseClassification> ClassifyClauseAsync(
        string text,
        CancellationToken ct)
    {
        var prompt = string.Format(ClassificationPrompt,
            text[..Math.Min(1500, text.Length)]);

        var settings = new PromptExecutionSettings
        {
            ExtensionData = new Dictionary<string, object>
            {
                ["temperature"] = 0,
                ["max_tokens"] = 100,
                ["response_format"] = new { type = "json_object" }
            }
        };

        var result = await _chat.GetChatMessageContentAsync(
            prompt, settings, cancellationToken: ct);

        return JsonSerializer.Deserialize<ClauseClassification>(
            result.Content)!;
    }
}

public record ClauseClassification(
    string ClauseType,
    double Confidence,
    List<string> KeyTerms
);

Key techniques for reliable classification:

  • Temperature 0: Makes outputs deterministic and consistent
  • JSON response format: Structured output prevents parsing errors
  • Confidence scores: Filter low-confidence results for human review
  • Few-shot examples: Ground the model in expected behavior

Risk Analysis Agent

Once we have classified clauses, we need to assess their risk. The Risk Analyzer evaluates each clause against standard benchmarks:

agents/risk_analyzer.py
RISK_ANALYSIS_PROMPT = """
You are a legal risk analyst. Analyze this {clause_type} clause for potential risks.

Contract type: {contract_type}
Our position: {our_position}  # "vendor" or "customer"

Clause text:
{clause_text}

Evaluate against these criteria:
1. Is this clause one-sided or balanced?
2. Are there unusual or non-standard terms?
3. What financial exposure does this create?
4. Are there missing protections we should have?

Risk levels:
- low: Standard clause, balanced, minimal exposure
- medium: Slightly unfavorable but common, manageable exposure
- high: Significantly unfavorable, material exposure
- critical: Extremely one-sided, major liability risk

Respond with JSON:
{{
    "risk_level": "",
    "risk_reason": "<2-3 sentence explanation>",
    "financial_exposure": "",
    "recommendations": ["", ""]
}}
"""

class RiskAnalyzerAgent:
    def __init__(self, azure_client: AzureOpenAI):
        self.client = azure_client

    async def analyze(self, state: ContractState) -> ContractState:
        analyzed_clauses = []
        risk_scores = []

        for clause in state.extracted_clauses:
            risk_result = await self._analyze_clause_risk(
                clause=clause,
                contract_type=state.contract_type,
                our_position="customer"  # Configure based on context
            )

            analyzed_clause = ExtractedClause(
                clause_type=clause.clause_type,
                text=clause.text,
                page_number=clause.page_number,
                section_reference=clause.section_reference,
                risk_level=RiskLevel(risk_result["risk_level"]),
                risk_reason=risk_result["risk_reason"],
                recommendations=risk_result["recommendations"]
            )
            analyzed_clauses.append(analyzed_clause)

            # Convert to numeric score for overall calculation
            risk_scores.append(self._risk_to_score(risk_result["risk_level"]))

        # Calculate overall risk score (0-100)
        overall_score = sum(risk_scores) / len(risk_scores) * 25 if risk_scores else 0

        state.extracted_clauses = analyzed_clauses
        state.overall_risk_score = overall_score
        state.risk_summary = self._generate_risk_summary(analyzed_clauses)

        return state

    def _risk_to_score(self, level: str) -> int:
        return {"low": 1, "medium": 2, "high": 3, "critical": 4}.get(level, 2)

    async def _analyze_clause_risk(self, clause, contract_type, our_position) -> dict:
        response = await self.client.chat.completions.create(
            model="gpt-4o",
            messages=[{
                "role": "user",
                "content": RISK_ANALYSIS_PROMPT.format(
                    clause_type=clause.clause_type.value,
                    contract_type=contract_type,
                    our_position=our_position,
                    clause_text=clause.text[:2000]
                )
            }],
            response_format={"type": "json_object"},
            temperature=0.1
        )
        return json.loads(response.choices[0].message.content)
Agents/RiskAnalyzerAgent.cs
public class RiskAnalyzerAgent : IRiskAnalyzerAgent
{
    private readonly IChatCompletionService _chat;

    private const string RiskPrompt = """
        You are a legal risk analyst. Analyze this {0} clause for potential risks.

        Contract type: {1}
        Our position: {2}

        Clause text:
        {3}

        Evaluate against these criteria:
        1. Is this clause one-sided or balanced?
        2. Are there unusual or non-standard terms?
        3. What financial exposure does this create?
        4. Are there missing protections we should have?

        Risk levels:
        - low: Standard clause, balanced, minimal exposure
        - medium: Slightly unfavorable but common, manageable exposure
        - high: Significantly unfavorable, material exposure
        - critical: Extremely one-sided, major liability risk

        Respond with JSON:
        {{
            "risk_level": "",
            "risk_reason": "<2-3 sentence explanation>",
            "financial_exposure": "",
            "recommendations": ["", ""]
        }}
        """;

    public async Task<ContractState> AnalyzeAsync(
        ContractState state,
        CancellationToken ct = default)
    {
        var analyzedClauses = new List<ExtractedClause>();
        var riskScores = new List<int>();

        foreach (var clause in state.ExtractedClauses)
        {
            var result = await AnalyzeClauseRiskAsync(
                clause, state.ContractType, "customer", ct);

            var riskLevel = Enum.Parse<RiskLevel>(result.RiskLevel, true);

            analyzedClauses.Add(clause with
            {
                RiskLevel = riskLevel,
                RiskReason = result.RiskReason,
                Recommendations = result.Recommendations
            });

            riskScores.Add(RiskToScore(result.RiskLevel));
        }

        var overallScore = riskScores.Count > 0
            ? riskScores.Average() * 25
            : 0;

        return state with
        {
            ExtractedClauses = analyzedClauses,
            OverallRiskScore = overallScore,
            RiskSummary = GenerateRiskSummary(analyzedClauses)
        };
    }

    private int RiskToScore(string level) => level.ToLower() switch
    {
        "low" => 1,
        "medium" => 2,
        "high" => 3,
        "critical" => 4,
        _ => 2
    };

    private async Task<RiskAnalysisResult> AnalyzeClauseRiskAsync(
        ExtractedClause clause,
        string contractType,
        string ourPosition,
        CancellationToken ct)
    {
        var prompt = string.Format(RiskPrompt,
            clause.ClauseType,
            contractType,
            ourPosition,
            clause.Text[..Math.Min(2000, clause.Text.Length)]);

        var result = await _chat.GetChatMessageContentAsync(prompt,
            cancellationToken: ct);

        return JsonSerializer.Deserialize<RiskAnalysisResult>(result.Content)!;
    }
}

public record RiskAnalysisResult(
    string RiskLevel,
    string RiskReason,
    string FinancialExposure,
    List<string> Recommendations
);

ROI & Business Value

Let's look at the real numbers:

Contract Review ROI Calculator

Metric Before (Manual) After (AI-Assisted) Improvement
Time per contract 6 hours 45 minutes 8x faster
Cost per contract $1,500 $150 90% reduction
Accuracy (clause detection) 85% 94% +9 points
Contracts per person per day 1.3 8 6x throughput

Break-Even Analysis

  • Development cost: ~$50,000 (2-3 months, 1 developer)
  • Monthly infrastructure: ~$500
  • Savings per contract: ~$1,350
  • Break-even: 40 contracts

For a firm reviewing 50+ contracts per month, the system pays for itself in the first month.

Industry-Specific Value

  • Law firms: 5x throughput while maintaining margins
  • Real estate: Close deals 3 days faster on average
  • Healthcare: 60% reduction in compliance-related delays
  • Tech companies: In-house counsel handles 3x more vendor contracts

What's Next

In this article, we built the core of an AI-powered contract review assistant:

  • Multi-agent architecture with specialized roles
  • Structure-aware document chunking
  • Reliable clause classification with confidence scoring
  • Risk analysis with actionable recommendations

In Part 2, we'll cover the production considerations:

  • Real cost analysis (tokens, infrastructure, per-contract)
  • Observability and debugging patterns
  • Python vs C# decision framework
  • Azure deployment architecture
  • When NOT to use AI for contract review

Part 2: Production Considerations

Coming soon — Real costs, observability, and when NOT to use this approach.

Read Part 2 →

This article demonstrates multi-agent patterns for contract analysis. Production implementations should include proper error handling, audit logging, and always pair AI analysis with human review for high-stakes contracts.

Want More Practical AI Tutorials?

I write about building production AI systems with Azure, Python, and C#. Subscribe for practical tutorials delivered twice a month.

Subscribe to Newsletter →

Written by Jaffar Kazi, a software engineer in Sydney building AI-powered applications. Connect on LinkedIn.