Build a legal document conflict detector

Spotting deviations in contracts, laws, and policies

This tutorial is aimed at intermediate Python or JavaScript users who understand LLMs. Try our Build a legal RAG app that won’t be held in contempt guide for something simpler.

tl;dr

This guide walks through building an app that finds conflicts between two legal documents: provisions that conflict with each other, and provisions one document has that the other doesn’t.

Every finding is grounded in the text of both documents. The crux of the work is handled by Isaacus’ enrichment, embedding and universal classification models. A generative model is used to summarize findings.

While we focus on the backend, the entire demo app below is available to run in our open-source cookbook GitHub repo. The cookbook is available only in Python.

Loading…

Run this on your own files

This demo shows saved results for our sample contracts.

To use your own files, run conflict-detector yourself.

A demo that detects conflicts between a reference contract and an input contract, running on real output from our models. To try your own documents, download the repo.

The problem: finding conflict between provisions

Comparisons have been a part of legal work for a long time: manually reading line by line, while tracking a checklist or a playbook, all to create issue lists for review. Extraction and redlining grew easier with tools like Microsoft Word’s change tracking, Kira, and contract lifecycle management platforms… but that was still fairly manual.

And slow.

Then gen AI emerged, with all sorts of models being integrated into legal review tooling. They provided some speed on the document processing side – great – but came with an Achilles’ heel: recall. Point a generative model at two documents and ask for conflicts: it’ll find some, but it won’t do a comprehensive pairwise comparison, it doesn’t scale to long documents, and it can miss obvious conflicts. Hallucinations are still a problem on top of that.

When introducing automation, there are a few things to take into consideration, which include:

  • What constitutes a difference between two provisions in separate documents?
  • How large is the volume of differences?
  • Which of the differences found are actually significant?
  • How can human understanding of the differences become more efficient?

We can take these questions into consideration while improving recall and precision by relegating gen AI models to what they’re actually good at: summarizing and editing, rather than reviewing. For reviewing, we’re going to replace the void they left behind with models that don’t hallucinate: Isaacus’ models. The generative model only comes in at the very end, so any hallucinations are confined to the summaries, recommendations, and rewrites.

Side note: during this article’s drafting, I was fresh out of an Odyssey IMAX screening. Every time I mention ‘provisions’, know I’m thinking of the provisions Odysseus would’ve had packed for a voyage. And probably hungry.

Process

Fundamentally, we have an incoming input document we want to compare to an existing reference document we already have. We divide them up into provisions, and work through each pair making decisions about them. We call all the results findings, of which there are two types:

  • Deviation: two provisions that cover the same topic but don’t have the same legal effect.
  • Gap: a provision in one document with no agreement or difference in the other document.

For the purposes of this demo, provisions that agree with each other are ignored; this means we’re only flagging items that may provide a source of conflict.

The general process is as follows:

  1. Documents are prepared beforehand, i.e. cleaned and then normalized.
  2. The documents are split into segments with Kanon 2 Enricher.
  3. The segments are embedded with Kanon 2 Embedder.
  4. The segments get filtered with Kanon Universal Classifier to leave only relevant provisions (dropping things like footnotes).
  5. Every provision in an input document gets matched against every provision in a reference document, and the closest pairs are shortlisted.
  6. Each shortlisted pair is scored for conflict with the universal classifier and Kanon 2 Reranker.
  7. The pairs are then sorted into deviations and gaps.
Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.

Our app will be a bit more involved. The code is split into three parts:

  • Document indexing
  • Document analysis
  • Generation (optional)

We’ll focus more on the first two as they cover finding deviations and gaps.

Document indexing runs once per document:

  1. Normalize the documents. Documents are converted into markdown and cleaned (encoding, stray characters, and whitespace). It’s basic pre-processing.
  2. Enrich the text into provisions (clauses).
  3. Embed every clause.

We also cache the results to test values for future steps instead of making more API calls.

Document analysis runs once per pair of documents (an input document and a reference document):

  1. Read both documents through document indexing.
  2. Filter out what isn’t a provision, such as footnotes, citations and signature blocks, using the enricher’s labels and the classifier. Definition clauses skip the classifier.
  3. Match every input clause against every reference clause and shortlist the closest.
  4. Score each shortlisted pair for conflict with the classifier and the reranker.
  5. Compare provision pairs and decide which are deviations and which clauses are gaps, in document order. Definition clauses are ignored.
  6. Assemble the report, then run the entire backend. A pair that hasn’t changed since its last run reuses the saved report.

The third part, generation, is optional. It writes a summary of each finding using a gen AI model: a sentence or two on the differences between the two provisions. It also writes a recommendation for each finding about whether to keep, remove, or rewrite it. I briefly cover how it works, but we’ll just be dropping in the whole file to see how it works with everything else.

Prerequisites

  • Python 3.11+ and uv.
  • An Isaacus account and API key.
  • Sample documents. I’ve prepared a set in the repo’s samples folder, already converted to markdown. Your own documents can be plain text: the normalizer converts them.
    • Any of them can be a reference document (the document to check against) or an input document (the document to check).

This tutorial compares two different agreements: the Common Paper Cloud Service Agreement as the reference document, and Mistral AI’s Terms of Use as the input document.

Setup

  1. Clone the cookbooks repo.
shell
git clone --depth 1 https://github.com/isaacus-dev/cookbooks.git
cd cookbooks/cookbooks/conflict-detector
uv venv
# Install required packages for the conflict detector.
uv pip install ftfy isaacus msgspec numpy python-dotenv text2markdown flask
# Only needed for generation (section C) with an API model: install the one you use.
# uv pip install anthropic openai google-genai
  1. Create a .env file with your API key in that folder.
ISAACUS_API_KEY=your_isaacus_api_key_here

A. Index a document

Before we can compare documents for conflicts, we first have to identify the provisions in them. Every document goes through the same stages: load it, enrich it into clauses, embed those, and then cache everything.

Stage 1: clean the documents

Legal documents can be messy with all sorts of different patterns and layouts that need particular attention, such as tables. The sample files for this demo were fairly straightforward so general ‘cleaning’ fixes the rest: broken encoding, curly quotes, non-breaking spaces, stray control characters, and trailing whitespace. Cleaning them up helps us get better results from the models and makes them look prettier (💅) when we present them.

Isaacus’ models were trained on markdown, so typical layout markers, such as headings and text formatting, are digested appropriately. Our sample files have already been converted to markdown. Any other text file goes through text2markdown, our library that turns plain text into markdown.

We’ll focus on the normalize step first. It takes a document’s text and returns it as clean markdown.

This section uses the following dependencies:

  • ftfy: repairs text that was decoded with the wrong encoding, and straightens curly quotes (its uncurl_quotes fix, which is on by default) so "Agreement" and “Agreement” read the same.
  • text2markdown: turns a plain-text document into markdown, for any file that isn’t markdown already.
The Odyssey Batman meme
We don't want something popping up as something it isn't.
  1. Add the cleaner and the converter.
import re

from functools import cache

import ftfy

from text2markdown import text2markdown

NON_STANDARD_CONTROL_CHARS_RE = re.compile(r"[" + re.escape("\a\b\f\r\v") + "]")
START_OF_TEXT_THAT_IS_ONLY_WHITESPACE_FOLLOWED_BY_NEWLINE_RE = re.compile(r"^\s*\n")
END_OF_TEXT_THAT_IS_ONLY_WHITESPACE_PRECEDED_BY_NEWLINE_RE = re.compile(r"\n\s*$")


def clean_text(text: str) -> str:
    text = ftfy.fix_text(text)
    text = text.replace("\xa0", " ")
    text = NON_STANDARD_CONTROL_CHARS_RE.sub("", text)
    text = "\n".join([re.sub(r"\s+$", "", line) for line in text.split("\n")])
    text = START_OF_TEXT_THAT_IS_ONLY_WHITESPACE_FOLLOWED_BY_NEWLINE_RE.sub("", text)

    return END_OF_TEXT_THAT_IS_ONLY_WHITESPACE_PRECEDED_BY_NEWLINE_RE.sub("", text)


# A document can be loaded more than once in a run; convert it only once
@cache
def to_markdown(text: str) -> str:
    # Skip cross-reference links: they don't help the models, and their HTML would stop ftfy decoding entities
    return text2markdown(text, link_xrefs=False)


def normalize_text(text: str, *, markdown: bool) -> str:
    if not markdown:
        text = to_markdown(text)

    return clean_text(text)
  1. Test the cleaner by printing the first few lines:
from pathlib import Path

path = Path("samples/03-common-paper-cloud-service-agreement.md")
raw = path.read_text(encoding="utf-8")
text = normalize_text(raw, markdown=True)

for line in [line for line in text.splitlines() if line][:6]:
    print(f"  {line[:100]}")
output
  • Curly quotes are straightened, and trailing whitespace is gone.
  • The markdown is still there: the # headings and the section numbers are untouched.

Stage 2: enrich into clauses

We compare documents provision by provision, so first we need to find where each provision starts and ends. Kanon 2 Enricher turns a wall of text into structured segments: headings, containers, clauses, definition clauses, and list items. Segments can nest, so this stage removes nested text from each one and gives it a heading.

This section uses the following dependencies:

  • isaacus: the official SDK.
  • python-dotenv: loads ISAACUS_API_KEY out of .env, so the key never lands in your code.
  • msgspec: typed records for clauses, and later for indexes and pair scores.
  1. Start document indexing with the imports, the paths, the clause record and the loader.

    • text is what we embed and classify.
    • start and end are character offsets in the document’s text, so a report can point at the exact words rather than printing them again.
    • type and category are the enricher’s labels for the segment. category is how we distinguish body text from front matter, back matter and annotations.
    • load_text() reads a document from disk. It hands the text to normalize_text().
from pathlib import Path

import msgspec

from dotenv import load_dotenv

PROJECT_ROOT = Path.cwd()
SAMPLES_DIR = PROJECT_ROOT / "samples"

MARKDOWN_SUFFIXES = (".md", ".markdown")

ENRICHER_MODEL = "kanon-2-enricher"

load_dotenv(PROJECT_ROOT / ".env")


class Clause(msgspec.Struct):
    doc: str
    seg_id: str
    type: str | None
    text: str
    start: int | None = None
    end: int | None = None
    parent: str | None = None
    category: str | None = None
    heading: str | None = None
    order: int | None = None
    definition: bool = False


def load_text(path) -> str:
    path = Path(path)
    text = path.read_text(encoding="utf-8")

    return normalize_text(text, markdown=path.suffix.lower() in MARKDOWN_SUFFIXES)
  1. Create the API client. Every function that calls a model takes it as its first argument, so importing a module never connects to anything on its own.
import os

from isaacus import Isaacus

client = Isaacus(api_key=os.getenv("ISAACUS_API_KEY"))
  1. Add the enricher call, the section list and the heading resolver.

    • resolve_headings() gives each clause the title of its nearest titled ancestor, or its own. Where the enricher finds no title at all, the fallback is the nearest markdown heading above the clause, without its number or bold markers. Headings only label findings. In the interactive demo, they label the cards.
import re


def enrich_document(client, text):
    return (
        client.enrichments.create(
            model=ENRICHER_MODEL,
            texts=text,
            overflow_strategy="auto",
        )
        .results[0]
        .document
    )


def document_sections(document):
    # The document's titled containers, saved with the index
    sections = []

    for seg in document.segments:
        if seg.kind != "container" or not seg.title:
            continue

        title = seg.title.decode(document.text).strip()

        if not title:
            continue

        sections.append(
            {
                "title": title,
                "level": seg.level,
                "start": seg.span.start,
                "end": seg.span.end,
            }
        )

    return sections


def own_title(document, seg):
    return seg.title.decode(document.text).strip() if seg.title else None


MARKDOWN_HEADING = re.compile(r"^#+\s+(.+)$", re.M)
HEADING_NUMBER = re.compile(r"^\d+(?:\\?\.\d+)*\\?\.?\s+")
DEFINITION_LEAD = re.compile(r"[\W\d_]*(?:(?:the|an?)\b[\W_]*)?", re.I)


def markdown_headings(text):
    found = []

    for match in MARKDOWN_HEADING.finditer(text):
        title = HEADING_NUMBER.sub("", match.group(1).replace("*", "")).strip()

        if title:
            found.append((match.start(), title))

    return found


def resolve_headings(document):
    markdown = markdown_headings(document.text)
    segments = {seg.id: seg for seg in document.segments}
    headings = {}

    for seg in document.segments:
        parent_id, heading = seg.parent, None

        while parent_id is not None and parent_id in segments:
            heading = own_title(document, segments[parent_id])

            if heading:
                break

            parent_id = segments[parent_id].parent

        # Title of the closest parent section that has one, else its own title, else the nearest markdown heading above
        headings[seg.id] = (
            heading
            or own_title(document, seg)
            or next((title for start, title in reversed(markdown) if start <= seg.span.start), None)
        )

    return headings
  1. Add the clause extractor.

    • The enricher returns segments nested inside each other: a section, the clauses beneath it, and the list items beneath those. Each segment’s span covers its children’s text too, so the same words would be counted more than once. extract_clauses() cuts the children’s spans out, so each segment keeps only its own text.
    • The enricher also marks each segment’s own label: its type name (“Section”), its code (“4.2”) and its title. has_body() drops a piece with nothing left outside those, such as a bare heading.
    • The enricher doesn’t mark every heading’s title or number, so a markdown heading line (one starting with #) counts as a label too. Without it, ”## Definitions” would be a clause of its own, which we don’t want.
    • defines_term() marks a clause as a definition when it opens with a term the enricher found, such as in "Fees" means….
def opening(text):
    return text[DEFINITION_LEAD.match(text).end() :]


def defines_term(document, *, start, end):
    clause_opening = opening(document.text[start:end])

    # A definition opens with a term the enricher found, and that term is defined inside this clause
    return any(
        clause_opening.startswith(opening(term.name.decode(document.text)))
        and any(start <= pos < end for pos in (term.name.start, term.meaning.start))
        for term in document.terms
    )


def has_body(document, seg, *, start, end):
    # Spans that only label the segment: its type name ("Section"), code ("4.2") and title
    labels = [(span.start, span.end) for span in (seg.type_name, seg.code, seg.title) if span]

    # Markdown heading lines count as labels too
    pos = start

    for line in document.text[start:end].split("\n"):
        if line.lstrip().startswith("#"):
            labels.append((pos, pos + len(line)))

        pos += len(line) + 1

    # It has a body if any letter or digit sits outside every label
    return any(
        ch.isalnum() for n, ch in enumerate(document.text[start:end], start) if not any(a <= n < b for a, b in labels)
    )


def extract_clauses(doc_name, document):
    headings = resolve_headings(document)
    spans = {seg.id: seg.span for seg in document.segments}
    clauses = []

    for seg in document.segments:
        pieces, cut = [], seg.span.start

        # Cut the segment around its children's spans; each word belongs to one clause
        for child in sorted((spans[c] for c in seg.children), key=lambda span: span.start):
            pieces.append((cut, child.start))
            cut = max(cut, child.end)

        pieces.append((cut, seg.span.end))

        for n, (start, end) in enumerate(pieces):
            raw = document.text[start:end]
            clause_text = raw.strip()

            if not has_body(document, seg, start=start, end=end):
                continue

            # Offsets of the text once leading and trailing whitespace is trimmed
            clause_start, clause_end = start + len(raw) - len(raw.lstrip()), start + len(raw.rstrip())
            clauses.append(
                Clause(
                    doc=doc_name,
                    seg_id=seg.id if n == 0 else f"{seg.id}.{n}",
                    type=seg.type,
                    text=clause_text,
                    start=clause_start,
                    end=clause_end,
                    parent=seg.parent,
                    category=seg.category,
                    heading=headings.get(seg.id),
                    definition=defines_term(document, start=clause_start, end=clause_end),
                )
            )

    clauses.sort(key=lambda clause: clause.start)

    for order, clause in enumerate(clauses):
        clause.order = order

    return clauses
  1. This is the first call that spends credits, so it runs on one document. Test the enricher by printing the first few clauses:
path = SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md"
document = enrich_document(client, load_text(path))
clauses = extract_clauses(path.name, document)

print(f"{len(document.segments)} segments -> {len(clauses)} clauses")

for clause in clauses[:5]:
    print(f"  {clause.seg_id:<8} [{clause.heading}] {clause.text[:80]}")
output
  • There should be fewer clauses than segments: sections that only hold other clauses, and bare headings, are dropped. If the numbers match, nothing was dropped.
  • Each printed clause should show its heading in brackets. If every heading is None, resolve_headings() isn’t finding them.

Stage 3: embed and cache the clauses

Time to convert text into embeddings to be sorted, searched and compared. We’re using a semantic embedding model, which lets us measure how close two clauses are in meaning and not just wording.

This section uses one dependency: numpy, which holds the embeddings as one matrix, compares them with a dot product, and saves them to disk as an .npy file.

  1. Add the model, the batch size and the normalizer.

    • The embedder turns each clause into a vector. Clauses about the same thing end up ‘close’ to each other, and cosine similarity scores that closeness.
    • unit_normalize() scales every vector to length 1. Cosine similarity is the dot product (multiply matching numbers and add them up) divided by both vectors’ lengths, so when every length is 1, it’s just the dot product. Because the vectors are scaled before they’re saved, comparing every clause in one document with every clause in another takes a single matrix multiplication.
import numpy as np

EMBEDDER_MODEL = "kanon-2-embedder"
EMBED_BATCH_SIZE = 96


def unit_normalize(rows) -> np.ndarray:
    vectors = np.array(rows, dtype=np.float32) if rows else np.zeros((0, 0), np.float32)

    if vectors.size == 0:
        return vectors

    norms = np.linalg.norm(vectors, axis=1, keepdims=True)

    # Divide each vector by its length; an all-zero vector stays zero rather than dividing by zero
    return (vectors / np.where(norms == 0, 1, norms)).astype(np.float32)


def embed_propositions(client, texts, *, task="retrieval/document"):
    rows = []

    for i in range(0, len(texts), EMBED_BATCH_SIZE):
        resp = client.embeddings.create(
            model=EMBEDDER_MODEL,
            texts=texts[i : i + EMBED_BATCH_SIZE],
            task=task,
        )
        rows.extend(e.embedding for e in resp.embeddings)

    return unit_normalize(rows)
  1. Test the embeddings by printing the clauses closest to the payment clause:
path = SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md"
document = enrich_document(client, load_text(path))
clauses = extract_clauses(path.name, document)
vectors = embed_propositions(client, [c.text for c in clauses])

print(f"{len(clauses)} clauses -> {len(vectors)} vectors")

n = next(n for n, clause in enumerate(clauses) if clause.heading == "Payment")
scores = vectors @ vectors[n]
print(f"closest to [{clauses[n].heading}] {clauses[n].text[:60]}")

for m in np.argsort(-scores)[:4]:
    print(f"  {scores[m]:.2f}  [{clauses[m].heading}] {clauses[m].text[:60]}")
output
  • Every clause should get one vector, so the two counts match.
  • The closest clauses should be about the same thing: here, fees, taxes and invoicing.
  • The clause itself comes first with a score of 1.00. Anything else and unit_normalize() isn’t being applied.

Cache the index

Unlike Penelope’s loom situation, we don’t want to have to repeat anything more than necessary.

Penelope at her Loom, painting by Angelica Kauffman
Some of us probably bear the same expression unpicking vibe code instead of a loom.

Enriching and embedding a document only depends on its text, so the result is saved under a hash of that text, not under the file’s name or path. Rename or move a document and it’s still found in the cache; only a change to its text sends it back to the models.

  1. Add the cache: the index record, a hash function, a reader, a writer, the document records, and the function that ties the stages together.

    • Bump PIPELINE_VERSION by hand when you change how clauses are extracted. The text hasn’t changed, so nothing else would.
    • An index is five files: embeddings.npy, propositions.json, the cleaned text, the sections and a manifest. Row j of the embeddings belongs to proposition j, so read_index() refuses the pair if their counts differ.
import json
import hashlib

RESULTS_DIR = PROJECT_ROOT / "results"
DOC_CACHE_DIR = RESULTS_DIR / "cache"
DOCUMENTS_DIR = RESULTS_DIR / "documents"
DOCUMENT_SCHEMA = "conflict-document/1"

PIPELINE_VERSION = 19


class DocumentIndex(msgspec.Struct):
    # One document's clauses (called propositions here), their embeddings and its cleaned text
    propositions: list
    embeddings: np.ndarray
    manifest: dict | None = None
    text: str | None = None
    sections: list | None = None


def read_index(index_dir: Path) -> DocumentIndex | None:
    index_dir = Path(index_dir)

    try:
        rows = json.loads((index_dir / "propositions.json").read_text(encoding="utf-8"))
        matrix = np.load(index_dir / "embeddings.npy")
        manifest = json.loads((index_dir / "manifest.json").read_text(encoding="utf-8"))
        text = (index_dir / "text.txt").read_text(encoding="utf-8")
        sections = json.loads((index_dir / "sections.json").read_text(encoding="utf-8"))

    except (OSError, ValueError):
        return None

    if matrix.shape[0] != len(rows):
        return None

    return DocumentIndex(
        propositions=[Clause(**{f.name: r.get(f.name) for f in msgspec.structs.fields(Clause)}) for r in rows],
        embeddings=matrix,
        manifest=manifest,
        text=text,
        sections=sections,
    )


def write_index(index, index_dir):
    index_dir = Path(index_dir)
    index_dir.mkdir(parents=True, exist_ok=True)
    (index_dir / "manifest.json").write_text(json.dumps(index.manifest, indent=2), encoding="utf-8")
    (index_dir / "propositions.json").write_text(
        json.dumps([msgspec.structs.asdict(c) for c in index.propositions], indent=2),
        encoding="utf-8",
    )
    np.save(index_dir / "embeddings.npy", index.embeddings)
    (index_dir / "text.txt").write_text(index.text, encoding="utf-8")
    (index_dir / "sections.json").write_text(
        json.dumps(index.sections or [], indent=2),
        encoding="utf-8",
    )

    return index_dir


def document_identity(path, index, *, text=None) -> dict:
    path = Path(path)

    if text is None:
        text = index.text if index.text is not None else load_text(path)

    try:
        source = path.resolve().relative_to(PROJECT_ROOT).as_posix()

    except ValueError:
        source = str(path)

    return {
        "source": source,
        "source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
        "text_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
        "sections": index.sections or [],
    }


def document_artifact(path, index) -> dict:
    path = Path(path)
    text = load_text(path)
    assert index.text is None or index.text == text, f"{path.name}: clear results/cache"
    text = index.text if index.text is not None else text

    return {"schema": DOCUMENT_SCHEMA, **document_identity(path, index, text=text), "text": text}


def write_document_artifact(path, index, *, documents_dir=DOCUMENTS_DIR) -> Path:
    # The report only stores offsets, so the demo reads each document's text from here
    payload = document_artifact(path, index)
    dest = Path(documents_dir) / f"{payload['source_sha256']}.json"
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")

    return dest


def document_fingerprint(normalized_text):
    # Any change to the text, the pipeline version or a model re-indexes; delete results/cache/ to force it
    stamp = f"{PIPELINE_VERSION}|{ENRICHER_MODEL}|{EMBEDDER_MODEL}|{normalized_text}"

    return hashlib.sha256(stamp.encode("utf-8")).hexdigest()[:16]


def document_manifest(doc_name, path, entry):
    return {"doc_name": doc_name, "source_path": str(path), "files": {doc_name: entry}}


def index_document(client, path, *, use_cache=True) -> DocumentIndex:
    path = Path(path)
    text = load_text(path)
    cache_dir = DOC_CACHE_DIR / document_fingerprint(text)

    if use_cache:
        cached = rekey(read_index(cache_dir), path.name, path)

        if cached is not None:
            return cached

    document = enrich_document(client, text)
    propositions = extract_clauses(path.name, document)

    index = DocumentIndex(
        propositions=propositions,
        embeddings=embed_propositions(client, [p.text for p in propositions]),
        text=document.text,
        sections=document_sections(document),
        manifest=document_manifest(
            path.name, path, {"proposition_count": len(propositions), "clause_count": len(propositions)}
        ),
    )
    write_index(index, cache_dir)

    return index


def rekey(index, doc_name, path):
    if index is None or index.text is None:
        return None

    # A cached index may have been saved under another file name
    for clause in index.propositions:
        clause.doc = doc_name

    entry = next(iter((index.manifest or {}).get("files", {}).values()), {})
    index.manifest = document_manifest(doc_name, path, entry)

    return index
  1. The earlier tests called the enricher directly, so they spent credits on every run; this goes through the cache. Test the cache by indexing the same document twice and timing each run:
from time import perf_counter

path = SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md"

for attempt in ("cold", "warm"):
    started = perf_counter()
    index = index_document(client, path)
    seconds = perf_counter() - started

    print(f"{attempt:<5} {seconds:6.2f}s  {len(index.propositions)} propositions")
output
  • The warm run should take a fraction of a second and report the same count. If both runs take the same time, the cache is never being read: check results/cache/ has a folder in it.
  1. Index the input document. It needs to run through the same process as the reference in order to be compared to it. Test the index by printing how many propositions each document has:
reference = index_document(client, SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md")
input_doc = index_document(client, SAMPLES_DIR / "04-mistral-ai-terms-of-use.md")

for name, index in (("reference", reference), ("input", input_doc)):
    print(f"{name:<10} {len(index.propositions):>4} propositions")
output

Neither count should be zero.

B. Analyze documents

Everything below is part of document analysis. We’re essentially going to build a search engine, but for conflicts. We’ve already embedded our provisions, so this is where we compare them and classify conflicts.

Penelope gets suitors; we’d probably just get a suit. At least we can also get rid of it in minutes instead of two decades – much like how it feels to work on some documents (or blog posts 👀).

Stage 1: filter and match

This is the bulk of the conflict search engine. Scoring every clause against every other clause would take thousands of model calls, so we narrow them down first.

Footnotes, citations and signature blocks aren’t provisions, so they’re dropped. The rest are matched by embedding similarity, with no extra model calls. The closest pairs go on to the classifier and reranker in batches. Think of it as cutting Penelope’s suitors before the feast.

A Trojan horse washed up on a beach
Don't want anything sneaking in, do we?
  1. Add the classifier call, the filter, the matrix and the shortlist.

    • The filter has two parts. The enricher already labels front matter, back matter and annotations, so those are filtered immediately. The classifier scores every other clause against one IQL statement, roughly “this is a footnote, citation or signature, not a provision”, and sets aside anything at or above FILTER_THRESHOLD.
    • Definitions are never filtered. The classifier scores them inconsistently, and a changed definition can matter as much as any obligation. Stage 2 weighs them down instead.
    • shortlist_pairs() keeps a pair if it scores at least SHORTLIST_MIN_RELEVANCE and is one of the top SHORTLIST_TOP_K matches for either clause. Ranking from both sides means every reference clause gets its own best matches, so one missing from the input can still be reported.
from functools import partial
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor

import numpy as np

CLASSIFIER_MODEL = "kanon-universal-classifier"

SHORTLIST_MIN_RELEVANCE = 0.5
SHORTLIST_TOP_K = 3

STRUCTURAL_CATEGORIES = ("front_matter", "back_matter", "annotation")

FILTER_QUERY = "{This is a footnote, citation, editorial note, heading or signature, not a contractual provision.}"
FILTER_THRESHOLD = 0.5


def pair_matrix(input_doc: DocumentIndex, reference: DocumentIndex) -> np.ndarray:
    if input_doc.embeddings.size == 0 or reference.embeddings.size == 0:
        return np.zeros((len(input_doc.propositions), len(reference.propositions)), np.float32)

    return input_doc.embeddings @ reference.embeddings.T


def non_operative(client, index: DocumentIndex) -> np.ndarray:
    scores = classify(client, FILTER_QUERY, [flatten(c.text) for c in index.propositions], is_iql=True)

    return np.array(
        [
            c.category in STRUCTURAL_CATEGORIES or (not c.definition and score >= FILTER_THRESHOLD)
            for c, score in zip(index.propositions, scores, strict=True)
        ],
        dtype=bool,
    )


def mask_non_operative(sim: np.ndarray, inc: np.ndarray, ref: np.ndarray) -> np.ndarray:
    masked = sim.copy()

    # A filtered clause scores -1 with everything, so it can't be shortlisted
    masked[inc[:, None] | ref[None, :]] = -1.0

    return masked


def shortlist_pairs(sim: np.ndarray, *, min_relevance: float, top_k: int) -> dict[int, list[tuple[int, float]]]:
    if sim.size == 0:
        return {}

    # Rank of each pair among its input clause's matches, and among its reference clause's (0 = closest)
    row_rank = np.argsort(np.argsort(-sim, axis=1, kind="stable"), axis=1)
    col_rank = np.argsort(np.argsort(-sim, axis=0, kind="stable"), axis=0)

    # Keep a pair that's similar enough and in the top k for either clause
    keep = (sim >= min_relevance) & ((row_rank < top_k) | (col_rank < top_k))

    shortlist: dict[int, list[tuple[int, float]]] = defaultdict(list)

    for i, j in zip(*np.nonzero(keep), strict=True):
        shortlist[int(i)].append((int(j), float(sim[i, j])))

    for row in shortlist.values():
        row.sort(key=lambda entry: -entry[1])

    return dict(shortlist)


def flatten(text: str) -> str:
    return " ".join(text.split())


def span(clause: Clause) -> dict | None:
    if clause.start is None or clause.end is None:
        return None

    return {"start": clause.start, "end": clause.end}


def clause_ref(clause: Clause) -> dict:
    return {"seg_id": clause.seg_id, "text": flatten(clause.text), "span": span(clause)}


CLASSIFY_MAX_PARALLEL_REQUESTS = 6
CLASSIFY_BATCH_SIZE = 50


def batched(score, texts: list[str]) -> list[float]:
    batches = [texts[n : n + CLASSIFY_BATCH_SIZE] for n in range(0, len(texts), CLASSIFY_BATCH_SIZE)]

    with ThreadPoolExecutor(max_workers=CLASSIFY_MAX_PARALLEL_REQUESTS) as pool:
        return [s for scores in pool.map(score, batches) for s in scores]


def classify_batch(client, batch: list[str], *, query: str, is_iql: bool) -> list[float]:
    resp = client.classifications.universal.create(
        model=CLASSIFIER_MODEL, query=query, texts=batch, is_iql=is_iql, scoring_method="auto"
    )

    return [c.score for c in sorted(resp.classifications, key=lambda c: c.index)]


def classify(client, query: str, texts: list[str], *, is_iql: bool = False) -> list[float]:
    return batched(partial(classify_batch, client, query=query, is_iql=is_iql), texts)
  1. Both documents come out of the cache; the filter is the part that calls the classifier. Test the filter by counting how many clauses it drops and how many candidate pairs are left:
reference = index_document(client, SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md")
input_doc = index_document(client, SAMPLES_DIR / "04-mistral-ai-terms-of-use.md")
input_filtered, reference_filtered = non_operative(client, input_doc), non_operative(client, reference)
sim = pair_matrix(input_doc, reference)

shortlist = shortlist_pairs(
    mask_non_operative(sim, input_filtered, reference_filtered),
    min_relevance=SHORTLIST_MIN_RELEVANCE,
    top_k=SHORTLIST_TOP_K,
)

pairs = sum(len(row) for row in shortlist.values())

for name, filtered in (("input", input_filtered), ("reference", reference_filtered)):
    print(f"{name:<10} {filtered.sum():>3} of {len(filtered)} clauses filtered")

print(f"candidate pairs: {pairs}")
output
  • About a tenth of the input is filtered: headings, cross-references and other text that isn’t a provision. The reference is almost all provisions, so only one clause goes. If nearly everything is filtered, FILTER_THRESHOLD is too low.
  • Candidate pairs are what go on to the classifier and reranker, about 200 here. If there are thousands, raise SHORTLIST_MIN_RELEVANCE or lower SHORTLIST_TOP_K.
  • Your counts may differ by one or two between runs, because the embedder’s output varies slightly.

Stage 2: score

One of the few times it’s good to be judgy: the universal classifier and the reranker earning their keep. In this stage we produce a score that quantifies how much two provisions conflict.

Every shortlisted pair gets two numbers:

  • P(same)P(\text{same}) is how likely the two clauses deal with the same matter. It’s worked out from the embeddings we already have, so it needs no more model calls.
  • P(differs)P(\text{differs}) is how likely the difference between them matters, from four questions put to the classifier and the reranker.
  • Combined and calibrated into P(conflict)P(\text{conflict}), they decide what becomes a finding.
  1. Add the questions, the weights and the scorer.

    • match_matrix() turns the embedding similarity between two clauses into P(same)P(\text{same}). A similarity of 0.6 can be the best match in one pair of documents and an ordinary one in another, so the raw score isn’t enough. Instead, a pair scores high only when each clause is clearly the other’s closest match. A clause with no real counterpart has no clear closest match, so all its pairs score low.
    • Four questions decide whether a difference matters. empty (is it standard wording, a heading, a signature or a definition?) and money (does it state an amount, a percentage or a cap?) are asked of each clause. care and agree compare the two clauses directly.
    • materiality combines the four answers into one score. A clause empty flags, such as standard wording found in most contracts or a signature block, pulls it down hard; clauses that agree, or that a party wouldn’t care about, pull it down a little; an amount pushes it up. differs turns that score into a probability between 0 and 1: P(differs)P(\text{differs}).
    • conflict combines P(same)P(\text{same}) and P(differs)P(\text{differs}) into P(conflict)P(\text{conflict}). Multiplying them is too cautious, so the combination is fitted: 0.73 means about a 73% chance of a real difference.
    • The SAME_*, DIFFERS_*, CONFLICT_* and *_WEIGHT constants are fitted on labeled clause pairs. With the questions, they’re what you’d retune.
    • CLASSIFY_QUERY_MAX_CHARS trims the clause sent with care. Each distinct clause is sent once, however many pairs it’s in.
import math

import msgspec

RERANKER_MODEL = "kanon-2-reranker"

EMPTY_QUERY = "routine boilerplate, a recital, a preamble, a heading, a signature block, an address or a definition"
CARE_STATEMENT = "A party would not care whether this clause or the following clause applied: {clause}"
AGREE_QUERY = "A lawyer would compare this clause with the following clause: {clause}"
MONEY_STATEMENT = "This states an amount of money, a percentage or a cap."
CLASSIFY_QUERY_MAX_CHARS = 2000
MATCH_TEMPERATURE = 0.06
SAME_SLOPE = 2.049
SAME_BIAS = 3.058

EMPTY_WEIGHT = -4.0
CARE_WEIGHT = -1.0
AGREE_WEIGHT = -1.0
MONEY_WEIGHT = 1.0
DIFFERS_SLOPE = 0.526
DIFFERS_BIAS = -12.312
CONFLICT_SAME_SLOPE = 0.576
CONFLICT_DIFFERS_SLOPE = 0.707
CONFLICT_BIAS = 1.663


def rerank_batch(client, batch: list[str], *, query: str) -> list[float]:
    resp = client.rerankings.create(model=RERANKER_MODEL, query=query, texts=batch)

    return [r.score for r in sorted(resp.results, key=lambda r: r.index)]


def rerank(client, query: str, texts: list[str]) -> list[float]:
    return batched(partial(rerank_batch, client, query=query), texts)


def logit(p: float) -> float:
    p = min(max(p, 1e-6), 1 - 1e-6)

    return math.log(p / (1 - p))


def sigmoid(x: float) -> float:
    return 1 / (1 + math.exp(-x))


def log_sigmoid(x: float) -> float:
    # log(sigmoid(x)), without rounding tiny probabilities to zero
    return -float(np.logaddexp(0.0, -x))


def mean(scores: dict, a, b) -> float:
    return (scores[a] + scores[b]) / 2


def score_query(b: str, *, texts: list[str], score, template: str, max_chars: int | None) -> dict:
    # Ask the model about clause b once, against every clause it's paired with
    return {(a, b): s for a, s in zip(texts, score(template.format(clause=b[:max_chars]), texts), strict=True)}


def pairwise(score, template: str, pairs: list[tuple[str, str]], *, max_chars: int | None = None) -> dict:
    by_query: dict[str, list[str]] = defaultdict(list)

    # Group pairs by their second clause, so each clause goes to the model once
    for a, b in dict.fromkeys(pairs):
        by_query[b].append(a)

    out = {}

    with ThreadPoolExecutor(max_workers=CLASSIFY_MAX_PARALLEL_REQUESTS) as pool:
        jobs = [
            pool.submit(score_query, b, texts=texts, score=score, template=template, max_chars=max_chars)
            for b, texts in by_query.items()
        ]

        for job in jobs:
            out.update(job.result())

    return out


class PairScore(msgspec.Struct):
    relevance: float
    match: float
    empty: float
    care: float
    agree: float
    money: float

    @property
    def same_matter(self) -> float:
        return sigmoid(SAME_SLOPE * self.match + SAME_BIAS)

    @property
    def materiality(self) -> float:
        return (
            EMPTY_WEIGHT * logit(self.empty)
            + CARE_WEIGHT * logit(self.care)
            + AGREE_WEIGHT * logit(self.agree)
            + MONEY_WEIGHT * logit(self.money)
        )

    @property
    def differs(self) -> float:
        return sigmoid(DIFFERS_SLOPE * self.materiality + DIFFERS_BIAS)

    @property
    def conflict(self) -> float:
        return sigmoid(
            CONFLICT_SAME_SLOPE * log_sigmoid(SAME_SLOPE * self.match + SAME_BIAS)
            + CONFLICT_DIFFERS_SLOPE * log_sigmoid(DIFFERS_SLOPE * self.materiality + DIFFERS_BIAS)
            + CONFLICT_BIAS
        )


def match_matrix(sim: np.ndarray) -> np.ndarray:
    logits = sim / MATCH_TEMPERATURE
    row = logits - np.logaddexp.reduce(logits, axis=1, keepdims=True)
    col = logits - np.logaddexp.reduce(logits, axis=0, keepdims=True)

    return (row + col) / 2


def score_pairs(
    client,
    input_doc: DocumentIndex,
    reference: DocumentIndex,
    *,
    shortlist: dict[int, list[tuple[int, float]]],
) -> dict[tuple[int, int], PairScore]:
    match = match_matrix(pair_matrix(input_doc, reference))
    texts = {
        (i, j): (flatten(input_doc.propositions[i].text), flatten(reference.propositions[j].text))
        for i, row in shortlist.items()
        for j, _ in row
    }
    unique = list(dict.fromkeys(t for pair in texts.values() for t in pair))
    both_ways = [p for a, b in texts.values() for p in ((a, b), (b, a))]

    empty = dict(zip(unique, rerank(client, EMPTY_QUERY, unique), strict=True))
    money = dict(zip(unique, classify(client, MONEY_STATEMENT, unique), strict=True))
    care = pairwise(partial(classify, client), CARE_STATEMENT, both_ways, max_chars=CLASSIFY_QUERY_MAX_CHARS)
    agree = pairwise(partial(rerank, client), AGREE_QUERY, both_ways)

    return {
        (i, j): PairScore(
            relevance=relevance,
            match=float(match[i, j]),
            empty=mean(empty, *texts[(i, j)]),
            money=mean(money, *texts[(i, j)]),
            care=mean(care, texts[(i, j)], texts[(i, j)][::-1]),
            agree=mean(agree, texts[(i, j)], texts[(i, j)][::-1]),
        )
        for i, candidates in shortlist.items()
        for j, relevance in candidates
    }

Scoring is the stage that costs money; you’ll see its results in the Stage 4 run.

Stage 3: compare

Stage 2 scored every shortlisted pair. This stage decides which of them are deviations and which clauses are gaps to complete our actual findings.

  1. Add the thresholds, the row helpers, the partition and the two roll-ups.

    • partition() first drops any pair that isn’t about the same topic (below SAME_THRESHOLD). Then every clause, on both sides, picks the one counterpart it matches best. If that pair’s P(conflict)P(\text{conflict}) reaches CONFLICT_THRESHOLD, it’s a deviation.
    • Only the best match counts. Otherwise a clause would get flagged against every vaguely similar clause nearby. No drowning in findings, thanks.
    • Picking from both sides matters because documents don’t split things up the same way. If the reference spreads one input clause across four, all four pick it, so it ends up as one row with four matches.
    • By the end, every clause has one of three statuses:
      • deviation: its best match conflicts with it.
      • covered: it has a match, and they don’t conflict.
      • gap: nothing in the other document covers the same thing.
    • roll_up_gaps() turns gaps into findings for each side: what the input document dropped, and what it added. Filtered clauses never become gaps, and covered clauses are ignored; they agree, so there’s nothing to flag.
    • A definition with nothing to match isn’t a gap. Documents define different terms, or define them in different places: the input defines “Effective Date”, while the reference just uses it because its cover page sets the date. If a term actually matters, the clauses that use it will turn up as findings anyway.
SAME_THRESHOLD = 0.3

CONFLICT_THRESHOLD = 0.728


def status(pairs: list[tuple[int, PairScore]], *, deviating: bool) -> dict:
    best = max(pairs, key=lambda e: e[1].relevance, default=None)

    return {
        "status": "deviation" if deviating else "covered" if pairs else "gap",
        "best_relevance": best[1].relevance if best else None,
        "nearest_index": best[0] if best else None,
    }


def side_status(index: DocumentIndex, pairs, deviating: set[int]) -> dict[int, dict]:
    return {n: status(pairs.get(n, []), deviating=n in deviating) for n in range(len(index.propositions))}


def partition(input_doc: DocumentIndex, reference: DocumentIndex, scored: dict[tuple[int, int], PairScore]):
    closest: dict[int, dict[int, tuple[tuple[int, int], PairScore]]] = {0: {}, 1: {}}

    # For each clause on each side, keep its best match that's about the same matter
    for pair, sc in scored.items():
        for side, best in closest.items():
            if sc.same_matter >= SAME_THRESHOLD and (pair[side] not in best or sc.match > best[pair[side]][1].match):
                best[pair[side]] = (pair, sc)

    # Both sides' picks together; a pair both clauses picked appears once
    selected = dict(entry for best in closest.values() for entry in best.values())

    # A picked pair is a deviation when its P(conflict) clears the threshold
    deviating_pairs = [(i, j, sc) for (i, j), sc in selected.items() if sc.conflict >= CONFLICT_THRESHOLD]

    inc_pairs: dict[int, list[tuple[int, PairScore]]] = defaultdict(list)
    ref_pairs: dict[int, list[tuple[int, PairScore]]] = defaultdict(list)

    for (i, j), score in scored.items():
        if score.same_matter >= SAME_THRESHOLD:
            inc_pairs[i].append((j, score))
            ref_pairs[j].append((i, score))

    return (
        deviating_pairs,
        side_status(input_doc, inc_pairs, {i for i, _, _ in deviating_pairs}),
        side_status(reference, ref_pairs, {j for _, j, _ in deviating_pairs}),
    )


def clause_row(clause: Clause) -> dict:
    return {
        "id": None,
        "seg_id": clause.seg_id,
        "heading": clause.heading,
        "text": flatten(clause.text),
        "span": span(clause),
        "order": clause.order,
        "category": clause.category,
    }


def numbered(rows: list[dict], sort_key) -> list[dict]:
    # Number rows in document order, so the report reads top to bottom like the documents
    rows.sort(key=sort_key)

    for n, row in enumerate(rows, start=1):
        row["id"] = n

    return rows


def roll_up_deviations(deviating_pairs, input_doc: DocumentIndex, reference: DocumentIndex) -> list[dict]:
    by_input: dict[int, list[tuple[int, PairScore]]] = defaultdict(list)

    for i, j, score in deviating_pairs:
        by_input[i].append((j, score))

    rows = []

    for i, pairs in by_input.items():
        pairs.sort(key=lambda e: reference.propositions[e[0]].order or 0)
        rows.append(
            {
                **clause_row(input_doc.propositions[i]),
                "relevance": pairs[0][1].relevance,
                "match_count": len(pairs),
                "matches": [
                    {
                        "input": clause_ref(input_doc.propositions[i]),
                        "reference": {
                            **clause_ref(reference.propositions[j]),
                            "clause_text": flatten(reference.propositions[j].text),
                        },
                        "relevance": score.relevance,
                    }
                    for j, score in pairs
                ],
            }
        )

    return numbered(rows, lambda r: r["order"] or 0)


def roll_up_gaps(
    index: DocumentIndex, status: dict[int, dict], *, other: DocumentIndex, filtered: np.ndarray
) -> list[dict]:
    rows = []

    for i, clause in enumerate(index.propositions):
        info = status[i]

        if filtered[i] or info["status"] != "gap" or clause.definition:
            continue

        near = info["nearest_index"]
        rows.append(
            {
                **clause_row(clause),
                "propositions": {"total": 1, "matched_count": 0, "uncovered_count": 1},
                "gap_score": 1.0 - (info["best_relevance"] or 0.0),
                "uncovered_propositions": [
                    {
                        **clause_ref(clause),
                        **{k: info[k] for k in ("status", "best_relevance")},
                        "nearest": clause_ref(other.propositions[near]) if near is not None else None,
                    }
                ],
            }
        )

    return numbered(rows, lambda r: r["order"] or 0)

Stage 4: report and run

The last stage puts every finding into one report and saves it under the pair it belongs to. Then we run the whole pipeline.

  1. Add the report, the writer, and the check that reuses a saved run.

    • report.json holds the findings. pairs.json holds every shortlisted pair behind them, with all four answers and the three probabilities. Want to try a different threshold? You can see what would change without paying for the models again.
    • params.json is the paper trail: the models, the thresholds, the wording of every question, and the pipeline version. If two runs disagree, look here first.
    • saved_report_if_current() reuses a saved run as long as the settings and both documents’ text haven’t changed, so running the same pair twice doesn’t hit the models again. The exception is a plain-text document: it still gets converted to markdown on every run, since that’s how we get the text to compare.
import re
import json
import shutil
import hashlib

from pathlib import Path
from datetime import UTC, datetime

REPORTS_DIR = RESULTS_DIR / "reports"

SCHEMA = "doc-compare/2"


def coverage_counts(status: dict[int, dict], filtered: np.ndarray) -> dict:
    counts = {"total": len(status), "covered": 0, "deviation": 0, "gap": 0, "filtered": int(filtered.sum())}

    for n, row in status.items():
        if not filtered[n]:
            counts[row["status"]] += 1

    return counts


def document_summary(index: DocumentIndex, path: Path) -> dict:
    return {
        "name": Path(path).name,
        **document_identity(path, index),
        "propositions": len(index.propositions),
    }


def scored_pairs_rows(input_doc, reference, scored, *, deviating) -> list[dict]:
    rows = []

    for (i, j), score in scored.items():
        inc, ref = input_doc.propositions[i], reference.propositions[j]
        rows.append(
            {
                "input_seg": inc.seg_id,
                "input": flatten(inc.text),
                "reference_seg": ref.seg_id,
                "reference": flatten(ref.text),
                "relevance": score.relevance,
                "same_matter": score.same_matter,
                "differs": score.differs,
                "conflict": score.conflict,
                "deviating": (i, j) in deviating,
            }
        )

    rows.sort(key=lambda row: -row["relevance"])

    return rows


def build_report(
    *,
    reference,
    input_doc,
    reference_path,
    input_path,
    deviation_rows,
    gaps_reference,
    gaps_input,
    input_status,
    reference_status,
    input_filtered,
    reference_filtered,
    pairs,
) -> dict:
    return {
        "schema": SCHEMA,
        "reference": document_summary(reference, reference_path),
        "input": document_summary(input_doc, input_path),
        "summary": {
            "deviation_rows": len(deviation_rows),
            "deviations": sum(row["match_count"] for row in deviation_rows),
            "shortlisted_pairs": len(pairs),
            "gaps_reference": len(gaps_reference),
            "gaps_input": len(gaps_input),
            "reference_coverage": coverage_counts(reference_status, reference_filtered),
            "input_coverage": coverage_counts(input_status, input_filtered),
        },
        "summaries": None,
        "recommendations": None,
        "deviations": deviation_rows,
        "gaps_reference": gaps_reference,
        "gaps_input": gaps_input,
        "pairs": pairs,
    }


def slug(name: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:60] or "document"


def pair_slug(reference_path, input_path) -> str:
    return f"{slug(Path(reference_path).stem)}__{slug(Path(input_path).stem)}"


def analysis_params() -> dict:
    return {
        "schema": SCHEMA,
        "pipeline_version": PIPELINE_VERSION,
        "enricher_model": ENRICHER_MODEL,
        "embedder_model": EMBEDDER_MODEL,
        "classifier_model": CLASSIFIER_MODEL,
        "shortlist_min_relevance": SHORTLIST_MIN_RELEVANCE,
        "shortlist_top_k": SHORTLIST_TOP_K,
        "filter_query": FILTER_QUERY,
        "filter_threshold": FILTER_THRESHOLD,
        "reranker_model": RERANKER_MODEL,
        "empty_query": EMPTY_QUERY,
        "care_statement": CARE_STATEMENT,
        "agree_query": AGREE_QUERY,
        "money_statement": MONEY_STATEMENT,
        "classify_query_max_chars": CLASSIFY_QUERY_MAX_CHARS,
        "match_temperature": MATCH_TEMPERATURE,
        "same_slope": SAME_SLOPE,
        "same_bias": SAME_BIAS,
        "same_threshold": SAME_THRESHOLD,
        "materiality_weights": [EMPTY_WEIGHT, CARE_WEIGHT, AGREE_WEIGHT, MONEY_WEIGHT],
        "differs_slope": DIFFERS_SLOPE,
        "differs_bias": DIFFERS_BIAS,
        "conflict_slopes": [CONFLICT_SAME_SLOPE, CONFLICT_DIFFERS_SLOPE],
        "conflict_bias": CONFLICT_BIAS,
        "conflict_threshold": CONFLICT_THRESHOLD,
        "structural_categories": list(STRUCTURAL_CATEGORIES),
    }


def comparison_dir_for(reference_path, input_path, *, runs_dir=REPORTS_DIR) -> Path:
    return Path(runs_dir) / pair_slug(reference_path, input_path)


def read_json(path):
    try:
        return json.loads(Path(path).read_text(encoding="utf-8"))

    except (OSError, ValueError):
        return None


def saved_report_if_current(comparison_dir, reference_path, input_path) -> dict | None:
    report = read_json(Path(comparison_dir) / "report.json")
    saved = read_json(Path(comparison_dir) / "params.json")

    if report is None or saved is None:
        return None

    current = analysis_params()

    if {k: saved.get(k) for k in current} != current:
        return None

    for side, path in (("reference", reference_path), ("input", input_path)):
        text = load_text(path).encode("utf-8")

        if report.get(side, {}).get("text_sha256") != hashlib.sha256(text).hexdigest():
            return None

    return report


def write_analysis_run(
    report,
    *,
    reference: DocumentIndex,
    input_doc: DocumentIndex,
    reference_path: Path,
    input_path: Path,
    run_dir: Path,
    label=None,
    documents_dir=DOCUMENTS_DIR,
) -> Path:
    dest = Path(run_dir)

    if dest.exists():
        shutil.rmtree(dest)

    dest.mkdir(parents=True)

    params = {
        **analysis_params(),
        "slug": dest.name,
        "label": label,
        "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
    }

    for name, payload in (
        ("report", {k: v for k, v in report.items() if k != "pairs"}),
        ("pairs", report.get("pairs") or []),
        ("params", params),
    ):
        (dest / f"{name}.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")

    write_document_artifact(reference_path, reference, documents_dir=documents_dir)
    write_document_artifact(input_path, input_doc, documents_dir=documents_dir)

    return dest
  1. Add the pipeline. run_comparison() runs every stage in order for one pair, and use_cache=False runs everything from scratch. The STAGE: lines follow the overview, with Report split into assembling and saving.
def run_comparison(client, reference_path, input_path, *, use_cache: bool = True, label: str | None = None) -> dict:
    reference_path, input_path = Path(reference_path), Path(input_path)
    dest = comparison_dir_for(reference_path, input_path)

    if use_cache and (report := saved_report_if_current(dest, reference_path, input_path)) is not None:
        return report

    print("STAGE: Reading documents")
    reference = index_document(client, reference_path, use_cache=use_cache)
    input_doc = index_document(client, input_path, use_cache=use_cache)

    print("STAGE: Filtering")
    input_filtered, reference_filtered = non_operative(client, input_doc), non_operative(client, reference)

    print("STAGE: Matching")
    shortlist = shortlist_pairs(
        mask_non_operative(pair_matrix(input_doc, reference), input_filtered, reference_filtered),
        min_relevance=SHORTLIST_MIN_RELEVANCE,
        top_k=SHORTLIST_TOP_K,
    )

    print("STAGE: Classifying")
    scored = score_pairs(client, input_doc, reference, shortlist=shortlist)

    print("STAGE: Comparing")
    deviating_pairs, input_status, reference_status = partition(input_doc, reference, scored)

    deviation_rows = roll_up_deviations(deviating_pairs, input_doc, reference)
    gaps_reference = roll_up_gaps(reference, reference_status, other=input_doc, filtered=reference_filtered)
    gaps_input = roll_up_gaps(input_doc, input_status, other=reference, filtered=input_filtered)

    print("STAGE: Assembling report")
    report = build_report(
        reference=reference,
        input_doc=input_doc,
        reference_path=reference_path,
        input_path=input_path,
        deviation_rows=deviation_rows,
        gaps_reference=gaps_reference,
        gaps_input=gaps_input,
        input_status=input_status,
        reference_status=reference_status,
        input_filtered=input_filtered,
        reference_filtered=reference_filtered,
        pairs=scored_pairs_rows(input_doc, reference, scored, deviating={(i, j) for i, j, _ in deviating_pairs}),
    )

    print("STAGE: Saving results")
    write_analysis_run(
        report,
        reference=reference,
        input_doc=input_doc,
        reference_path=reference_path,
        input_path=input_path,
        run_dir=dest,
        label=label,
    )

    return report
  1. Run it against the sample pair. This is the first time the whole pipeline runs end to end. The report’s summaries is None for now: part C fills it in.
report = run_comparison(
    client,
    SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md",
    SAMPLES_DIR / "04-mistral-ai-terms-of-use.md",
)
output
  • We get a line per stage, then a new folder under results/reports/ holding report.json, pairs.json and params.json, and a record of each document under results/documents/.

  • 56 deviations in 54 rows (two input clauses have two counterparts each), and many more gaps, is about right for two different agreements that overlap only in part.

  • Hundreds of deviations on a pair that should mostly agree means CONFLICT_THRESHOLD is too low; none on a pair you know disagrees means it’s too high.

  • Read the pairs, not the count. Each deviation’s matches in report.json should say different things about the same point; expect a few that only rename a defined term.

  • Each row’s heading should be a clause a reviewer could find in the document.

  • Your counts may differ by one or two, and scores by a hundredth or so, between runs.

Swap in any other document from samples/ to check a new pair. Running the same pair again with nothing changed reuses the saved run and costs nothing; pass use_cache=False to start over.

C. Generation (optional)

We can make our lives easier by having a generative model summarize each finding and make recommendations for us. Document analysis creates a saved analysis report. This optional step adds summaries and recommendations to that same report file.

Here’s a deviation from the sample pair:

  • Cloud Service Agreement (reference): “Except as provided in Section 8.4 (Exceptions), each party’s total cumulative liability for all claims arising out of or relating to this Agreement will not be more than the General Cap Amount.”
  • Mistral AI Terms of Use (input): “To the extent permitted by law, the total aggregate liability of Mistral AI in respect of any Losses incurred by the Customer under or in relation to this Agreement will not exceed, in the aggregate, the lower of (i) the amount of the Fees paid or payable by the Customer in the twelve (12) calendar months preceding the date on which the first such event or events occurred or (ii) 10.000 euros.”

P(same)P(\text{same}) of 0.68 puts them on the same matter; with P(differs)P(\text{differs}) of 0.99, P(conflict)P(\text{conflict}) is 0.81, over CONFLICT_THRESHOLD.

Our optional third part, generation, turns those results into something more user-friendly. It’s easier to read a summary of what the values mean than to decipher the meaning after reading two pairs of provisions. These models are generative, so a watchful eye is certainly needed in case of hallucination and other errors.

Matt Damon reacting in The Odyssey
Don't fall for generative shenanigans.

I’ve found generative models giving me different results even with similar prompts, so it’s worth trying a few for preferences’ sake. I was working with Opus and Sonnet; you should customize the prompts for models you prefer to use.

The code for generation is long because it supports several LLM providers and adds to the report. I’ll summarize the quirks of the prompts instead.

There are two prompts. Their placeholders are filled with the documents, parties, and findings at run time.

PARTIES_PROMPT names the parties once per pair, from the input document only. Legislation or a policy has no signing parties, so it lists the groups the document binds or protects instead, such as “employers” and “employees”.

PARTIES_PROMPT

RECOMMEND_PROMPT takes the findings in batches. For each one, it writes the summaries, a comment, an action and reason for each party, and a rewrite if appropriate. Some of the quirks:

  • It writes a finding’s summaries, comment, actions and rewrite in one answer, so they don’t contradict each other.
  • Every call includes both full documents. When summaries were written from the two passages alone, they often said the other document lacked something that was in a different clause.
  • The model can’t recommend remove for a missing clause, or for a passage that another clause refers to. Before this rule, it deleted subclauses that other clauses cite by number.
  • A rewrite is only a suggestion until the reviewer accepts it, so the model judges every finding against the input document as written. A rewrite goes no further than the reference position, and keeps the input document’s numbering, list markers and line breaks so the redline shows only the real change. An added passage can get no rewrite (null) when the reference document has no such passage, so the model doesn’t invent a narrower clause.
RECOMMEND_PROMPT
  1. You can have a look at the generation file containing the prompts above if you like. Import it and run it on the pair from document analysis. Set model to the model you want, and api_key to its key. The default, claude-cli-opus, runs the Claude CLI on your Claude Code sign-in, so it needs no key. For an API, use a claude-, gpt- or gemini- model ID. generate_run() reads the pair’s report.json, fills in summaries and recommendations and saves it again. It keeps findings already saved for the same model; pass use_cache=False to write them again.
import sys

sys.path.insert(0, str(PROJECT_ROOT))

import generation

report = generation.generate_run(
    comparison_dir_for(
        SAMPLES_DIR / "03-common-paper-cloud-service-agreement.md",
        SAMPLES_DIR / "04-mistral-ai-terms-of-use.md",
    ),
    model="claude-cli-opus",
    api_key=None,
)
stage messages (batch progress omitted)

The results

report.json has a summaries block. Here’s an example, trimmed to the liability cap deviation and the first gap on the reference side:

report.json

You can find this in the demo above.

Recommendations

Here is the recommendations block from the same run, trimmed to the liability cap finding:

report.json

Run the demo UI to view results

import server

server.serve(run_comparison, client)

Further development

You’ve built a conflict checker containing normalization, indexing, and analysis that can check one document against another and write down every deviation and gap. No vector database, and no generative model deciding what counts as a conflict. Generation optionally adds a written summary and recommendation to each finding.

There are plenty of ways to extend it:

  • Tune it. The questions, the weights and the thresholds can be refitted to your own reference documents; pairs.json keeps every answer, so you can try new weights or a new threshold against a saved run for free. A lower CONFLICT_THRESHOLD finds more real differences at the cost of more false alarms.
  • Mind your inputs. The pipeline expects clean markdown. Plain text gets converted for you, but if you’ve got tables or scanned documents, fix the files rather than adding rules for them.
  • Run your reference documents against each other. Find the conflicts in your own templates (not some meta conflictception, I promise).
  • Make it faster. Cache classifier results, skip repeated clause pairs, and find the sweet spot for parallel requests. Originally I’d kept a full history of results plus a dashboard, but, y’know… Brevity.
  • Build on it. Redlining, review queues, pre-signing checklists, template and playbook checks, and comparisons when a reference document is updated. It’s a demo so I didn’t do any of that, but I needed you to know I thought of it. 🩵

If you somehow managed to scroll this far without actually doing any coding, the notebook is available in our cookbook and the demo files are available separately on GitHub. Run it on your own files – good luck!

A chaotic battle scene from The Odyssey
Me getting ready to build more demos for you after this *harrowing* tutorial.