Guide
AI document review that locates its findings
A language model can read a contract and write a convincing paragraph about it in seconds. That paragraph is not a review. A review says where the problem is, on which page, in which clause, and it survives being checked by someone else.
This page covers the three ways teams put a model on a document, what each one costs, and one implementation in full: located findings, per-document rules and a human gate, down to the function that returns nothing when it cannot find the text.
Three routes
How the model touches the document
The same request produces three systems. They differ in what a reviewer can check afterwards.
01Summarise the whole document
One prompt, the full text, prose back. Fast to build, and it reads well.
Nothing in the output can be checked. There is no page, no clause, no quote, so a reviewer cannot verify a sentence without re-reading the document. The cheapest thing to demo, the most expensive to defend.
02Chunk, extract, then apply rules
Split the document, extract fields per chunk into a schema, run rules over the fields. Each finding inherits the chunk it came from.
Works when the document has a known shape. The location is only as precise as the chunk, and a chunk boundary can cut a clause in half.
03Locate the finding in the document
The model proposes a finding and the text that supports it; the service maps that text to a page and a rectangle in the PDF; a person accepts or dismisses it.
Costs a second pass over the PDF and a rule for what happens when the text cannot be found. That rule is the difference between a tool and a demo.
One implementation
How DocAuditAgent locates a finding
DocAuditAgent takes the third route. Six pieces of code carry it: the coordinate assumptions, the text search that may fail, the rectangle conversion, the annotation the browser writes, per-document rules, and the human gate. Paths are complete repository-relative paths.
The coordinate space is declared, not assumed: which corner is the origin, what the units are, and how much of the page the content is expected to cover.
# MinerU
mineru_base_url: str = "https://mineru.net"
mineru_api_key: str = ""
mineru_model_version: str = "vlm"
mineru_poll_interval_sec: float = 1.0
mineru_max_wait_sec: float = 300.0
mineru_cache_artifacts: bool = True
mineru_cache_dir: str = "./app/data/mineru"
# MinerU bbox coordinate assumptions
# Most MinerU JSON outputs use image-like coordinates with origin at top-left.
mineru_bbox_origin: str = "top-left" # "top-left" or "bottom-left"
mineru_bbox_units: str = "auto" # "auto", "px", "pt"
mineru_bbox_content_coverage: float = 0.92 # used to infer full-page bbox canvas size from content extentsThe text of the finding is searched in the PDF text layer, with two fallbacks and then a refusal. When nothing is found the function returns nothing; there is no estimated position.
candidates: list[str] = []
if needle:
candidates.append(needle.strip())
if fallback_sentence:
candidates.append(str(fallback_sentence).strip())
# Also try removing spaces for CJK PDFs where extraction may omit spaces
candidates.extend([c.replace(" ", "") for c in candidates if " " in c])
rects: list[fitz.Rect] = []
for c in candidates:
if not c:
continue
rects = page.search_for(c)
if rects:
break
# If still nothing, try a shorter needle (first 12 chars) to improve hit rate
if not rects and needle:
short = needle.strip()
if len(short) > 12:
rects = page.search_for(short[:12])
doc.close()
if not rects:
return NoneThe rectangle is converted into the quadpoint form a PDF highlight takes: bottom-left origin, one set of eight numbers per rectangle, at most six rectangles, rounded to two decimals.
# Convert rects (top-left origin) to PDF quadpoints (bottom-left origin).
# Allow multi-quad highlights (8*n).
quadpoints: list[float] = []
for r in rects[:6]:
x0, y0, x1, y1 = float(r.x0), float(r.y0), float(r.x1), float(r.y1)
quadpoints.extend(
[
x0,
page_h - y0,
x1,
page_h - y0,
x0,
page_h - y1,
x1,
page_h - y1,
]
)
return [round(v, 2) for v in quadpoints]The browser writes the annotation itself, with those quadpoints, through annotpdf. This is the step that turns coordinates into a highlight a reviewer can click.
export function addAnnotation(
page_num: number,
quad: number[],
color = { r: 255, g: 255, b: 0 }
): [Uint8Array, HighlightAnnotationObj] {
const page = page_num - 1 // Annotation library page numbering starts from 0
const annotation = annotator.createHighlightAnnotation({
page,
color,
quadPoints: quad,
opacity: 0.5
})
return [annotator.write(), annotation]
}Rules are per document, not global: the ids come with the request, are loaded, and are passed into the review that runs.
# Get custom rules if rule_ids provided
custom_rules = None
if rule_ids:
custom_rules = await rules_service.get_rules_by_ids(rule_ids)
logging.info(f"Using {len(custom_rules)} custom rules for review")
stored_issues = await issues_service.get_issues_data(doc_id)
# If force=true, delete existing issues and re-run
if force and stored_issues:
await issues_service.issues_repository.delete_issues_by_doc(doc_id)
stored_issues = []
if stored_issues:
def issues_events():
yield issues_event(stored_issues)
yield "event: complete\n\n"
issues = issues_events()
else:
date_time = datetime.now(timezone.utc)
pdf_path = Path(settings.local_docs_dir) / doc_id
if not pdf_path.exists():
raise HTTPException(status_code=404, detail="Document not found on server")
issues_stream = issues_service.initiate_review(str(pdf_path), user, date_time, custom_rules)The human step is a LangGraph interrupt, and the endpoint fails loudly when the graph did not interrupt. A review that silently skipped the person would return 500, not a success.
thread_id = f"issue:{issue_id}:{uuid4()}"
interrupt = await issues_service.hitl.start_update(
thread_id=thread_id, issue_id=issue_id, update_fields=update_fields
)
if interrupt is None:
raise HTTPException(status_code=500, detail="HITL did not raise an interrupt; cannot enter human review.")
proposed_action = {"name": "update_issue", "args": {"issue_id": issue_id, "update_fields": update_fields}}
return HitlStartResponse(
thread_id=thread_id,
interrupt_id=interrupt.get("id") if isinstance(interrupt, dict) else None,
proposed_action=proposed_action,
raw_interrupt=interrupt if isinstance(interrupt, dict) else None,
)The pipeline is local apart from two calls: MinerU for layout and DeepSeek for the review (`app/api/config/config.py:20-37`, `app/api/services/lc_pipeline.py:451-465`). Findings persist in SQLite (`app/api/database/issues_repository.py:4,10`), so a forced re-review is a delete and a re-run (`app/api/routers/issues.py:86-90`).
The repository still carries an unused Azure Cosmos configuration class (`app/api/database/config.py:1-2`). The runtime data layer is SQLite and nothing imports that module; it is left over from the sample this project started from.
The README quotes a one-page contract returning six issues in 30 to 45 seconds. That is the author's observation and there is no timing artifact in the repository, so this page does not repeat the number.
Boundaries
When not to use it
The tool locates and proposes. Three cases where that is not enough.
- When the output has to be legally effective
- A review tool can point at a clause; it cannot sign, opine or bind. Anything that needs a qualified person to stand behind it stays with that person. The human step is gated (`app/api/routers/issues.py:283-296`), which makes it real rather than decorative, but it does not replace the reviewer.
- When the document has no text layer
- A scan without OCR leaves the locator nothing to search. The code returns no coordinates rather than guessing (`app/api/services/lc_pipeline.py:426-427`), and a finding without coordinates is not something the workbench can show.
- When nobody will open the PDF
- Every finding arrives with its clause text and its page. If the team will not look, accept becomes a rubber stamp and the record proves nothing. The accept and dismiss step exists to be used, not to be passed.
Terms
What people call it
- English-language results split between `document review` and `contract review`. The first is broader; the second is what most teams actually type.
- Japanese-language results use 契約書レビュー, Korean results use 계약서 검토, and Spanish results use revisión de contratos. The mechanics below do not change with the language.
- One note on intent: most results in this family are procurement pages for legal eDiscovery suites. This page is not that. It is written for engineering teams that review their own contracts, vendor agreements and internal reports.
Next
See it running
The workbench, the streaming review and the annotation layer ship together.
DocAuditAgent
Upload a PDF, stream the findings with their page and rectangle, and accept, dismiss or escalate each one.
The catalogue
Retrieval, agents, knowledge and analytics, each with its full source.