DEV Community

Building a Multimodal, Suggestion-Based AI Writing Coach with Gemini and Google Docs

Tanaike on October 23, 2026

When we use generative AI to proofread our daily writing—whether technical reports, business proposals, or blog posts—it is easy to fall into the...
Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The two-phase chained batchUpdate is the detail that would have saved me a week once — I learned the hard way that you can't attach a comment reply to a suggestion before it exists, and my first attempt at exactly this kind of reviewer fired the replies at ghost IDs. Your descending-index sort for replacements is the same class of fix: make the indices stable before the writes land. For the multimodal figures, does the anchored review comment survive if the user rejects the table edit it refers to, or do you re-anchor after every accept/reject?

Collapse
 
tanaike profile image
Tanaike Google Developer Experts •

Thanks for the insightful comment! Hitting the "ghost ID" issue when trying to attach replies in the same batch as WriteMode.SUGGEST is definitely a classic Google Docs API rite of passage.

Regarding your question: Yes, the anchored review comments (insertComment) survive automatically if the user rejects the table edit, and we don't need to re-anchor after accepts/rejects.

Because Google Docs doesn't expose a real-time trigger for individual suggestion accepts/rejects, we handle the index shift upfront in Step 4 as mentioned in my article:

  1. First, we execute the SUGGEST batchUpdate and attach the addCommentReply coaching notes to createdSuggestionIds.
  2. Next, we immediately re-fetch the document (documents.get) to get the post-suggestion indices and anchor insertComment onto the figure (VISUAL_1) or table (TABLE_1) using a 3-tier range fallback (exact element range ➔ containing paragraph range ➔ nearest settled text block).

Since insertComment attaches to the underlying structural range (the inlineObjectElement or base table text) rather than the pending suggestion ID, rejecting a table cell suggestion simply removes the SUGGEST overlay while leaving the base range—and the anchored multimodal comment—completely intact.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The "anchor to structure, not the handle" framing is the part I wish I'd internalized earlier. When I built a document-generation pipeline on Lambda, my downstream references kept breaking on the same index shift — I was anchoring to positions returned before the batch, and every accept or reject moved the floor under me. Your re-fetch-and-reanchor step names the fix precisely.

It reminds me of the same mistake in retry-safe systems: anchoring to a request token instead of the business identifier orphans the retry and duplicates the work. Same bug, different API.

One thing I keep wondering: when the user rejects the whole table rather than a cell, and the base range itself disappears — does the multimodal comment fall back to the nearest settled text block, or does it die with the element?

Thread Thread
 
tanaike profile image
Tanaike Google Developer Experts •

I love that retry-safe system analogy—"anchoring to a request token instead of the business identifier" captures the exact architectural trap.

To answer your question: in the normal case, it dies with the element.

Here is what happens under the hood:

  1. The 3-Tier Fallback Runs at Creation Time (Step 4):
    When Step 4 calls insertComment, it tries the 3 tiers sequentially until the Docs API returns HTTP 200:

    • Tier 1: The element's exact range (for a table, the first cell's character range firstCellStartIndex..firstCellEndIndex).
    • Tier 2: The containing paragraph range.
    • Tier 3: The nearest settled text block (e.g., an adjacent paragraph or caption outside the element).
  2. Post-Creation Lifecycle (Owned by Google Docs commentAnchors):

    • Whenever Tier 1 succeeds (which is the standard path for tables), Google Docs binds the comment's anchorId to that cell range inside the table. If the user later deletes the entire table—or rejects a whole-table insertion—so that the entire anchored character span collapses to zero, Google Docs automatically detaches/hides the comment along with the table rather than migrating it to a neighboring paragraph.
    • The only case where the comment survives a full-table removal is if Tier 1/Tier 2 failed during Step 4 creation and it had to fall back to Tier 3 (an adjacent settled paragraph outside the table).

In practice, having the comment die with the table when the whole table is rejected actually turns out to be desirable UX—otherwise, the author would be left with an orphaned table critique attached to an unrelated paragraph after getting rid of the table!

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

"It dies with the element" is the answer I was hoping for — and your orphaned-critique point nails why. A coaching comment that outlives the text it criticized would be worse than useless; it would be actively misleading to the author.

The retry-safe parallel I keep coming back to: an idempotency record has exactly the same lifecycle problem. The record must die with the operation it protects, never linger and claim coverage over a different request. I ran into the inverse case when I built a document-generation pipeline on Lambda — there I wanted the review decisions to survive, so I copied every accept/reject decision into a separate table and treated the document as the working copy, not the system of record.

So the real question is which one the comment is: working note or record. For the writing coach, are you keeping any separate review ledger at all, or is the document with its anchored comments the only artifact of the review?

Thread Thread
 
tanaike profile image
Tanaike Google Developer Experts •

You framed that distinction—"working note vs. system of record"—beautifully.

We don't maintain an external database table because Google Docs actually separates the "working view" from the "review ledger" natively, and we complement that with a distilled summary:

  1. Google Docs' Built-in Ledger ("Show all comments" Drawer):
    When an inline suggestion is accepted or rejected (acceptSuggestion / rejectSuggestion), or when a comment is resolved or unanchored, it disappears from the page margin so the working canvas stays clean. However, unless a standalone comment is explicitly hard-deleted via deleteComment, Google Docs automatically preserves the full history—including accepted/rejected suggestions and our attached Reason & Learning Point replies—inside the native "Show all comments" panel at the top-right of the UI (complete with All, Open, and Resolved filters). So Google Docs itself serves as the raw audit trail without needing an external DB!

  2. Distilled Takeaways ("Writing Coach & Learning Summary" + Copy to Clipboard):
    While the "Show all comments" drawer keeps the raw item-by-item history, scrolling through dozens of resolved threads isn't ideal for long-term study. That is why DocuMentor AI also synthesizes the recurring writing habits and actionable advice into the 🎓 Writing Coach & Learning Summary modal dialog (with a one-click 📋 Copy to Clipboard button)—and lets you run 🎓 Generate Learning Summary from Suggestions & Comments on existing human/AI reviews anytime so you can paste the high-level takeaways into your personal study notes.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@tanaike That answers it: one batched call per run, and with selection-only on, just the selected excerpt leaves the machine. Uploading it once and deleting it right after the call is the part I'd have wanted to see.

Collapse
 
stefan_07d23f966225156b50 profile image
Stefan •

I'd be tempted to add a "hint first" mode: flag the sentence and explain the issue, then let me try fixing it before showing the rewrite. With the corrected version already there, I could still click through without learning much. The interesting test would be whether I catch the same mistake in my next draft.

Collapse
 
tanaike profile image
Tanaike Google Developer Experts •

Thank you for the fantastic suggestion! Your point about the temptation to passively click through when the rewrite is already visible hit the nail on the head—so I just implemented and shipped your idea in v1.0.2 on GitHub! 🎉

Here is how the new 💡 Hint-Only Mode (Comment Without Rewrite) works in v1.0.2:

  1. Flag & Coach on the Canvas (insertComment Instead of WriteMode.SUGGEST): When you toggle ON 💡 Hint-Only Mode in the sidebar, DocuMentor AI skips deleteContentRange / insertText (WriteMode.SUGGEST). Instead, it anchors a native Google Docs comment (insertComment) directly onto the exact UTF-16 range of the flagged phrase showing only the 🎯 Flagged Phrase, 💡 Issue (reason), and 🎓 Coaching Hint (learningPoint)—leaving the sentence untouched so you can try revising it yourself first.
  2. On-Demand Answer Key in the Sidebar: In Step 2 of the sidebar dashboard, the AI's reference rewrite (suggestedText) is hidden inside a collapsible 👁️ Show Reference Rewrite (After Trying Yourself) accordion. Once you've rewritten the sentence in the doc, you can expand it to compare your fix against Gemini's version.
  3. Tracking Habit Retention Across Reviews (📜 Include Resolved Review History): To address your last point about testing whether you catch the same mistake in future passes, v1.0.2 also extracts resolved comments (RESOLVED) and accepted/rejected suggestions (ACCEPTED / REJECTED) via commentsViewMode=COMMENTS_VIEW_MODE_INCLUDED. Even after you resolve your hint comments or finish a review pass, you can generate a cumulative 🎓 Writing Coach & Learning Summary from your resolved history to track your recurring habits over time.

Thanks again for inspiring a great pedagogical upgrade!

Collapse
 
rivenpark profile image
Riven Park •

The v1.0.1 change to send only the selected excerpt makes the scope much clearer. I'd add one regression case combining Selection Only with Include Resolved Review History: put a synthetic marker in an out-of-selection resolved comment and confirm it is absent from both the text payload and uploaded files. The history path needs the same scope boundary as the manuscript path; otherwise a later coaching feature could quietly widen what leaves the document.

Collapse
 
megancollins32 profile image
Megan Collins •

DocuMentor AI’s suggestion mode encourages writers to consciously evaluate edits rather than accepting automatic changes, fostering a more reflective drafting process. Integrating data from the Riverside County Database into the same workflow would allow relevant public documents to be referenced instantly, enhancing both accuracy and compliance. This seamless blend of AI assistance with authoritative records can streamline collaboration across teams. Ultimately, such an approach aligns with the goal of producing higher‑quality content while respecting legal and informational standards.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

What a suggestion costs depends on how much goes out with it. Does each one ship the whole document again, or just the paragraph it touches?

Collapse
 
tanaike profile image
Tanaike Google Developer Experts •

Thank you for the great question!

First, regarding whether each suggestion triggers its own trip: it never ships the document per suggestion. Both in the initial release (v1.0.0) and the latest update (v1.0.1), the entire workflow runs in a single batched pass:

  1. Generating Suggestions (1 Single Gemini API Call per Run):
    Whether DocuMentor AI proposes 1 edit or 50 edits, it makes only one generateContent request. Using Gemini's Structured Outputs (responseSchema), all inline text suggestions, table/figure review comments, and the learning summary are returned together in a single JSON response.

  2. Applying Suggestions to Google Docs (1 Single Delta batchUpdate):
    When writing suggestions back to Google Docs (WriteMode.SUGGEST), it does not send the document back. Step 3 computes the exact UTF-16 startIndex and endIndex offsets for each touched phrase, sorts them in descending order, and sends one single documents.batchUpdate call containing only the character-range deltas (deleteContentRange + insertText).

  3. Whole Document vs. Just the Touched Paragraph (v1.0.0 vs. v1.0.1):
    This depends on whether you run Full-Tab Mode or Proofread Selection Only Mode, and I actually just updated this part in v1.0.1:

    • In v1.0.0: Even when "Proofread Selection Only" was turned ON for a single paragraph, the script still embedded the full active tab's text inside the prompt string for background context alongside the target editable blocks.
    • In v1.0.1 (Latest):
      1. When "Proofread Selection Only" is ON, it now extracts and ships strictly the selected paragraph/elements (selected_excerpt.md)—none of the unselected document text is sent.
      2. Furthermore, instead of embedding manuscript text directly inside the JSON prompt string, the target text (either the full tab for holistic cross-checking or just your selected excerpt) is now uploaded once as a lightweight Markdown file via the Gemini File API (fileData.fileUri) in parallel with any figures, and automatically deleted in the finally block right after generation.