Ask a model to review a services agreement and it does a decent job: ninety-day payment terms, unlimited liability, a data-transfer clause with no safeguards. The answer arrives as a list in a chat window.
Then the contract goes to a colleague, who opens it and sees a clean document. The review is in another application, in a conversation they were not part of, keyed to sentences they now have to find by hand.
The model is not the problem. It has no way to write into the file. This post is about giving it one — with about a hundred lines of C# and GroupDocs.Annotation for .NET, so the same code marks up PDF, Word, Excel and PowerPoint server-side, with no Word add-in and no browser extension.
The full sample is on GitHub: ai-document-review-annotations-net.
Keep the contract with the model small
Do not ask a model for coordinates. Ask it for text and a remark:
[
{
"quote": "pay all invoices within ninety days",
"comment": "Ninety-day payment terms are three times our standard. Ask for thirty days.",
"severity": "high"
}
]
quote has to be copied verbatim from the document, because it is the anchor: the next step looks
for it on the page. Four to fifteen words is the sweet spot — long enough to be unique, short
enough that it does not wrap across half the page.
Everything else follows from those three fields, and none of it depends on the file format. That is
why one prompt serves a PDF, a .docx and a spreadsheet.
Find the quote on the page
GroupDocs.Annotation hands back the text of every page with its geometry:
using Annotator annotator = new Annotator("service-agreement.docx");
IDocumentInfo info = annotator.Document.GetDocumentInfo();
foreach (PageInfo page in info.PagesInfo)
foreach (TextLineInfo line in page.TextLines)
Console.WriteLine($"{line.Text} @ {line.LeftIndent},{line.TopIndent} {line.Width}x{line.Height}");
That same call answers for PDF, Word, Excel and PowerPoint — which is the part the Word-only tools cannot do.
Matching the quote takes three tries, in order: a single line that contains it; a run of consecutive lines whose joined text contains it, because a sentence on the page wraps where the model’s quote does not; and, failing both, nothing at all. A finding that cannot be anchored is reported, never placed at a guess. Normalise whitespace, curly quotes and dashes before comparing — models reproduce text with tidier typography than the document has.
The two rules that decide where the markup lands
This is where a first attempt usually goes wrong, so it is worth stating plainly.
Page numbers are counted twice. PageInfo.PageNumber from GetDocumentInfo() is 1-based.
AnnotationBase.PageNumber is 0-based. Pass the first straight into the second and every
annotation lands one page too far — and the ones meant for the last page disappear, because an
annotation addressed to a page that does not exist is dropped when the document is saved. No
exception, no markup, no message.
Two origins. TextLineInfo and AreaAnnotation.Box measure from the top-left corner of the
page. Text markup — HighlightAnnotation.Points and its relatives — measures from the
bottom-left, the convention PDF itself uses. A line at TopIndent = t with height h on a page
of height H therefore spans H - t - h to H - t.
| Member | Origin | Page numbering |
|---|---|---|
PageInfo, TextLineInfo |
top-left | 1-based |
AnnotationBase.PageNumber |
— | 0-based |
AreaAnnotation.Box |
top-left | — |
HighlightAnnotation.Points |
bottom-left | — |
PreviewOptions.PageNumbers |
— | 1-based |
Write the finding into the document
User reviewer = new User { Id = 1, Name = "AI reviewer", Role = Role.Editor };
float top = (float)(page.Height - box.Y);
float bottom = (float)(page.Height - box.Y - box.Height);
annotator.Add(new HighlightAnnotation
{
PageNumber = page.PageNumber - 1,
BackgroundColor = 0xFFE699,
Opacity = 0.5,
Message = finding.Comment,
CreatedOn = DateTime.Now,
User = reviewer,
Points = new List<Point>
{
new Point(left, top), new Point(right, top),
new Point(left, bottom), new Point(right, bottom)
},
Replies = new List<Reply>
{
new Reply
{
Comment = $"Severity: {finding.Severity}. Reply to accept or reject this remark.",
RepliedOn = DateTime.Now,
User = reviewer
}
}
});
annotator.Save("service-agreement.reviewed.docx");
Open the result in Word and it is a normal reviewed document: the phrase highlighted, the remark in the margin, a reply under it waiting for an answer. Open it in Acrobat and it is a highlight with a note. Nothing about it says “produced by a machine” except the author name — which is the point.
Two details worth stealing:
- Do not repeat the message in the reply. Word renders both, so the same sentence would show up twice in the margin. Let the message carry the finding and the reply open the thread.
- Colours are approximate in Word.
BackgroundColoris0xRRGGBBand a PDF keeps it exactly, but Word has a fixed palette of highlight colours and snaps to the nearest one. Choose severities that stay apart after snapping.
The part that makes it safe to try
Every annotation carries an author, so the whole machine-written pass can be withdrawn without touching a word a person wrote:
annotator.Remove(annotator.Get().Where(a => a.User?.Name == "AI reviewer").ToList());
The review is a layer, not an edit. Run it on a draft, see what it finds, delete it if it is noise,
run it again with a better prompt. Replies that people added stay in the same threads and come back
through Get(), so a second pass can see what was accepted and what was rejected.
Try it
git clone https://github.com/groupdocs-annotation/ai-document-review-annotations-net.git
cd ai-document-review-annotations-net
dotnet run --project src/AiReview -- review samples/service-agreement.docx
The sample ships a findings file so it runs offline; set ANTHROPIC_API_KEY and use llm-review
to have a model produce them instead. The prompt is a single markdown file, and swapping the model
is swapping one HTTP call.
The step-by-step version, with the coordinate rules spelled out, is in the documentation: Write AI review comments into documents. If you would rather drive this from a chat client than from your own code, the GroupDocs.Annotation MCP server does the same job through Claude, Cursor or Copilot.