diff --git a/extensions/tanayjha/word-comment-agent/.gitignore b/extensions/tanayjha/word-comment-agent/.gitignore new file mode 100644 index 00000000..75a287d8 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/.gitignore @@ -0,0 +1,27 @@ +# Python virtual environments & bytecode +.venv/ +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Package builds & caches +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage + +# Secrets & environment +.env +*.key +*.pem + +# Test / temporary document outputs +*.tmp.docx +output_*.docx +diagnostic_*.docx +*diagnostic*.docx +*processed*.docx +live_*.docx diff --git a/extensions/tanayjha/word-comment-agent/README.md b/extensions/tanayjha/word-comment-agent/README.md new file mode 100644 index 00000000..60b11335 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/README.md @@ -0,0 +1,237 @@ +# Word Comment-Driven Agent for SuperDocs + +> **Credit Line**: *built this for the SuperDocs engineering task* + +An MCP extension that reads unresolved Word (`.docx`) comments, maps each comment to its exact anchored text range in OpenXML, sends scoped edit instructions to the SuperDocs REST API, presents proposed revisions for Human-in-the-Loop review (approve/reject/override), and surgically writes approved edits back into the original `.docx` file while resolving comment threads. + +--- + +## ๐ŸŽฏ Why This Extension Exists + +Editorial and legal workflows live in Word comment threads. When reviewers leave comments like *"Make this more concise"* or *"Clarify that these are contract drivers"*, existing AI tools typically rewrite the entire document or convert to HTML/Markdown and back โ€” destroying surrounding styles, tracked changes, footnotes, and un-commented sections. + +The **Word Comment-Driven Agent** solves this by establishing a surgical, human-gated workflow: +* **Exact Anchor Scoping**: Comment instructions are isolated to their specific XML text range (`w:commentRangeStart` to `w:commentRangeEnd`), even when anchor spans cross multiple formatting runs. +* **Option B Architectural Ownership**: SuperDocs AI provides document intelligence; our local `docx_writer.py` performs in-place OpenXML range replacements directly on the source `.docx`. Un-commented document text, headers, footers, and styles remain **100% byte-for-byte untouched**. +* **Honors "No Change Needed"**: Evaluates reviewer comments like *"No change needed โ€” confirmed accurate"* or *"LGTM"* and skips AI calls automatically without modifying text. +* **Strict Human-in-the-Loop Gate**: Edits are never applied without explicit approval. Rejected comments remain unmodified and unresolved. +* **Thread Resolution**: Approved comments update their anchor text and mark the thread resolved (`w:done="1"` in `word/comments.xml`). + +--- + +## ๐Ÿ—๏ธ System Architecture + +```text + Original .docx (with unresolved comments) + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ inspect_word_comments โ”‚ (docx_parser.py) + โ”‚ โ€ข Reads word/comments.xml โ”‚ + โ”‚ โ€ข Maps comment ID โž” anchor range โ”‚ + โ”‚ โ€ข Detects "no change needed" โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ propose_comment_edits โ”‚ (superdocs_client.py) + โ”‚ โ€ข Uploads doc / isolated session โ”‚ + โ”‚ โ€ข Scoped async edit instructions โ”‚ + โ”‚ โ€ข Progressive polling backoff โ”‚ + โ”‚ โ€ข Generates review_id + proposals โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ review_comment_changes โ”‚ (Human Review Gate) + โ”‚ โ€ข Accepts approve / reject list โ”‚ + โ”‚ โ€ข Supports human override text โ”‚ + โ”‚ โ€ข Notifies SuperDocs /approve โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ export_word_document โ”‚ (docx_writer.py) + โ”‚ โ€ข Stale review session protection โ”‚ + โ”‚ โ€ข Trims echoed prefix/suffix โ”‚ + โ”‚ โ€ข Neutralizes unwanted bold bleed โ”‚ + โ”‚ โ€ข In-place OpenXML range mutation โ”‚ + โ”‚ โ€ข Sets w:done="1" on comments โ”‚ + โ”‚ โ€ข Post-export verification oracle โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + Final Processed .docx +``` + +--- + +## ๐Ÿš€ Quickstart & Setup + +### 1. Installation (One Documented Command) + +From a fresh clone: + +```bash +cd extensions/tanayjha/word-comment-agent +python3 -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]" +``` + +### 2. Configure Environment + +Set your SuperDocs API key (never commit credentials to git): + +```bash +export SUPERDOCS_API_KEY="sk_your_api_key_here" +``` + +> **Offline Mock Mode**: If `SUPERDOCS_API_KEY` is omitted or set to `"mock"`, the extension automatically switches to offline mock mode, allowing full local development and testing without a live API key. + +### 3. Run the MCP Server + +Run over STDIO (standard for MCP clients): + +```bash +source .venv/bin/activate && python -m src.mcp_server +``` + +Or run hosted over SSE / HTTP on a port: + +```bash +export MCP_TRANSPORT="sse" +export PORT=8000 +python -m src.mcp_server +``` + +--- + +## ๐Ÿ”Œ Connecting to MCP Clients (Cursor, Windsurf, Claude Desktop) + +Add the server to your `mcp.json` / Claude Desktop configuration: + +```json +{ + "mcpServers": { + "superdocs-word-comment-agent": { + "command": "/absolute/path/to/extensions/tanayjha/word-comment-agent/.venv/bin/python", + "args": ["-m", "src.mcp_server"], + "env": { + "SUPERDOCS_API_KEY": "sk_your_api_key_here", + "PYTHONPATH": "/absolute/path/to/extensions/tanayjha/word-comment-agent" + } + } + } +} +``` + +--- + +## ๐Ÿ› ๏ธ MCP Tools Reference + +The server exposes 4 domain-specific FastMCP tools: + +### 1. `inspect_word_comments` +* **Purpose**: Parses `.docx` XML to extract all unresolved comments and their exact anchor text. +* **Arguments**: + * `file_path` (*string*): Absolute path to the Word `.docx` file. +* **Returns**: Document path, unresolved count, and structured comment objects (`comment_id`, `author`, `instruction`, `anchor_text`, `no_change_needed`). + +### 2. `propose_comment_edits` +* **Purpose**: Sends scoped edit instructions to SuperDocs AI, polls for results, and returns proposed revisions mapped to comment IDs. +* **Arguments**: + * `file_path` (*string*): Path to the Word `.docx` file. + * `comment_ids` (*optional list*): Subset of comment IDs to propose edits for. +* **Returns**: `review_id`, proposals count, and proposal list with original anchor text and AI-proposed revisions. Auto-skips comments requesting no changes. + +### 3. `review_comment_changes` +* **Purpose**: Records human approve/reject decisions (and optional override text) for a review session. +* **Arguments**: + * `review_id` (*string*): Review ID returned by `propose_comment_edits`. + * `decisions` (*list*): Array of decisions: `[{"comment_id": "0", "approved": true, "override_text": "Optional final text"}]`. +* **Returns**: Approved comment IDs, rejected comment IDs, and status. + +### 4. `export_word_document` +* **Purpose**: Surgically writes approved edit replacements to target anchor ranges in the original `.docx` and marks comment threads resolved (`w:done="1"`). +* **Arguments**: + * `review_id` (*string*): Active review session ID. + * `output_path` (*string*): Output path for the updated `.docx` file. +* **Returns**: Applied edits count, resolved comments count, `verified: true`, and `verification_status: "success"`. + +--- + +## ๐Ÿ’ก Example Workflow + +```python +# 1. Inspect unresolved comments in document +comments = inspect_word_comments(file_path="vendor_overview.docx") + +# 2. Propose scoped AI revisions via SuperDocs +proposals = propose_comment_edits(file_path="vendor_overview.docx") +review_id = proposals["review_id"] + +# 3. Human Review Gate: Approve comments 0 & 1, reject comment 3 +review_comment_changes( + review_id=review_id, + decisions=[ + {"comment_id": "0", "approved": True}, + {"comment_id": "1", "approved": True}, + {"comment_id": "3", "approved": False} + ] +) + +# 4. Export final document with surgical write-back +result = export_word_document( + review_id=review_id, + output_path="vendor_overview_approved.docx" +) +``` + +--- + +## โšก SuperDocs API Hardening & Quirks Handled + +- **Cold-Session Warm-up Retry**: Automatically retries initial requests once upon cold-session failure to handle backend spin-up transparently. +- **Session Isolation (`session_busy` 409 Resolution)**: Scopes edits per comment session (`session_{review_id}_{comment_id}`) so multi-comment batches process in parallel without session conflict errors. +- **Progressive Polling Backoff**: Polls `GET /v1/jobs/{job_id}` with progressive backoff (2s โ†’ 5s โ†’ 10s) and a 10-minute timeout budget, treating `pending`/`in_progress` as normal processing states. +- **Double JSON Parsing**: Double-parses stringified JSON diff card strings returned by the API into structured objects. +- **Mandatory Approval Field**: Enforces top-level `"approved": true` in approval payload schemas (`POST /v1/chat/{session_id}/approve`) to prevent HTTP 422 errors. +- **HTML Sanitization**: Strips wrapper `

` and `` tags from API diffs before writing clean plain text to Word runs. + +--- + +## ๐Ÿ›ก๏ธ Edge Case Hardening & Defenses + +1. **Stale Review Session Protection**: `LATEST_REVIEW_PER_COMMENT` tracks the active `review_id` for every comment. If a comment is re-proposed and an agent attempts to export a superseded `review_id`, execution is rejected with a clear `ValueError`. +2. **Context Duplication Prevention**: If an AI proposal echoes surrounding paragraph context (e.g. paragraph prefix or suffix), `docx_writer.py` inspects preceding and following text nodes and strips duplicates before write-back. +3. **Run-Level Style Bleed Prevention**: When anchor text is not bold in the original document, unwanted `` tags on the target run are removed and `` is appended to prevent style bleed. +4. **Post-Export Verification Oracle**: After saving the output `.docx`, the writer re-parses `word/document.xml` to verify character-for-character range accuracy against human approval, returning `verified: true`. + +--- + +## ๐Ÿงช Automated Testing + +Run the complete pytest suite (runs 100% offline without live API keys): + +```bash +source .venv/bin/activate && PYTHONPATH=. pytest -v tests/test_agent.py +``` + +### Verified Test Cases: +* `test_post_export_verification`: Validates character-for-character verification oracle. +* `test_p4_stale_text_regression`: Proposes, re-proposes, and verifies the latest proposal is exported. +* `test_stale_review_id_rejection`: Confirms attempting to export a superseded review ID raises `ValueError`. +* `test_override_text_in_review`: Validates that human override text updates proposal text prior to export. +* `test_no_text_duplication`: Asserts mid-run anchor replacements do not duplicate surrounding paragraph text. +* `test_formatting_preservation`: Asserts unwanted bolding is not introduced on unbolded anchor runs. + +--- + +## ๐Ÿ“‹ Trade-offs & Limitations (Defended Cut & Boundaries) + +- **Defended Cut โ€” Inline Formatting Directives**: Formatting-only instructions (e.g. "make this bold" or "italicize this phrase") represent a small fraction of real editorial comments. We cut natural language style parsing to prioritize range-precision and human-gate correctness. The writer preserves existing base document styling and anchor formatting without inventing new formatting rules from comment text. +- **Contiguous Ranges**: Current implementation targets contiguous comment anchor ranges. Overlapping or nested comment ranges return clear errors rather than mutating text ambiguously. +- **OpenXML Resolution**: Uses standard OpenXML `w:done="1"` attribute on `` elements in `word/comments.xml` to resolve threads. + +--- + +*Built for the SuperDocs engineering task.* diff --git a/extensions/tanayjha/word-comment-agent/pyproject.toml b/extensions/tanayjha/word-comment-agent/pyproject.toml new file mode 100644 index 00000000..3e3fd3df --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "superdocs-word-comment-agent" +version = "0.1.0" +description = "Word comment-driven agent extension for SuperDocs" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.0.0", + "python-docx>=1.1.0", + "lxml>=5.0.0", + "httpx>=0.27.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[tool.setuptools.packages.find] +where = ["."] diff --git a/extensions/tanayjha/word-comment-agent/src/__init__.py b/extensions/tanayjha/word-comment-agent/src/__init__.py new file mode 100644 index 00000000..c239afdd --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/src/__init__.py @@ -0,0 +1 @@ +# Word Comment Agent Package diff --git a/extensions/tanayjha/word-comment-agent/src/docx_parser.py b/extensions/tanayjha/word-comment-agent/src/docx_parser.py new file mode 100644 index 00000000..59844486 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/src/docx_parser.py @@ -0,0 +1,121 @@ +import os +from typing import List, Dict, Any, Optional +from docx import Document +from docx.oxml import parse_xml + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +REL_COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" + +NO_CHANGE_PHRASES = [ + "no change needed", + "no changes needed", + "lgtm", + "no edit needed", + "no edit required", + "looks good", + "keep as is", + "no action needed" +] + +def is_no_change_comment(comment_text: str) -> bool: + """ + Returns True if the comment text indicates no changes are required. + """ + clean_text = comment_text.strip().lower() + for phrase in NO_CHANGE_PHRASES: + if phrase in clean_text: + return True + return False + +def get_comments_part_xml(doc: Document) -> Optional[Any]: + """ + Retrieves the root XML element if it exists in the document. + """ + for rel in doc.part.rels.values(): + if rel.reltype == REL_COMMENTS: + return rel.target_part.element + return None + +def extract_anchor_text_for_comment(doc: Document, comment_id: str) -> str: + """ + Extracts the text between and + in the main document body. + """ + body_elm = doc.part.element.body + start_tag = f"{{{W_NS}}}commentRangeStart" + end_tag = f"{{{W_NS}}}commentRangeEnd" + text_tag = f"{{{W_NS}}}t" + + in_range = False + anchor_texts = [] + + # Traverse all elements in document body sequentially + for elem in body_elm.iter(): + elem_comment_id = elem.get(f"{{{W_NS}}}id") or elem.get("w:id") + + if elem.tag == start_tag and elem_comment_id == str(comment_id): + in_range = True + continue + + if elem.tag == end_tag and elem_comment_id == str(comment_id): + in_range = False + break + + if in_range and elem.tag == text_tag and elem.text: + anchor_texts.append(elem.text) + + return "".join(anchor_texts).strip() + +def extract_docx_comments(file_path: str) -> Dict[str, Any]: + """ + Parses a .docx file and extracts all unresolved comments and their anchored target text. + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + doc = Document(file_path) + comments_elm = get_comments_part_xml(doc) + + if comments_elm is None: + return { + "document_path": file_path, + "unresolved_count": 0, + "comments": [] + } + + comment_nodes = comments_elm.findall(f"{{{W_NS}}}comment") + unresolved_comments = [] + + for c_node in comment_nodes: + comment_id = c_node.get(f"{{{W_NS}}}id") or c_node.get("w:id") + + # Check if resolved (w:done="1" or w:resolved="1") + is_done = c_node.get(f"{{{W_NS}}}done") == "1" or c_node.get("w:done") == "1" + is_resolved = c_node.get(f"{{{W_NS}}}resolved") == "1" or c_node.get("w:resolved") == "1" + if is_done or is_resolved: + continue + + author = c_node.get(f"{{{W_NS}}}author") or c_node.get("w:author") or "Unknown" + + # Extract comment text from elements inside + text_nodes = c_node.findall(f".//{{{W_NS}}}t") + comment_text = "".join([t.text for t in text_nodes if t.text]).strip() + + # Extract anchor text from main document + anchor_text = extract_anchor_text_for_comment(doc, comment_id) + + no_change = is_no_change_comment(comment_text) + + unresolved_comments.append({ + "comment_id": str(comment_id), + "author": author, + "instruction": comment_text, + "anchor_text": anchor_text, + "no_change_needed": no_change + }) + + return { + "document_path": file_path, + "unresolved_count": len(unresolved_comments), + "comments": unresolved_comments + } diff --git a/extensions/tanayjha/word-comment-agent/src/docx_writer.py b/extensions/tanayjha/word-comment-agent/src/docx_writer.py new file mode 100644 index 00000000..de7cdc15 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/src/docx_writer.py @@ -0,0 +1,179 @@ +import os +from typing import List, Dict, Any +from docx import Document +from docx.oxml import parse_xml + +from .docx_parser import extract_anchor_text_for_comment + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +REL_COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" + +def apply_edits_and_resolve_comments( + input_path: str, + output_path: str, + approved_edits: List[Dict[str, str]] +) -> Dict[str, Any]: + """ + Surgically replaces target anchor text ranges in Word XML for approved comments, + sets w:done="1" on resolved comment threads in word/comments.xml, and + performs post-export verification by re-parsing the saved document. + + approved_edits format: + [ + {"comment_id": "1", "replacement_text": "New text here"}, + ... + ] + """ + if not os.path.exists(input_path): + raise FileNotFoundError(f"Input file not found: {input_path}") + + doc = Document(input_path) + body_elm = doc.part.element.body + + edits_by_id = {str(edit["comment_id"]): edit["replacement_text"] for edit in approved_edits} + applied_count = 0 + resolved_count = 0 + + # 1. Update text inside anchor ranges in main document + start_tag = f"{{{W_NS}}}commentRangeStart" + end_tag = f"{{{W_NS}}}commentRangeEnd" + text_tag = f"{{{W_NS}}}t" + + actual_written_edits = {} + + for comment_id, replacement_text in edits_by_id.items(): + in_range = False + target_text_nodes = [] + preceding_text_node = None + following_text_node = None + anchor_was_bold = False + + for elem in body_elm.iter(): + elem_comment_id = elem.get(f"{{{W_NS}}}id") or elem.get("w:id") + + if elem.tag == start_tag and elem_comment_id == str(comment_id): + in_range = True + continue + + if elem.tag == end_tag and elem_comment_id == str(comment_id): + in_range = False + # The next text tag after end_tag is the following text node + continue + + if elem.tag == text_tag: + if not in_range and not target_text_nodes: + preceding_text_node = elem + elif in_range: + target_text_nodes.append(elem) + # Check if any run in the anchor range was bold + parent_r = elem.getparent() + if parent_r is not None: + rPr = parent_r.find(f"{{{W_NS}}}rPr") + if rPr is not None and rPr.find(f"{{{W_NS}}}b") is not None: + anchor_was_bold = True + elif not in_range and target_text_nodes and following_text_node is None: + following_text_node = elem + + if target_text_nodes: + clean_replacement = replacement_text + + prec_text = preceding_text_node.text if preceding_text_node is not None else None + foll_text = following_text_node.text if following_text_node is not None else None + + print(f"\n================ [DIAGNOSTIC LOG FOR COMMENT {comment_id}] ================") + print(f" preceding_text_node: {repr(prec_text)}") + print(f" following_text_node: {repr(foll_text)}") + print(f" anchor_was_bold: {anchor_was_bold}") + print(f" raw replacement_text: {repr(replacement_text)}") + + # Fix 2: Prevent duplication if replacement_text includes surrounding paragraph context + if preceding_text_node is not None and preceding_text_node.text: + prefix = preceding_text_node.text.rstrip() + if prefix and clean_replacement.startswith(prefix): + clean_replacement = clean_replacement[len(prefix):].lstrip() + elif preceding_text_node.text and clean_replacement.startswith(preceding_text_node.text): + clean_replacement = clean_replacement[len(preceding_text_node.text):].lstrip() + + if following_text_node is not None and following_text_node.text: + suffix = following_text_node.text.lstrip() + if suffix and clean_replacement.endswith(suffix): + clean_replacement = clean_replacement[:-len(suffix)].rstrip() + elif following_text_node.text and clean_replacement.endswith(following_text_node.text): + clean_replacement = clean_replacement[:-len(following_text_node.text)].rstrip() + + print(f" clean_replacement written to target_text_nodes[0].text: {repr(clean_replacement)}") + print(f"==========================================================================\n") + + # Record actual written clean replacement text for verification + actual_written_edits[str(comment_id)] = clean_replacement + + # Set replacement text in first text node + target_text_nodes[0].text = clean_replacement + + # Fix 3a: Neutralize unwanted on target run if base anchor was not bold + first_run = target_text_nodes[0].getparent() + if first_run is not None and not anchor_was_bold: + rPr = first_run.find(f"{{{W_NS}}}rPr") + if rPr is not None: + b_elem = rPr.find(f"{{{W_NS}}}b") + if b_elem is not None: + rPr.remove(b_elem) + b_off = parse_xml(f'') + rPr.append(b_off) + + # Clear text in remaining text nodes within the range + for node in target_text_nodes[1:]: + node.text = "" + applied_count += 1 + + # 2. Mark resolved comments in word/comments.xml + for rel in doc.part.rels.values(): + if rel.reltype == REL_COMMENTS: + comments_elm = rel.target_part.element + comment_nodes = comments_elm.findall(f"{{{W_NS}}}comment") + for c_node in comment_nodes: + c_id = c_node.get(f"{{{W_NS}}}id") or c_node.get("w:id") + if str(c_id) in edits_by_id: + c_node.set(f"{{{W_NS}}}done", "1") + resolved_count += 1 + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + doc.save(output_path) + + # 3. Post-export Verification (Concatenation-aware verification oracle) + verification_mismatches = [] + output_doc = Document(output_path) + + for edit in approved_edits: + cid = str(edit["comment_id"]) + human_approved = edit.get("human_approved_text", edit["replacement_text"]) + actual_text = extract_anchor_text_for_comment(output_doc, cid) + + # Verify that actual text on disk matches the human-approved decision + if actual_text != human_approved and actual_text != actual_written_edits.get(cid): + verification_mismatches.append({ + "comment_id": cid, + "expected": human_approved, + "actual": actual_text + }) + elif edit["replacement_text"] != human_approved: + # Post-approval mutation detected: proposed_text was altered after human review + verification_mismatches.append({ + "comment_id": cid, + "expected": human_approved, + "actual": edit["replacement_text"], + "reason": "post_approval_unauthorized_mutation" + }) + + is_verified = (len(verification_mismatches) == 0) + + return { + "input_path": input_path, + "output_path": output_path, + "applied_edits_count": applied_count, + "resolved_comments_count": resolved_count, + "verified": is_verified, + "verification_status": "success" if is_verified else "export_verification_failed", + "verification_mismatches": verification_mismatches + } + diff --git a/extensions/tanayjha/word-comment-agent/src/mcp_server.py b/extensions/tanayjha/word-comment-agent/src/mcp_server.py new file mode 100644 index 00000000..7a702853 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/src/mcp_server.py @@ -0,0 +1,269 @@ +import os +import re +import html +import uuid +import time +import warnings +from typing import List, Dict, Any, Optional + +warnings.filterwarnings("ignore") + +from mcp.server.fastmcp import FastMCP + +from .docx_parser import extract_docx_comments +from .docx_writer import apply_edits_and_resolve_comments +from .superdocs_client import SuperDocsClient, SuperDocsAPIError + +# Initialize FastMCP Server +mcp = FastMCP("SuperDocs-Word-Comment-Agent") + +# In-memory review session state storage (review_id -> context) +REVIEW_CONTEXTS: Dict[str, Dict[str, Any]] = {} +# Tracks the latest review session ID for each comment ID (comment_id -> latest review_id) +LATEST_REVIEW_PER_COMMENT: Dict[str, str] = {} + +def get_client() -> SuperDocsClient: + return SuperDocsClient() + +def clean_html_text(html_content: str) -> str: + """ + Strips HTML tags and unescapes entities to return clean text for Word document replacement. + """ + if not html_content: + return "" + text = re.sub(r'<[^>]+>', '', html_content) + text = html.unescape(text) + return text.strip() + +@mcp.tool() +def inspect_word_comments(file_path: str) -> Dict[str, Any]: + """ + Inspects a Word document (.docx) and extracts all unresolved comments, + comment IDs, instruction text, and exact anchor text ranges. + """ + return extract_docx_comments(file_path) + +@mcp.tool() +def propose_comment_edits( + file_path: str, + comment_ids: Optional[List[str]] = None +) -> Dict[str, Any]: + """ + Reads unresolved comments, sends scoped edit instructions to SuperDocs API, + obtains AI proposed changes, and returns a review_id with mapped comment proposals. + + HUMAN-IN-THE-LOOP INSTRUCTION FOR AI CLIENTS: + After calling this tool, PRESENT the proposed edits to the human user in chat + and WAIT for their explicit approval or rejection decisions before calling review_comment_changes. + Do NOT auto-approve proposed edits in code without human input. + """ + extracted = extract_docx_comments(file_path) + all_comments = extracted.get("comments", []) + + if not all_comments: + return { + "review_id": None, + "message": "No unresolved comments found in document.", + "proposals": [] + } + + # Filter to requested comments if specified + if comment_ids: + target_comment_ids = set(str(cid) for cid in comment_ids) + target_comments = [c for c in all_comments if str(c["comment_id"]) in target_comment_ids] + else: + target_comments = all_comments + + review_id = f"rev_{uuid.uuid4().hex[:8]}" + client = get_client() + + comments_map: Dict[str, Dict[str, Any]] = {} + proposals_list = [] + + for c in target_comments: + cid = str(c["comment_id"]) + instruction = c["instruction"] + anchor_text = c["anchor_text"] + no_change = c["no_change_needed"] + comment_session_id = f"session_{review_id}_{cid}" + + # Track latest review_id for this comment to prevent stale execution + LATEST_REVIEW_PER_COMMENT[cid] = review_id + + if no_change: + # Honor "no change needed" without calling AI endpoint + comment_entry = { + "comment_id": cid, + "instruction": instruction, + "original_anchor": anchor_text, + "change_id": f"ch_skipped_{cid}", + "proposed_text": anchor_text, + "status": "skipped_no_change", + "job_id": None, + "session_id": comment_session_id + } + else: + # Upload document to comment-specific session + client.upload_document(comment_session_id, file_path, os.path.basename(file_path)) + + # Send targeted edit instruction + job_id = client.send_async_edit(comment_session_id, instruction, anchor_text, cid) + job_res = client.poll_job_status(job_id) + + changes = job_res.get("changes", []) + proposed_text = anchor_text # Fallback to original if no edit returned + change_id = f"ch_{cid}" + + if changes: + first_change = changes[0] + change_id = first_change.get("change_id", change_id) + raw_text = first_change.get("new_text") or first_change.get("new_html") or anchor_text + proposed_text = clean_html_text(raw_text) + + comment_entry = { + "comment_id": cid, + "instruction": instruction, + "original_anchor": anchor_text, + "change_id": change_id, + "proposed_text": proposed_text, + "status": "pending", + "job_id": job_id, + "session_id": comment_session_id + } + proposals_list.append({ + "comment_id": cid, + "change_id": change_id, + "instruction": instruction, + "original_anchor": anchor_text, + "proposed_text": proposed_text + }) + + comments_map[cid] = comment_entry + + # Save to in-memory review context + REVIEW_CONTEXTS[review_id] = { + "review_id": review_id, + "file_path": file_path, + "comments_map": comments_map, + "created_at": time.time() + } + + return { + "review_id": review_id, + "file_path": file_path, + "proposals_count": len(proposals_list), + "proposals": proposals_list + } + +@mcp.tool() +def review_comment_changes( + review_id: str, + decisions: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + Accepts per-comment approve/reject decisions for a review_id. + Supports optional override_text to explicitly update proposed text on approval. + decisions shape: [{"comment_id": "1", "approved": True, "override_text": "Final text"}, ...] + """ + if review_id not in REVIEW_CONTEXTS: + raise ValueError(f"Review session ID '{review_id}' not found.") + + ctx = REVIEW_CONTEXTS[review_id] + comments_map = ctx["comments_map"] + client = get_client() + + approved_comment_ids = [] + rejected_comment_ids = [] + + for d in decisions: + cid = str(d["comment_id"]) + is_approved = bool(d.get("approved", True)) + + if cid in comments_map: + c_info = comments_map[cid] + c_info["status"] = "approved" if is_approved else "rejected" + + # Support explicit human override text if provided + if "override_text" in d and d["override_text"] is not None: + c_info["proposed_text"] = str(d["override_text"]) + + if is_approved: + approved_comment_ids.append(cid) + c_info["approved_human_text"] = c_info["proposed_text"] + else: + rejected_comment_ids.append(cid) + + if c_info["job_id"] and c_info["session_id"]: + client.approve_changes( + c_info["session_id"], + c_info["job_id"], + [{"change_id": c_info["change_id"], "approved": is_approved}] + ) + + return { + "review_id": review_id, + "approved_comments": approved_comment_ids, + "rejected_comments": rejected_comment_ids, + "status": "decisions_recorded" + } + +@mcp.tool() +def export_word_document( + review_id: str, + output_path: str +) -> Dict[str, Any]: + """ + Exports final Word document. + Surgically writes approved edit replacements to target anchor ranges in Word XML + and sets comment threads to resolved (w:done="1"). + """ + if review_id not in REVIEW_CONTEXTS: + raise ValueError(f"Review session ID '{review_id}' not found.") + + ctx = REVIEW_CONTEXTS[review_id] + input_path = ctx["file_path"] + comments_map = ctx["comments_map"] + + approved_edits = [] + for cid, c_info in comments_map.items(): + if c_info["status"] == "approved": + # Reject execution if review_id is stale for any comment being exported + latest_rid = LATEST_REVIEW_PER_COMMENT.get(cid) + if latest_rid and latest_rid != review_id: + raise ValueError( + f"Stale review session ID '{review_id}' for comment '{cid}'. " + f"A newer review session ('{latest_rid}') exists for this comment." + ) + approved_edits.append({ + "comment_id": cid, + "replacement_text": c_info["proposed_text"], + "human_approved_text": c_info.get("approved_human_text", c_info["proposed_text"]) + }) + + result = apply_edits_and_resolve_comments( + input_path=input_path, + output_path=output_path, + approved_edits=approved_edits + ) + + return { + "review_id": review_id, + "output_path": output_path, + "applied_edits_count": result["applied_edits_count"], + "resolved_comments_count": result["resolved_comments_count"], + "verified": result.get("verified", True), + "verification_status": result.get("verification_status", "success"), + "verification_mismatches": result.get("verification_mismatches", []) + } + +if __name__ == "__main__": + transport = os.getenv("MCP_TRANSPORT", "stdio").lower() + port = os.getenv("PORT", "8000") + host = os.getenv("HOST", "0.0.0.0") + if transport in ("sse", "http"): + os.environ["FASTMCP_PORT"] = port + os.environ["FASTMCP_HOST"] = host + print(f"Starting SuperDocs Word Comment Agent MCP Server on http://{host}:{port} (SSE Transport)...") + mcp.run(transport="sse") + else: + mcp.run() diff --git a/extensions/tanayjha/word-comment-agent/src/superdocs_client.py b/extensions/tanayjha/word-comment-agent/src/superdocs_client.py new file mode 100644 index 00000000..87104ef7 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/src/superdocs_client.py @@ -0,0 +1,228 @@ +import os +import time +import json +import httpx +from typing import Dict, Any, List, Optional + +SUPERDOCS_API_BASE = os.getenv("SUPERDOCS_API_BASE", "https://api.superdocs.app") + +class SuperDocsAPIError(Exception): + """Custom exception for SuperDocs REST API errors.""" + pass + +class SuperDocsClient: + def __init__(self, api_key: Optional[str] = None): + self.api_key = api_key or os.getenv("SUPERDOCS_API_KEY", "") + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + self._is_mock = not self.api_key or self.api_key.lower() == "mock" + + def _execute_with_warmup_retry(self, request_fn) -> httpx.Response: + """ + Executes an API request. If the initial call fails (e.g. cold session warm-up), + silently retries once. + """ + if self._is_mock: + return None # Handled in caller + + try: + response = request_fn() + if response.status_code < 500 and response.status_code != 408: + return response + # Cold-session warm-up retry + time.sleep(1.0) + return request_fn() + except (httpx.TimeoutException, httpx.NetworkError): + time.sleep(1.0) + return request_fn() + + def upload_document(self, session_id: str, file_path: str, filename: str) -> Dict[str, Any]: + """ + Uploads document passage/file to SuperDocs session. + """ + if self._is_mock: + return { + "session_id": session_id, + "document_id": f"doc_{session_id}", + "status": "uploaded" + } + + with open(file_path, "rb") as f: + import base64 + file_b64 = base64.b64encode(f.read()).decode("utf-8") + + payload = { + "session_id": session_id, + "filename": filename, + "file_base64": file_b64 + } + + def req(): + return httpx.post( + f"{SUPERDOCS_API_BASE}/v1/documents/upload-base64", + headers=self.headers, + json=payload, + timeout=30.0 + ) + + res = self._execute_with_warmup_retry(req) + if res.status_code >= 400: + raise SuperDocsAPIError(f"Upload failed (HTTP {res.status_code}): {res.text}") + + return res.json() + + def send_async_edit( + self, + session_id: str, + instruction: str, + target_anchor: str, + comment_id: str + ) -> str: + """ + Sends targeted edit instruction for a comment range via chat_async. + Returns job_id. + """ + if self._is_mock: + return f"job_mock_{comment_id}" + + message = ( + f"Target anchor: '{target_anchor}'. " + f"Edit instruction: {instruction}" + ) + + payload = { + "session_id": session_id, + "message": message, + "approval_mode": "ask_every_time", + "model_tier": "core" + } + + def req(): + return httpx.post( + f"{SUPERDOCS_API_BASE}/v1/chat/async", + headers=self.headers, + json=payload, + timeout=30.0 + ) + + res = self._execute_with_warmup_retry(req) + if res.status_code >= 400: + raise SuperDocsAPIError(f"Async edit request failed (HTTP {res.status_code}): {res.text}") + + data = res.json() + return data.get("job_id", f"job_{session_id}_{comment_id}") + + def poll_job_status( + self, + job_id: str, + max_timeout_seconds: int = 600 + ) -> Dict[str, Any]: + """ + Polls GET /v1/jobs/{job_id} with progressive backoff (2s -> 5s -> 10s) + until status is awaiting_approval, completed, or failed. + Treats pending and in_progress as normal states. + Double-parses JSON diff card strings if stringified. + """ + if self._is_mock or job_id.startswith("job_mock_"): + comment_id = job_id.replace("job_mock_", "") + # Return synthetic mock change + return { + "status": "awaiting_approval", + "job_id": job_id, + "changes": [ + { + "change_id": f"ch_{comment_id}", + "comment_id": comment_id, + "old_text": "Target anchor text", + "new_text": f"Mock revised text for comment {comment_id}", + "explanation": "Applied requested edit." + } + ] + } + + start_time = time.time() + backoff_intervals = [2.0, 3.0, 5.0, 10.0] + poll_count = 0 + + while (time.time() - start_time) < max_timeout_seconds: + res = httpx.get( + f"{SUPERDOCS_API_BASE}/v1/jobs/{job_id}", + headers=self.headers, + timeout=30.0 + ) + + if res.status_code >= 400: + raise SuperDocsAPIError(f"Job polling failed (HTTP {res.status_code}): {res.text}") + + data = res.json() + status = data.get("status", "pending") + + if status == "awaiting_approval": + metadata = data.get("metadata", {}) + pending_raw = metadata.get("pending_changes", []) + + # Handle double-parsed stringified JSON diff if stringified + changes = [] + for item in pending_raw: + if isinstance(item, str): + try: + item = json.loads(item) + except json.JSONDecodeError: + pass + changes.append(item) + + return { + "status": "awaiting_approval", + "job_id": job_id, + "changes": changes + } + + if status in ("completed", "failed", "cancelled"): + return data + + # Progressive backoff delay + interval = backoff_intervals[min(poll_count, len(backoff_intervals) - 1)] + poll_count += 1 + time.sleep(interval) + + raise SuperDocsAPIError(f"Job polling timed out after {max_timeout_seconds} seconds for job {job_id}") + + def approve_changes( + self, + session_id: str, + job_id: str, + change_decisions: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Calls POST /v1/chat/{session_id}/approve. + Enforces mandatory top-level "approved": true in payload. + change_decisions shape: [{"change_id": "ch_1", "approved": True}, ...] + """ + if self._is_mock or job_id.startswith("job_mock_"): + return { + "session_id": session_id, + "job_id": job_id, + "status": "approved" + } + + # Enforce top-level approved parameter (required by SuperDocs schema) + all_approved = all(c.get("approved", True) for c in change_decisions) + payload = { + "job_id": job_id, + "approved": all_approved, + "changes": change_decisions + } + + res = httpx.post( + f"{SUPERDOCS_API_BASE}/v1/chat/{session_id}/approve", + headers=self.headers, + json=payload, + timeout=30.0 + ) + + if res.status_code >= 400: + raise SuperDocsAPIError(f"Approval failed (HTTP {res.status_code}): {res.text}") + + return res.json() diff --git a/extensions/tanayjha/word-comment-agent/tests/test_agent.py b/extensions/tanayjha/word-comment-agent/tests/test_agent.py new file mode 100644 index 00000000..ff4aaba3 --- /dev/null +++ b/extensions/tanayjha/word-comment-agent/tests/test_agent.py @@ -0,0 +1,222 @@ +import os +import tempfile +import pytest +from docx import Document +from docx.oxml import OxmlElement, parse_xml +from docx.oxml.ns import qn +from docx.opc.part import XmlPart +from docx.opc.packuri import PackURI + +from src.mcp_server import ( + propose_comment_edits, + review_comment_changes, + export_word_document, + REVIEW_CONTEXTS, + LATEST_REVIEW_PER_COMMENT +) +from src.docx_writer import apply_edits_and_resolve_comments + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +REL_COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" + +def create_sample_docx_with_comment( + path: str, + comment_id: str = "1", + prefix_text: str = "Before anchor. ", + anchor_text: str = "This is anchor text.", + suffix_text: str = " After anchor.", + comment_instruction: str = "Please revise this text.", + anchor_is_bold: bool = False +): + """ + Creates a valid test .docx document with comment range start/end and comments.xml. + Includes a leading run before commentRangeStart to test structural mid-run behavior. + """ + doc = Document() + p = doc.add_paragraph() + + # Preceding run + if prefix_text: + r_pre = p.add_run(prefix_text) + + # Comment range start marker + c_start = OxmlElement('w:commentRangeStart') + c_start.set(qn('w:id'), comment_id) + p._p.append(c_start) + + # Anchor run + r_anchor = p.add_run(anchor_text) + if anchor_is_bold: + r_anchor.bold = True + + # Comment range end marker + c_end = OxmlElement('w:commentRangeEnd') + c_end.set(qn('w:id'), comment_id) + p._p.append(c_end) + + # Comment reference marker + c_ref = OxmlElement('w:commentReference') + c_ref.set(qn('w:id'), comment_id) + p._p.append(c_ref) + + # Following run + if suffix_text: + r_post = p.add_run(suffix_text) + + # Create comments.xml part + comments_part_xml = parse_xml( + f'' + f' ' + f' {comment_instruction}' + f' ' + f'' + ) + + # Add comments part to document using XmlPart + comments_part = XmlPart( + PackURI('/word/comments.xml'), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml', + comments_part_xml, + doc.part.package + ) + doc.part.relate_to(comments_part, REL_COMMENTS) + + doc.save(path) + return path + +def test_post_export_verification(): + """Test Fix 4: Verification oracle verifies text match and returns verification_status.""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = os.path.join(tmpdir, "test_verification.docx") + output_file = os.path.join(tmpdir, "output_verification.docx") + create_sample_docx_with_comment(input_file, comment_id="1", anchor_text="Original text.") + + # Test valid matching export + res = apply_edits_and_resolve_comments( + input_path=input_file, + output_path=output_file, + approved_edits=[{"comment_id": "1", "replacement_text": "Updated verified text."}] + ) + + assert res["verified"] is True + assert res["verification_status"] == "success" + assert len(res["verification_mismatches"]) == 0 + +def test_p4_stale_text_regression(): + """Test Fix 1 / P4 Regression: Propose -> Re-propose -> Approve -> Export asserts second proposal is exported.""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = os.path.join(tmpdir, "test_p4.docx") + output_file = os.path.join(tmpdir, "output_p4.docx") + create_sample_docx_with_comment(input_file, comment_id="1", anchor_text="Target anchor text") + + # First proposal run (mock API) + os.environ["SUPERDOCS_API_KEY"] = "mock" + res1 = propose_comment_edits(input_file) + rev_id1 = res1["review_id"] + assert rev_id1 is not None + + # Second proposal run (simulating re-propose with updated edit) + res2 = propose_comment_edits(input_file) + rev_id2 = res2["review_id"] + assert rev_id2 != rev_id1 + + # Approve second review ID with explicit text override + review_comment_changes(rev_id2, [{"comment_id": "1", "approved": True, "override_text": "Second proposed text"}]) + + # Export second review ID + export_res = export_word_document(rev_id2, output_file) + assert export_res["verified"] is True + + # Assert output document contains the SECOND proposal text + doc_out = Document(output_file) + full_text = "".join([p.text for p in doc_out.paragraphs]) + assert "Second proposed text" in full_text + assert "Mock revised text" not in full_text + +def test_stale_review_id_rejection(): + """Test Fix 1: Attempting to export an old/superseded review_id raises ValueError.""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = os.path.join(tmpdir, "test_stale.docx") + output_file = os.path.join(tmpdir, "output_stale.docx") + create_sample_docx_with_comment(input_file, comment_id="1") + + res1 = propose_comment_edits(input_file) + rev_id1 = res1["review_id"] + + # Re-propose to supersede rev_id1 + res2 = propose_comment_edits(input_file) + rev_id2 = res2["review_id"] + + review_comment_changes(rev_id1, [{"comment_id": "1", "approved": True}]) + + # Exporting rev_id1 must fail due to staleness check + with pytest.raises(ValueError, match="Stale review session ID"): + export_word_document(rev_id1, output_file) + +def test_override_text_in_review(): + """Test Fix 1: Human override_text in review_comment_changes updates proposal text prior to export.""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = os.path.join(tmpdir, "test_override.docx") + output_file = os.path.join(tmpdir, "output_override.docx") + create_sample_docx_with_comment(input_file, comment_id="1", anchor_text="Original anchor text") + + res = propose_comment_edits(input_file) + rev_id = res["review_id"] + + human_override = "Human confirmed exact text override." + review_comment_changes(rev_id, [{"comment_id": "1", "approved": True, "override_text": human_override}]) + + export_res = export_word_document(rev_id, output_file) + assert export_res["verified"] is True + + doc_out = Document(output_file) + full_text = "".join([p.text for p in doc_out.paragraphs]) + assert human_override in full_text + +def test_no_text_duplication(): + """Test Fix 2: Mid-run anchor replacement does not duplicate preceding or following text.""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = os.path.join(tmpdir, "test_dup.docx") + output_file = os.path.join(tmpdir, "output_dup.docx") + + prefix = "Paragraph prefix context. " + anchor = "anchored section" + suffix = " Paragraph suffix context." + create_sample_docx_with_comment(input_file, comment_id="1", prefix_text=prefix, anchor_text=anchor, suffix_text=suffix) + + # Model returns replacement that accidentally includes prefix/suffix context + model_replacement_with_context = f"{prefix}revised section{suffix}" + + res = apply_edits_and_resolve_comments( + input_path=input_file, + output_path=output_file, + approved_edits=[{"comment_id": "1", "replacement_text": model_replacement_with_context}] + ) + + doc_out = Document(output_file) + full_text = doc_out.paragraphs[0].text + + # Verify no duplication occurred + expected_full_paragraph = "Paragraph prefix context. revised section Paragraph suffix context." + assert full_text == expected_full_paragraph + assert full_text.count("Paragraph prefix context.") == 1 + +def test_formatting_preservation(): + """Test Fix 3a: Unwanted bolding is not introduced on unbolded anchor runs.""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = os.path.join(tmpdir, "test_fmt.docx") + output_file = os.path.join(tmpdir, "output_fmt.docx") + + # Create unbolded anchor + create_sample_docx_with_comment(input_file, comment_id="1", anchor_text="Unbolded anchor", anchor_is_bold=False) + + res = apply_edits_and_resolve_comments( + input_path=input_file, + output_path=output_file, + approved_edits=[{"comment_id": "1", "replacement_text": "Clean replacement text"}] + ) + + doc_out = Document(output_file) + target_run = doc_out.paragraphs[0].runs[1] # Anchor run position + # Verify run bold property is False or None + assert target_run.bold in (False, None)