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. BackgroundColor is 0xRRGGBB and 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.