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.
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:
- Documents are prepared beforehand, i.e. cleaned and then normalized.
- The documents are split into segments with Kanon 2 Enricher.
- The segments are embedded with Kanon 2 Embedder.
- The segments get filtered with Kanon Universal Classifier to leave only relevant provisions (dropping things like footnotes).
- Every provision in an input document gets matched against every provision in a reference document, and the closest pairs are shortlisted.
- Each shortlisted pair is scored for conflict with the universal classifier and Kanon 2 Reranker.
- The pairs are then sorted into deviations and gaps.
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:
- Normalize the documents. Documents are converted into markdown and cleaned (encoding, stray characters, and whitespace). It’s basic pre-processing.
- Enrich the text into provisions (clauses).
- 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):
- Read both documents through document indexing.
- 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.
- Match every input clause against every reference clause and shortlist the closest.
- Score each shortlisted pair for conflict with the classifier and the reranker.
- Compare provision pairs and decide which are deviations and which clauses are gaps, in document order. Definition clauses are ignored.
- 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
samplesfolder, 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
- Clone the cookbooks repo.
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- Create a
.envfile with your API key in that folder.
ISAACUS_API_KEY=your_isaacus_api_key_hereA. 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 (itsuncurl_quotesfix, 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.

- 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)- 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]}") # Cloud Service Agreement
## 1. Service
### 1.1 Access and Use.
During the Subscription Period and subject to the terms of this Agreement, Customer may (a) access a
### 1.2 Support.
During the Subscription Period, Provider will provide Technical Support as described in the Order Fo
- 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: loadsISAACUS_API_KEYout of.env, so the key never lands in your code.msgspec: typed records for clauses, and later for indexes and pair scores.
Start document indexing with the imports, the paths, the clause record and the loader.
textis what we embed and classify.startandendare character offsets in the document’s text, so a report can point at the exact words rather than printing them again.typeandcategoryare the enricher’s labels for the segment.categoryis how we distinguish body text from front matter, back matter and annotations.load_text()reads a document from disk. It hands the text tonormalize_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)- 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"))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 headingsAdd 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….
- 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.
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- 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]}")177 segments -> 106 clauses
seg:2 [Access and Use] During the Subscription Period and subject to the terms of this Agreement, Custo
seg:4 [Support] During the Subscription Period, Provider will provide Technical Support as descr
seg:6 [User Accounts] Customer is responsible for all actions on Users' accounts and for all Users' co
seg:8 [Feedback and Usage Data] Customer may, but is not required to, give Provider Feedback, in which case Cust
seg:10 [Customer Content] Provider may copy, display, modify, and use Customer Content only as needed to p
- 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.
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)- 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]}")106 clauses -> 106 vectors
closest to [Payment] Customer will pay Provider Fees and taxes in U.S. Dollars, u
1.00 [Payment] Customer will pay Provider Fees and taxes in U.S. Dollars, u
0.68 [Fees] Unless the Order Form specifies a different currency, all Fe
0.61 [Taxes] Customer is responsible for all duties, taxes, and levies th
0.58 [Invoicing] For a Payment Process with invoicing, Provider will send inv
- 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 andunit_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.

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.
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_VERSIONby 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. Rowjof the embeddings belongs to propositionj, soread_index()refuses the pair if their counts differ.
- Bump
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- 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")cold 3.21s 106 propositions
warm 0.02s 106 propositions
- 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.
- 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")reference 106 propositions
input 251 propositions
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.

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 leastSHORTLIST_MIN_RELEVANCEand is one of the topSHORTLIST_TOP_Kmatches 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.
- 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
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)- 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}")input 25 of 251 clauses filtered
reference 1 of 106 clauses filtered
candidate pairs: 197
- 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_THRESHOLDis too low. - Candidate pairs are what go on to the classifier and reranker, about 200 here. If there are thousands, raise
SHORTLIST_MIN_RELEVANCEor lowerSHORTLIST_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:
- 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.
- is how likely the difference between them matters, from four questions put to the classifier and the reranker.
- Combined and calibrated into , they decide what becomes a finding.
Add the questions, the weights and the scorer.
match_matrix()turns the embedding similarity between two clauses into . 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?) andmoney(does it state an amount, a percentage or a cap?) are asked of each clause.careandagreecompare the two clauses directly. materialitycombines the four answers into one score. A clauseemptyflags, 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.differsturns that score into a probability between 0 and 1: .conflictcombines and into . 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*_WEIGHTconstants are fitted on labeled clause pairs. With the questions, they’re what you’d retune. CLASSIFY_QUERY_MAX_CHARStrims the clause sent withcare. 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.
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 (belowSAME_THRESHOLD). Then every clause, on both sides, picks the one counterpart it matches best. If that pair’s reachesCONFLICT_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, andcoveredclauses 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.
Add the report, the writer, and the check that reuses a saved run.
report.jsonholds the findings.pairs.jsonholds 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.jsonis 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- Add the pipeline.
run_comparison()runs every stage in order for one pair, anduse_cache=Falseruns everything from scratch. TheSTAGE: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- Run it against the sample pair. This is the first time the whole pipeline runs end to end. The report’s
summariesisNonefor 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",
)STAGE: Reading documents
STAGE: Filtering
STAGE: Matching
STAGE: Classifying
STAGE: Comparing
STAGE: Assembling report
STAGE: Saving results
We get a line per stage, then a new folder under
results/reports/holdingreport.json,pairs.jsonandparams.json, and a record of each document underresults/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_THRESHOLDis too low; none on a pair you know disagrees means it’s too high.Read the pairs, not the count. Each deviation’s
matchesinreport.jsonshould say different things about the same point; expect a few that only rename a defined term.Each row’s
headingshould 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.”
of 0.68 puts them on the same matter; with of 0.99, 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.

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”.
Below is an input document. Identify its major parties: the people or organisations that sign it or are bound by it as a party. Leave out anyone who is only mentioned, such as affiliates, third parties or regulators. If the input document is not an agreement (for example, legislation or a policy), list instead the main groups it binds or protects, such as "employers" and "employees".
Respond with ONLY a JSON array, no prose and no markdown code fences, of {"id": short lowercase slug, "name": the name as the input document gives it (e.g. "The Walt Disney Company", "Robert A. Iger", or a role like "the Customer" if the input document names no one), "role": a few words on who they are in this deal}.
<input_document>
{input}
</input_document>
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
removefor 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.
You are a senior lawyer. A client checks an input document against its standard, the reference document. For each finding in <findings>, write summaries, a comment, a recommendation for each party to the input document and a rewrite. <all_findings> lists every finding in this review as context; several can be on the same point.
Read both documents in full first. A finding is what a detector matched, not a confirmed difference or absence, so check it against both documents. Read qualifiers exactly ("to the extent", "unless"). Never describe a limited rule as unlimited.
Parties to the input document:
{parties}
Kinds of finding: "changed" (an input passage matched with one or more reference passages that may differ from it), "added" (an input passage that matched no reference passage) and "missing" (a reference passage that matched no input passage).
Judge each finding against the input document as it is now, never against a rewrite that you give for another finding.
Fields:
- "summaries": for a reader who skims the findings. One or two sentences each, 40 words at most. For "changed", give one per reference passage, in order: start with "The input document", then say what the reference document does instead, with the exact amount, date, condition or party that differs and its qualifiers. If only the wording differs, write exactly "Same in substance; only the wording differs." instead. For "added" and "missing", give one: what the passage secures and for which party, with its qualifiers. Never say what happens without it or that another clause lacks it.
- "falsePositive": true only if, with both full documents in view, no part of the finding changes the effect. One equivalent reference passage does not make the whole finding false. A different law, price, amount or date is a real difference.
- "comment": two to four sentences: what the input document does, what the reference document does instead, and which party this favours and why. Cite a reference clause by the number and heading it has, for example "Section 4.1 (Fees) of the reference document", after you check them.
- "referenceQuotes": exact excerpts, 80 words at most each, from other reference passages that the comment relies on. [] if none.
- "parties": for each party id, from that party's point of view:
- "action", in this order: "ignore" if the finding is a false positive; null if it is a commercial choice, such as a price, salary or date; else "rewrite" if the rewrite helps the party, "remove" if the party gains most when the whole input passage goes (not for "missing", nor when another clause refers to it), or "ignore".
- "reason": one sentence on what the party gains or loses, in concrete terms. Name another input clause only if it already gives the same result.
- "rewrite": the passage at the reference document's position. For "changed", redraft the input passage. For "added", narrow or qualify it toward the reference position, or give null if that position is to have no such passage. For "missing", draft the clause to insert, and cover only this finding's reference passage.
- "insertAfter": for "missing", copy exactly the end of the input document paragraph that the new clause follows: its last 8 to 15 words, or all of it if it is shorter, with its punctuation and Markdown. These words must occur only once in the input document. Choose the place a lawyer would choose: in the right section, after the most related clause. Else null.
Summaries, comment and reasons:
- Write ASD-STE100 Simplified Technical English: active voice, one idea per sentence, no sentence over 20 words, common words with one meaning.
- Use plain words for legal ideas: "a chance to fix the problem first", not "a cure period". Never write "shall", "herein", "pursuant to", "notwithstanding", "the foregoing" or "in respect of".
- Call the documents "the input document" and "the reference document", never by title or year. Name each party, for example "the Customer". Never "You" or "We".
- Never mention finding ids, the detector, this review or the rewrite. Plain text, no Markdown.
- They must agree with each other. Check each of them against the exact words of the passages before you answer.
Rewrite:
- Write it as part of the input document, in its drafting style: its defined terms, party names, capitalisation, legal words and cross-reference style, in Markdown. Adapt the reference wording to the input document. Never cite a reference section number or use a term the input document does not define.
- Go no further than the reference position. Never limit or widen an obligation the finding is not about.
- "changed" and "added": change only what the finding is about. Keep all other words, numbering, list markers and line structure.
- "missing": write the whole clause, numbered to fit its place.
- The rewrite must not contradict or repeat another clause. If it must override a clause that this finding cannot change, start with "Despite Section X,". After you move or add words, check that "this" and "such" still point to the right thing.
- Where a rule depends on who acts, say who.
Respond with ONLY one JSON object, no prose and no code fences. Map each finding id in <findings>, exactly as given, to:
{"summaries": [string], "falsePositive": boolean, "comment": string, "referenceQuotes": [string], "parties": {<party id>: {"action": "ignore" | "remove" | "rewrite" | null, "reason": string}}, "rewrite": string (null only for "added"), "insertAfter": string or null}
Include each of those finding ids once, and every party id in each.
<reference_document>
{reference}
</reference_document>
<input_document>
{input}
</input_document>
<all_findings>
{index}
</all_findings>
<findings>
{findings}
</findings>
- 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
modelto the model you want, andapi_keyto 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 aclaude-,gpt-orgemini-model ID.generate_run()reads the pair’sreport.json, fills insummariesandrecommendationsand saves it again. It keeps findings already saved for the same model; passuse_cache=Falseto 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: Finding parties
STAGE: Summarizing and recommending
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:
{
"model": "claude-cli-opus",
"matches": [
{
"key": "d36-0",
"deviation_id": 36,
"match_index": 0,
"text": "The input document caps only Mistral AI's liability, as far as the law permits. The cap is the lower of 12 months' Fees or 10,000 euros. The reference document caps each party at the General Cap Amount, with stated exceptions."
}
],
"gaps": [
{
"key": "r1",
"gap_id": 1,
"side": "reference",
"text": "The reference document lets the Customer use the Cloud Service, Software and Documentation during the Subscription Period, for internal business purposes only. An Affiliate's separate Order Form makes a separate agreement, and the Customer is not responsible for it."
}
]
}
You can find this in the demo above.
Recommendations
Here is the recommendations block from the same run, trimmed to the liability cap finding:
{
"model": "claude-cli-opus",
"parties": [
{
"id": "mistral-ai",
"name": "Mistral AI",
"role": "provider of the AI models and platform services"
},
{
"id": "customer",
"name": "the Customer",
"role": "user or subscriber of the services"
}
],
"findings": {
"d36-0": {
"falsePositive": false,
"comment": "The input document caps only Mistral AI's liability for the Paid Services, as far as the law permits. The cap is the lower of 12 months' Fees or 10,000 euros. Section 8.1 (Liability Caps) of the reference document caps each party at the General Cap Amount, subject to Section 8.4 (Exceptions). The input document favours Mistral AI, because the liability of the Customer has no cap.",
"rewrite": "**Liability Cap for Paid Services**. To the extent permitted by law, the total aggregate liability of each Party in respect of any Losses incurred by the other Party 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.",
"parties": {
"mistral-ai": {
"action": "ignore",
"reason": "Mistral AI keeps its own cap but could recover only a capped amount from the Customer."
},
"customer": {
"action": "rewrite",
"reason": "The Customer's liability for the Paid Services would also stop at the lower of 12 months' Fees or 10,000 euros."
}
},
"references": [
{
"start": 14274,
"end": 14418
}
]
}
}
}
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.jsonkeeps every answer, so you can try new weights or a new threshold against a saved run for free. A lowerCONFLICT_THRESHOLDfinds 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!
