@introspection-ai/recipe-documents-docx 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,939 @@
1
+ """The Word commands of the `documents` tool, on paper-docx.
2
+
3
+ Every write reads `source`, applies what it can, and saves to `output` with patch_save, so parts it
4
+ did not touch stay byte for byte. One failed edit never stops the rest: it is rolled back and
5
+ reported with its reason. The accept/reject/reply logic is recipe-redlining's
6
+ skills/responding-to-a-redline/respond.py; the "Reserved." section removal follows RedlineBench's
7
+ redline_engine/reserved.py (MIT, see LICENSE-redline-bench).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import re
14
+ from collections import defaultdict
15
+
16
+ import docx
17
+ from docx.commentops import comment_thread, parent_of, reply
18
+ from docx.package import patch_save
19
+ from docx.revision import _move_units # no public API pairs a move's sites; paper-docx is pinned
20
+ from docx.search import find_text
21
+
22
+ from .anchors import AnchorFailure, Located, fold, locate, snippet
23
+ from .model import ADDED, BODY, CONTAINERS, REMOVED, W, census, change_lines, margin_lines, run_text, thread_lines, units, walk
24
+
25
+ TEXT_BUDGET = 50_000
26
+
27
+
28
+ def initials_of(author: str) -> str:
29
+ letters = "".join(word[0] for word in author.replace("(", " ").split()[:2] if word[0].isalnum())
30
+ return letters.upper() or "A"
31
+
32
+
33
+ # --- read -------------------------------------------------------------------------------------
34
+
35
+
36
+ def read(request: dict) -> dict:
37
+ document = docx.Document(request["path"])
38
+ offset = max(int(request.get("offset") or 0), 0)
39
+ budget = int(request.get("budget") or TEXT_BUDGET)
40
+ found = units(document, labels=True)
41
+ comments = {c.comment_id: c for c in document.comments}
42
+ revisions = list(document.revisions)
43
+
44
+ # After the paragraphs come the footer and the listings, paged under the same budget: offsets
45
+ # past the last paragraph continue into them, so a heavily marked-up document still reads in parts.
46
+ tail: list[str] = list(margin_lines(document, "footer"))
47
+ changes = change_lines(document)
48
+ if changes:
49
+ tail.append("\n## Tracked changes (numbers for respond; they change when the file changes)")
50
+ tail += changes
51
+ for kind, count in revisions.remaining_unsupported().items() if hasattr(revisions, "remaining_unsupported") else []:
52
+ tail.append(f" {count} {kind} can't be accepted or rejected here: the lawyer resolves those in Word")
53
+ threads = thread_lines(document)
54
+ if threads:
55
+ tail.append("\n## Comment threads (thread numbers for comment and respond)")
56
+ tail += threads
57
+ total = len(found) + len(tail)
58
+ if offset and offset >= total:
59
+ raise ValueError(f"offset {offset} is past the end: the read has {total} entries ({len(found)} paragraphs, then the listings)")
60
+
61
+ lines: list[str] = []
62
+ if offset == 0:
63
+ authors = census(document)
64
+ summary = ", ".join(f"{key} {count}" for key, count in sorted(authors.items()))
65
+ lines.append(
66
+ f"{len(found)} paragraphs ({found[0].id if found else '-'}..{found[-1].id if found else '-'}), "
67
+ f"{len(revisions)} tracked change(s), {len(comments)} comment(s)" + (f": {summary}" if summary else "") + "."
68
+ )
69
+ lines.append(
70
+ "Quote from the text after each id. §1.2. is Word's list number (approximate), not part of the text."
71
+ " A 'markup:' line shows [+inserted+] and [-deleted-] text and «commented» text, by author."
72
+ )
73
+ lines += margin_lines(document, "header")
74
+ used = sum(len(line) + 1 for line in lines)
75
+ seen: set[int] = set()
76
+ next_offset = None
77
+ for position in range(offset, total):
78
+ if position < len(found):
79
+ unit = found[position]
80
+ if not unit.text.strip() and not unit.marked:
81
+ continue
82
+ head = unit.id + (f" §{unit.label}" if unit.label else "")
83
+ style = f" ({unit.style})" if unit.style and unit.style not in ("Normal", "List Paragraph", "Body Text") else ""
84
+ line = f"[{head}]{style} {unit.text.strip()}"
85
+ if unit.marked:
86
+ line += f"\n markup: {walk(unit.element, comments, seen).strip()}"
87
+ else:
88
+ line = tail[position - len(found)]
89
+ if used + len(line) > budget and position > offset:
90
+ next_offset = position
91
+ break
92
+ lines.append(line)
93
+ used += len(line) + 1
94
+ if next_offset is not None:
95
+ where = found[next_offset].id if next_offset < len(found) else "the tracked-change and comment listings"
96
+ lines.append(f"[... more: pass offset {next_offset} to read from {where}]")
97
+ return {
98
+ "text": "\n".join(lines),
99
+ "details": {"paragraphs": len(found), "next_offset": next_offset, "changes": len(revisions), "comments": len(comments)},
100
+ }
101
+
102
+
103
+ # --- anchoring to live spans ------------------------------------------------------------------
104
+
105
+
106
+ def span_at(document, located: Located):
107
+ """The live paper-docx span for a located quote: same paragraph, same occurrence of the raw text."""
108
+ text = located.text
109
+ if not text:
110
+ raise AnchorFailure("empty_quote", "the quote is empty after normalization")
111
+ target = located.unit.element
112
+ before = located.unit.text[: located.start]
113
+ count, start = 0, before.find(text)
114
+ while start != -1:
115
+ count += 1
116
+ start = before.find(text, start + len(text))
117
+ spans = [s for s in find_text(document, text, story=BODY) if s._atoms and s._atoms[0].paragraph is target]
118
+ if len(spans) > count:
119
+ return spans[count]
120
+ raise AnchorFailure(
121
+ "unaddressable",
122
+ f"the library can't edit {snippet(text, 80)!r} in {located.unit.id} (inside a field, content control or "
123
+ "text box?): edit the plain text around it, or name it in the open decisions",
124
+ )
125
+
126
+
127
+ SEPARATORS = {f"{W}tab": "tab", f"{W}br": "line break", f"{W}cr": "line break", f"{W}noBreakHyphen": "no-break hyphen"}
128
+ SEPARATOR_KINDS = set(SEPARATORS.values())
129
+ TEXT_NODES = {f"{W}rPr", f"{W}t", f"{W}delText"}
130
+
131
+
132
+ def split_text(text: str, kinds: list[str]) -> list[str]:
133
+ """New text for each segment between separators.
134
+
135
+ Cut at the separator's own character (a tab, a line break, a hyphen). When the new text uses a
136
+ space instead, cut there only if it is the one space left, so no word moves across the mark.
137
+ """
138
+ exact = {"tab": "\t", "line break": "\n"}
139
+ parts, rest = [], text
140
+ for kind in kinds:
141
+ if kind == "no-break hyphen":
142
+ match = re.search(r"-|\u2011", rest)
143
+ elif exact[kind] in rest:
144
+ match = re.search(re.escape(exact[kind]), rest)
145
+ else:
146
+ spaces = list(re.finditer(r"\s+", rest))
147
+ if len(spaces) > 1:
148
+ raise AnchorFailure(
149
+ "crosses_marker",
150
+ f"the change crosses a {kind} and the new text doesn't show where it goes: "
151
+ f"keep the {kind} in the new text, or edit the text on each side of it separately",
152
+ )
153
+ match = spaces[0] if spaces else None
154
+ if not match:
155
+ raise AnchorFailure(
156
+ "crosses_marker",
157
+ f"the change crosses a {kind} and the new text has nothing to keep on its other side: "
158
+ f"edit the text on each side of the {kind} separately",
159
+ )
160
+ parts.append(rest[: match.start()])
161
+ rest = rest[match.end():]
162
+ return parts + [rest]
163
+
164
+
165
+ def marker_offsets(element) -> list[tuple[int, str]]:
166
+ """Current-text offsets of the markers between runs (bookmarks, proofing marks, comment ranges, fields), with their tag."""
167
+ offsets: list[tuple[int, str]] = []
168
+ position = 0
169
+
170
+ def visit(parent) -> None:
171
+ nonlocal position
172
+ for node in parent:
173
+ if node.tag == f"{W}r":
174
+ for child in node:
175
+ if child.tag in SEPARATORS:
176
+ offsets.append((position, SEPARATORS[child.tag]))
177
+ elif child.tag not in TEXT_NODES:
178
+ # lastRenderedPageBreak, fldChar, sym and the like: positional, no text
179
+ offsets.append((position, child.tag.split("}")[-1]))
180
+ position += len(run_text([child])) if child.tag != f"{W}rPr" else 0
181
+ elif node.tag in ADDED:
182
+ # paper-docx does not match or edit across a tracked change's edge
183
+ offsets.append((position, "tracked insertion"))
184
+ visit(node)
185
+ offsets.append((position, "tracked insertion"))
186
+ elif node.tag in CONTAINERS:
187
+ visit(node)
188
+ elif node.tag in REMOVED:
189
+ offsets.append((position, "tracked deletion"))
190
+ elif node.tag == f"{W}pPr":
191
+ continue
192
+ else:
193
+ offsets.append((position, node.tag.split("}")[-1]))
194
+
195
+ visit(element)
196
+ return offsets
197
+
198
+
199
+ def replace_range(document, located: Located, text: str, author: str, tracked: bool = True) -> None:
200
+ """Tracked replace; a span across a bookmark or proofing mark is done in parts between the marks.
201
+
202
+ The parts are struck right to left so the earlier offsets still hold, and the new text goes on
203
+ the first part. The marks stay where they were.
204
+ """
205
+ try:
206
+ span_at(document, located).replace(text, tracked=tracked, author=author if tracked else None)
207
+ return
208
+ except Exception as error:
209
+ unaddressable = isinstance(error, AnchorFailure) and error.reason == "unaddressable"
210
+ # A tab or break typed in the new text: fine when the quote crosses the same separator, which stays.
211
+ typed_separator = isinstance(error, ValueError) and "control character" in str(error)
212
+ if type(error).__name__ not in ("UnsupportedStructureError", "BoundaryViolationError") and not (unaddressable or typed_separator):
213
+ raise
214
+ markers = [
215
+ (o, tag) for o, tag in marker_offsets(located.unit.element)
216
+ if located.start < o < located.end or (tag in SEPARATOR_KINDS and located.start <= o < located.end)
217
+ ]
218
+ if not markers:
219
+ raise
220
+ # Segments between the marks. A separator (tab, break, no-break hyphen) is one character and
221
+ # stays untouched; a positional marker has no width.
222
+ separators = sorted((o, tag) for o, tag in markers if tag in SEPARATOR_KINDS)
223
+ texts = split_text(text, [tag for _, tag in separators])
224
+ pieces: list[tuple[int, int, str]] = []
225
+ bounds = [located.start - 1] + [o for o, _ in separators] + [located.end]
226
+ positional = sorted({o for o, tag in markers if tag not in SEPARATOR_KINDS})
227
+ for index in range(len(bounds) - 1):
228
+ seg_start, seg_end = bounds[index] + 1, bounds[index + 1]
229
+ inner = [o for o in positional if seg_start < o < seg_end]
230
+ edges = [seg_start, *inner, seg_end]
231
+ for k in range(len(edges) - 1):
232
+ pieces.append((edges[k], edges[k + 1], texts[index] if k == 0 else ""))
233
+ try:
234
+ for a, b, piece_text in reversed(pieces):
235
+ if b <= a and not piece_text:
236
+ continue
237
+ piece = fresh(document, Located(located.unit, a, b, located.notes))
238
+ if b <= a:
239
+ insert_at(document, piece.unit, a, piece_text, author, tracked)
240
+ elif piece_text or piece.text.strip():
241
+ span_at(document, piece).replace(piece_text, tracked=tracked, author=author if tracked else None)
242
+ except Exception as error:
243
+ unit = located.unit
244
+ raise AnchorFailure(
245
+ "crosses_marker",
246
+ f"the change crosses {', '.join(sorted({tag for _, tag in markers}))} in {unit.id} and the library refused the parts "
247
+ f"({type(error).__name__}): edit each side separately",
248
+ [{"paragraph": unit.id, "text": snippet(unit.text[a:b], 100)} for a, b, _ in pieces if b > a],
249
+ ) from error
250
+
251
+
252
+ def insert_at(document, unit, k: int, text: str, author: str, tracked: bool = True, split: bool = True) -> None:
253
+ """Tracked insertion of text at offset k of a paragraph's current text.
254
+
255
+ paper-docx inserts by replacing an anchor with the anchor plus the text, and refuses when the
256
+ alignment is ambiguous (the text starts or ends like its anchor). So try the word before, the
257
+ word after, then both, until one is accepted.
258
+ """
259
+ current = unit.text
260
+ before = current[:k]
261
+ after = current[k:]
262
+ prev_start = before.rstrip().rfind(" ") + 1 if before.strip() else k
263
+ next_end = k + (len(after) - len(after.lstrip())) + len(after.lstrip().split(" ", 1)[0]) if after.strip() else k
264
+ # An anchor never crosses a mark (a tracked change's edge, a bookmark, a layout hint).
265
+ marks = [o for o, _ in marker_offsets(unit.element)]
266
+ prev_start = max([prev_start, *[o for o in marks if o < k]])
267
+ next_end = min([next_end, *[o for o in marks if o > k]])
268
+ options = []
269
+ # "at |twice |the" is the same insertion as "at| twice|the": anchoring on "at" without its space
270
+ # gives an alignment paper-docx accepts when "at " -> "at twice " would be ambiguous.
271
+ if text[-1:].isspace() and before[-1:].isspace() and prev_start < k - 1:
272
+ rotated = before[-1] + text[:-1]
273
+ options.append((prev_start, k - 1, lambda a: a + rotated))
274
+ if prev_start < k:
275
+ options.append((prev_start, k, lambda a: a + text))
276
+ if next_end > k:
277
+ options.append((k, next_end, lambda a: text + a))
278
+ if prev_start < k and next_end > k:
279
+ options.append((prev_start, next_end, lambda a: a[: k - prev_start] + text + a[k - prev_start:]))
280
+ last = None
281
+ for start, end, build in options:
282
+ anchor = Located(unit, start, end)
283
+ try:
284
+ span_at(document, anchor).replace(build(anchor.text), tracked=tracked, author=author if tracked else None)
285
+ return
286
+ except Exception as error: # refused before any change: try the next anchor
287
+ if type(error).__name__ not in ("UnsupportedStructureError", "AmbiguousTargetError", "BoundaryViolationError"):
288
+ raise
289
+ last = error
290
+ unit = fresh(document, Located(unit, start, end)).unit
291
+ if split and len(text) > 1 and last is not None:
292
+ # "Agreement." + " More text." is ambiguous because both end in ".": insert all but the
293
+ # last character, then that character after it, each against a boundary that differs.
294
+ for head, tail in ((text[:-1], text[-1:]), (text[:1], text[1:])):
295
+ saved = snapshot(document)
296
+ try:
297
+ insert_at(document, unit, k, head, author, tracked, split=False)
298
+ unit = fresh(document, Located(unit, k, k)).unit
299
+ insert_at(document, unit, k + len(head), tail, author, tracked, split=False)
300
+ return
301
+ except Exception:
302
+ restored = docx.Document(saved)
303
+ document.element.replace(document.element.body, restored.element.body)
304
+ if hasattr(document, "_Document__body"):
305
+ document._Document__body = None # python-docx caches the body wrapper
306
+ unit = fresh(document, Located(unit, k, k)).unit
307
+ raise last or AnchorFailure("unaddressable", "no text beside the insertion point to anchor it")
308
+
309
+
310
+ def swap(document, located: Located, text: str, author: str):
311
+ """A tracked replacement Word shows word by word: strike the old words, then insert the new ones.
312
+
313
+ paper-docx aligns a replacement letter by letter ("[-thir-][+six+]ty"), which reads as noise in
314
+ a redline, and refuses ambiguous alignments. So the old words are deleted and the new ones
315
+ inserted beside them. If that is refused, the document is restored and the same minimal span
316
+ is replaced in one step; the span is never widened.
317
+ """
318
+ if not text:
319
+ replace_range(document, located, "", author)
320
+ return document
321
+ saved = snapshot(document)
322
+ try:
323
+ if located.text:
324
+ replace_range(document, located, "", author)
325
+ unit = fresh(document, Located(located.unit, located.start, located.start)).unit
326
+ insert_at(document, unit, located.start, text, author)
327
+ return document
328
+ except Exception:
329
+ # Never widen: retry the same minimal span as one replacement, or report why not.
330
+ document = docx.Document(saved)
331
+ if not located.text:
332
+ raise
333
+ replace_range(document, fresh(document, located), text, author)
334
+ return document
335
+
336
+
337
+ def comment_at(document, located: Located, text: str, author: str, initials: str) -> None:
338
+ """A comment on the located text, or on its longest part between marks when the whole can't be addressed."""
339
+ try:
340
+ span_at(document, located).comment(text, author=author, initials=initials)
341
+ return
342
+ except Exception as error:
343
+ unaddressable = isinstance(error, AnchorFailure) and error.reason == "unaddressable"
344
+ # A tab or break typed in the new text: fine when the quote crosses the same separator, which stays.
345
+ typed_separator = isinstance(error, ValueError) and "control character" in str(error)
346
+ if type(error).__name__ not in ("UnsupportedStructureError", "BoundaryViolationError") and not (unaddressable or typed_separator):
347
+ raise
348
+ failure = error
349
+ cuts = sorted({o for o, _ in marker_offsets(located.unit.element) if located.start < o < located.end})
350
+ bounds = [located.start, *cuts, located.end]
351
+ pieces = sorted(((b - a, a, b) for a, b in zip(bounds, bounds[1:]) if located.unit.text[a:b].strip()), reverse=True)
352
+ for _, a, b in pieces:
353
+ try:
354
+ span_at(document, Located(located.unit, a, b)).comment(text, author=author, initials=initials)
355
+ return
356
+ except Exception:
357
+ continue
358
+ raise failure
359
+
360
+
361
+ def fresh(document, located: Located) -> Located:
362
+ """Re-locate after a comment or edit changed the element tree (the text offsets still hold)."""
363
+ for unit in units(document):
364
+ if unit.id == located.unit.id:
365
+ return Located(unit, located.start, located.end, located.notes)
366
+ raise AnchorFailure("paragraph_not_found", f"paragraph {located.unit.id} disappeared")
367
+
368
+
369
+ def snapshot(document) -> io.BytesIO:
370
+ buffer = io.BytesIO()
371
+ document.save(buffer)
372
+ buffer.seek(0)
373
+ return buffer
374
+
375
+
376
+ def failure(error: Exception) -> dict:
377
+ if isinstance(error, AnchorFailure):
378
+ return error.as_dict()
379
+ return {"reason": "refused", "message": f"{type(error).__name__}: {error}"}
380
+
381
+
382
+ def change_summary(paragraph: str, old: str, new: str) -> str:
383
+ return f"{paragraph}: {snippet(old, 70)!r} -> {snippet(new, 70)!r}"
384
+
385
+
386
+ # --- propose_edits ------------------------------------------------------------------------------
387
+
388
+
389
+ def same(a: str, b: str) -> bool:
390
+ # Case-sensitive: capitalization can mark a defined term, so a case change is a real change.
391
+ return fold(a)[0] == fold(b)[0]
392
+
393
+
394
+ def narrowed_change(located: Located, replacement: str) -> tuple[Located, str]:
395
+ """narrowed(), refusing a replacement that differs from the text only in typography."""
396
+ target, text = narrowed(located, replacement)
397
+ if target.start == target.end and not text:
398
+ raise AnchorFailure("no_change", "new differs from the quoted text only in punctuation or spacing, so there is nothing to change")
399
+ return target, text
400
+
401
+
402
+ def narrowed(located: Located, replacement: str) -> tuple[Located, str]:
403
+ """Only the words that change: an unchanged lead-in or tail keeps the document's own characters.
404
+
405
+ Compared after folding, so retyping a curly quote as a straight one doesn't strike the quote.
406
+ Cut at word boundaries, and a pure insertion keeps the word before it, so Word shows a clean
407
+ change rather than a letter-level one.
408
+ """
409
+ old = located.text
410
+ p = 0
411
+ while p < len(old) and p < len(replacement) and same(old[p], replacement[p]):
412
+ p += 1
413
+
414
+ def mid_word(left: str, right_old: str, right_new: str) -> bool:
415
+ return left.isalnum() and (right_old.isalnum() or right_new.isalnum())
416
+
417
+ while p > 0 and mid_word(old[p - 1], old[p:p + 1], replacement[p:p + 1]):
418
+ p -= 1
419
+ s = 0
420
+ while s < len(old) - p and s < len(replacement) - p and same(old[len(old) - 1 - s], replacement[len(replacement) - 1 - s]):
421
+ s += 1
422
+ while s > 0 and mid_word(old[len(old) - s], old[len(old) - s - 1:len(old) - s], replacement[len(replacement) - s - 1:len(replacement) - s]):
423
+ s -= 1
424
+ middle = Located(located.unit, located.start + p, located.end - s, located.notes)
425
+ return middle, replacement[p: len(replacement) - s]
426
+
427
+
428
+ def apply_edit(document, edit: dict, author: str, initials: str) -> dict:
429
+ op = edit.get("op")
430
+ new = edit.get("new")
431
+ if op not in ("replace", "delete", "insert_after"):
432
+ raise AnchorFailure("invalid_edit", f"unknown op {op!r}")
433
+ if op == "replace" and new is None:
434
+ raise AnchorFailure("invalid_edit", "replace needs new (use op delete to strike)")
435
+ if op == "insert_after" and not new:
436
+ raise AnchorFailure("invalid_edit", "insert_after needs new text, with its leading space")
437
+ if not (edit.get("rationale") or "").strip():
438
+ raise AnchorFailure("invalid_edit", "every edit needs a rationale")
439
+ located = locate(units(document), edit["quote"], edit.get("paragraph"), edit.get("near"), edit.get("occurrence"))
440
+ old = located.text
441
+ replacement = "" if op == "delete" else (old + new if op == "insert_after" else new)
442
+ if replacement == old:
443
+ raise AnchorFailure("no_change", "new is the same as the quoted text: to comment without a change, use the comment command")
444
+ # The comment goes on first so it covers the struck and the new text however they align.
445
+ comment_at(document, located, edit["rationale"], author, initials)
446
+ located = fresh(document, located)
447
+ target, text = narrowed_change(located, replacement)
448
+ document = swap(document, target, text, author)
449
+ result = {"status": "applied", "paragraph": located.unit.id, "change": change_summary(located.unit.id, old, replacement)}
450
+ if located.notes:
451
+ result["note"] = "; ".join(located.notes)
452
+ return document, result
453
+
454
+
455
+ def write(source: str, document, output: str) -> None:
456
+ patch_save(source, document, output)
457
+
458
+
459
+ def propose_edits(request: dict) -> dict:
460
+ document = docx.Document(request["source"])
461
+ author = request["author"]
462
+ initials = initials_of(author)
463
+ before = census(document)
464
+ results = []
465
+ for number, edit in enumerate(request["edits"]):
466
+ saved = snapshot(document)
467
+ try:
468
+ document, result = apply_edit(document, edit, author, initials)
469
+ results.append({"edit": number, **result})
470
+ except Exception as error: # rolled back; the batch goes on
471
+ document = docx.Document(saved)
472
+ results.append({"edit": number, "status": "failed", **failure(error)})
473
+ write(request["source"], document, request["output"])
474
+ return report(results, before, docx.Document(request["output"]), "edit")
475
+
476
+
477
+ def report(results: list[dict], before, after_document, noun: str) -> dict:
478
+ after = census(after_document)
479
+ applied = sum(1 for r in results if r["status"] == "applied")
480
+ lines = [f"{applied} of {len(results)} {noun}(s) applied; saved."]
481
+ for r in results:
482
+ label = f"{noun} {r[noun]}" if noun in r else noun
483
+ if r["status"] == "applied":
484
+ line = f"{label}: applied {r.get('change', '')}".rstrip()
485
+ if r.get("note"):
486
+ line += f" ({r['note']})"
487
+ else:
488
+ line = f"{label}: FAILED {r['reason']}: {r['message']}"
489
+ for candidate in r.get("candidates", []):
490
+ line += f"\n closest: [{candidate['paragraph']}] {candidate['text']}"
491
+ lines.append(line)
492
+ changed = [f"{key}: {before[key]} -> {after[key]}" for key in sorted(set(before) | set(after)) if before[key] != after[key]]
493
+ if changed:
494
+ lines.append("Counts: " + "; ".join(changed))
495
+ if applied < len(results):
496
+ retry = f"Resubmit only the failed {noun}(s), with path set to the saved file"
497
+ if noun == "response":
498
+ retry += f"; for an accept or reject, {RENUMBERED}"
499
+ lines.append(retry + ".")
500
+ return {"text": "\n".join(lines), "details": {"results": results, "applied": applied, "failed": len(results) - applied}}
501
+
502
+
503
+ # --- comment ------------------------------------------------------------------------------------
504
+
505
+
506
+ def thread_root(document, number: int):
507
+ """Word shows one flat list under a thread, so every reply hangs off its first comment."""
508
+ comment = document.comments.get(number)
509
+ if comment is None:
510
+ raise AnchorFailure("thread_not_found", f"there is no comment {number}: use a thread number from read")
511
+ while (parent := parent_of(document, comment)) is not None:
512
+ comment = document.comments.get(parent)
513
+ return comment
514
+
515
+
516
+ def comment(request: dict) -> dict:
517
+ document = docx.Document(request["source"])
518
+ author = request["author"]
519
+ before = census(document)
520
+ try:
521
+ if request.get("thread") is not None:
522
+ reply(document, thread_root(document, int(request["thread"])), request["text"], author=author,
523
+ initials=initials_of(author))
524
+ result = {"status": "applied", "change": f"reply on thread {request['thread']}"}
525
+ else:
526
+ if not request.get("quote"):
527
+ raise AnchorFailure("invalid_comment", "pass quote (a new comment) or thread (a reply)")
528
+ located = locate(units(document), request["quote"], request.get("paragraph"), request.get("near"),
529
+ request.get("occurrence"))
530
+ comment_at(document, located, request["text"], author, initials_of(author))
531
+ result = {"status": "applied", "paragraph": located.unit.id, "change": f"comment on {located.unit.id}: {snippet(located.text, 60)!r}"}
532
+ except Exception as error:
533
+ write(request["source"], docx.Document(request["source"]), request["output"])
534
+ return {"text": f"comment: FAILED {failure(error)['reason']}: {failure(error)['message']}" + "".join(
535
+ f"\n closest: [{c['paragraph']}] {c['text']}" for c in failure(error).get("candidates", [])),
536
+ "details": {"results": [{"status": "failed", **failure(error)}], "applied": 0, "failed": 1}}
537
+ write(request["source"], document, request["output"])
538
+ return report([result], before, docx.Document(request["output"]), "comment")
539
+
540
+
541
+ # --- respond ------------------------------------------------------------------------------------
542
+
543
+
544
+ def numbers(spec: str) -> list[int]:
545
+ found: list[int] = []
546
+ for part in str(spec).replace(" ", "").split(","):
547
+ first, _, last = part.partition("-")
548
+ start, end = int(first), int(last or first)
549
+ if end < start:
550
+ # A range like 2-0 selects nothing; reading it as empty would report success for changes left alone.
551
+ raise ValueError(f"the range {part} ends before it starts")
552
+ found += range(start, end + 1)
553
+ return found
554
+
555
+
556
+ RENUMBERED = "change numbers shift once changes are resolved: take them from a fresh read of the saved file"
557
+
558
+
559
+ class Midway(Exception):
560
+ """A validated response that still failed while resolving: the batch is redone without it."""
561
+
562
+ def __init__(self, index: int, message: str):
563
+ super().__init__(message)
564
+ self.index = index
565
+
566
+
567
+ def resolve_selected(document, responses: list[dict], results: dict):
568
+ """Accept and reject the selected changes by their numbers as read. Returns the document.
569
+
570
+ Each response is all or nothing. Every number it selects is checked before anything changes, and a
571
+ response that still fails midway is rolled back: the batch starts again from the file as read
572
+ without it, so the other responses keep the numbering they were written against.
573
+ """
574
+ selected: dict[int, list[int]] = {}
575
+ for index, response in enumerate(responses):
576
+ if response["action"] not in ("accept", "reject"):
577
+ continue
578
+ try:
579
+ selected[index] = numbers(response["changes"])
580
+ except ValueError as error:
581
+ why = f" ({error})" if "ends before it starts" in str(error) else ""
582
+ results[index] = refused("invalid_changes", f"can't read change numbers {response['changes']!r}{why}")
583
+ count = len(document.revisions)
584
+ for index in list(selected):
585
+ missing = [number for number in selected[index] if number >= count]
586
+ if missing:
587
+ results[index] = refused("change_not_found", "; ".join(f"there is no change {n}" for n in missing))
588
+ del selected[index]
589
+ unit_of = change_units(document)
590
+ kinds: dict[int, set[str]] = defaultdict(set)
591
+ for index, chosen in selected.items():
592
+ for number in chosen:
593
+ kinds[unit_of(number)].add(responses[index]["action"])
594
+ for index in list(selected):
595
+ both = [number for number in selected[index] if len(kinds[unit_of(number)]) > 1]
596
+ if both:
597
+ results[index] = refused("refused", "; ".join(f"change {n} is selected for both accept and reject" for n in both))
598
+ del selected[index]
599
+ saved = snapshot(document)
600
+ while selected:
601
+ try:
602
+ resolve_all(document, responses, selected)
603
+ break
604
+ except Midway as error:
605
+ results[error.index] = refused("refused", str(error))
606
+ del selected[error.index]
607
+ saved.seek(0)
608
+ document = docx.Document(saved)
609
+ for index in selected:
610
+ results[index] = {"status": "applied", "change": f"{responses[index]['action']}ed change(s) {responses[index]['changes']}"}
611
+ return document
612
+
613
+
614
+ def refused(reason: str, message: str) -> dict:
615
+ return {"status": "failed", "reason": reason, "message": f"{message}. None of this response was applied; {RENUMBERED}"}
616
+
617
+
618
+ def change_units(document):
619
+ """Change number -> the change it resolves with: both sites of a move are one."""
620
+ changes = list(document.revisions)
621
+ moves = {id(element): unit for unit in _move_units(document)[0] for element in unit.elements}
622
+
623
+ def unit_of(number: int) -> int:
624
+ element = changes[number]._element
625
+ return id(moves.get(id(element), element))
626
+
627
+ return unit_of
628
+
629
+
630
+ def resolve_all(document, responses: list[dict], selected: dict[int, list[int]]) -> None:
631
+ """Resolve each selected change once, last first, finding it by its element and not its number."""
632
+ changes = list(document.revisions)
633
+ unit_of = change_units(document)
634
+ wanted: dict[int, int] = {}
635
+ for index, chosen in selected.items():
636
+ for number in chosen:
637
+ wanted.setdefault(number, index)
638
+ done: set[int] = set()
639
+ for number in sorted(wanted, reverse=True):
640
+ index = wanted[number]
641
+ unit = unit_of(number)
642
+ if unit in done:
643
+ continue
644
+ element = changes[number]._element
645
+ revision = next((r for r in document.revisions if r._element is element), None)
646
+ if revision is None:
647
+ raise Midway(index, f"change {number} went with another selected change")
648
+ try:
649
+ getattr(revision, responses[index]["action"])()
650
+ except Exception as error:
651
+ raise Midway(index, f"change {number}: {error}") from error
652
+ done.add(unit)
653
+
654
+
655
+ def counter(document, response: dict, author: str, initials: str) -> dict:
656
+ located = locate(units(document), response["quote"], response.get("paragraph"), response.get("near"),
657
+ response.get("occurrence"))
658
+ old = located.text
659
+ if response.get("thread") is not None:
660
+ thread_root(document, int(response["thread"])) # refuse an unknown thread before editing
661
+ target, text = narrowed_change(located, response["new"])
662
+ document = swap(document, target, text, author)
663
+ reply(document, thread_root(document, int(response["thread"])), response["rationale"], author=author, initials=initials)
664
+ else:
665
+ comment_at(document, located, response["rationale"], author, initials)
666
+ located = fresh(document, located)
667
+ target, text = narrowed_change(located, response["new"])
668
+ document = swap(document, target, text, author)
669
+ return document, {"status": "applied", "paragraph": located.unit.id, "change": change_summary(located.unit.id, old, response["new"])}
670
+
671
+
672
+ def respond(request: dict) -> dict:
673
+ document = docx.Document(request["source"])
674
+ author = request["author"]
675
+ initials = initials_of(author)
676
+ responses = request["responses"]
677
+ before = census(document)
678
+ results: dict[int, dict] = {}
679
+ document = resolve_selected(document, responses, results)
680
+ for index, response in enumerate(responses):
681
+ if response["action"] in ("accept", "reject"):
682
+ continue
683
+ saved = snapshot(document)
684
+ try:
685
+ if response["action"] == "counter":
686
+ document, results[index] = counter(document, response, author, initials)
687
+ elif response["action"] == "reply":
688
+ reply(document, thread_root(document, int(response["thread"])), response["text"], author=author, initials=initials)
689
+ results[index] = {"status": "applied", "change": f"reply on thread {response['thread']}"}
690
+ else:
691
+ raise AnchorFailure("invalid_response", f"unknown action {response['action']!r}")
692
+ except Exception as error:
693
+ document = docx.Document(saved)
694
+ results[index] = {"status": "failed", **failure(error)}
695
+ write(request["source"], document, request["output"])
696
+ ordered = [{"response": index, **results[index]} for index in range(len(responses))]
697
+ return report(ordered, before, docx.Document(request["output"]), "response")
698
+
699
+
700
+ # --- remove_section -------------------------------------------------------------------------------
701
+
702
+
703
+ def remove_section(request: dict) -> dict:
704
+ document = docx.Document(request["source"])
705
+ author = request["author"]
706
+ initials = initials_of(author)
707
+ before = census(document)
708
+ result: dict
709
+ try:
710
+ found = units(document)
711
+ ids = [u.id for u in found]
712
+ for key in ("from", "through"):
713
+ if request[key] not in ids:
714
+ raise AnchorFailure("paragraph_not_found", f"there is no paragraph {request[key]}: use an id from read")
715
+ first, last = ids.index(request["from"]), ids.index(request["through"])
716
+ if first > last:
717
+ raise AnchorFailure("invalid_range", "from comes after through")
718
+ targets = [u.id for u in found[first: last + 1] if u.text.strip()]
719
+ if not targets:
720
+ raise AnchorFailure("invalid_range", "the range has no text")
721
+ head = next(u for u in found if u.id == targets[0])
722
+ keep_end = 0
723
+ if request.get("keep"):
724
+ kept = locate([head], request["keep"])
725
+ if head.text[: kept.start].strip():
726
+ raise AnchorFailure("keep_not_at_start", f"keep must be the start of {head.id}'s text")
727
+ keep_end = kept.end
728
+ body_start = keep_end + (len(head.text[keep_end:]) - len(head.text[keep_end:].lstrip()))
729
+ whole = Located(head, len(head.text) - len(head.text.lstrip()), len(head.text.rstrip()))
730
+ comment_at(document, whole, request["rationale"], author, initials)
731
+ struck = 0
732
+ for pid in reversed(targets):
733
+ unit = next(u for u in units(document) if u.id == pid)
734
+ if pid == head.id:
735
+ body = Located(unit, body_start, len(unit.text.rstrip()))
736
+ if body.start >= body.end:
737
+ raise AnchorFailure("invalid_range", "nothing after keep to replace with Reserved.")
738
+ replace_range(document, body, "Reserved.", author)
739
+ else:
740
+ replace_range(document, Located(unit, len(unit.text) - len(unit.text.lstrip()), len(unit.text.rstrip())), "", author)
741
+ struck += 1
742
+ result = {"status": "applied", "paragraph": head.id,
743
+ "change": f"{head.id}..{targets[-1]}: {struck} paragraph(s) struck, 'Reserved.' in {head.id}"}
744
+ except Exception as error:
745
+ document = docx.Document(request["source"])
746
+ result = {"status": "failed", **failure(error)}
747
+ write(request["source"], document, request["output"])
748
+ return report([result], before, document, "remove_section")
749
+ write(request["source"], document, request["output"])
750
+ return report([result], before, docx.Document(request["output"]), "remove_section")
751
+
752
+
753
+ # --- revise ---------------------------------------------------------------------------------------
754
+
755
+ MERGE_GAP = 12
756
+ TOKEN = re.compile(r"\s+|\w+|[^\w\s]")
757
+
758
+
759
+ def word_changes(old: str, new: str) -> list[tuple[int, int, int, int]]:
760
+ """Changed (old_start, old_end, new_start, new_end) character ranges, word by word, compared after folding."""
761
+ from difflib import SequenceMatcher
762
+
763
+ def tokens(text):
764
+ return [(m.start(), m.end(), fold(m.group())[0] or " ") for m in TOKEN.finditer(text)]
765
+
766
+ a, b = tokens(old), tokens(new)
767
+ changes = []
768
+ matcher = SequenceMatcher(None, [t[2] for t in a], [t[2] for t in b], autojunk=False)
769
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
770
+ if tag == "equal":
771
+ continue
772
+ o1 = a[i1][0] if i1 < len(a) else len(old)
773
+ o2 = a[i2 - 1][1] if i2 > i1 else o1
774
+ n1 = b[j1][0] if j1 < len(b) else len(new)
775
+ n2 = b[j2 - 1][1] if j2 > j1 else n1
776
+ changes.append((o1, o2, n1, n2))
777
+ # Changes a word or two apart read better, and cost far fewer library operations, as one.
778
+ merged: list[tuple[int, int, int, int]] = []
779
+ for change in changes:
780
+ if merged and change[0] - merged[-1][1] <= MERGE_GAP and old[merged[-1][1]:change[0]] == new[merged[-1][3]:change[2]]:
781
+ merged[-1] = (merged[-1][0], change[1], merged[-1][2], change[3])
782
+ else:
783
+ merged.append(change)
784
+ return merged
785
+
786
+
787
+ def apply_changes(document, unit_id: str, new: str, author: str, tracked: bool):
788
+ """Apply one paragraph's word changes, right to left so earlier offsets hold. Returns the document."""
789
+ unit = next(u for u in units(document) if u.id == unit_id)
790
+ for o1, o2, n1, n2 in reversed(word_changes(unit.text, new)):
791
+ unit = fresh(document, Located(unit, o1, o2)).unit
792
+ text = new[n1:n2]
793
+ if o2 > o1:
794
+ if tracked:
795
+ document = swap(document, Located(unit, o1, o2), text, author)
796
+ else:
797
+ replace_range(document, Located(unit, o1, o2), text, author, tracked=False)
798
+ elif text:
799
+ insert_at(document, unit, o1, text, author, tracked)
800
+ return document
801
+
802
+
803
+ def comment_changes(document, unit_id: str, new: str, old: str, rationale: str, author: str, initials: str) -> None:
804
+ """Anchor the rationale on the paragraph's changed text, first change to last."""
805
+ unit = next(u for u in units(document) if u.id == unit_id)
806
+ changes = word_changes(old, new)
807
+ start, end = changes[0][2], changes[-1][3]
808
+ if end <= start: # only deletions: anchor on the word before them, else the word after
809
+ before = unit.text[:start].rstrip()
810
+ start = before.rfind(" ") + 1 if before else start
811
+ end = len(before) if before else start + len(unit.text[start:].split(" ", 1)[0])
812
+ while start < end and unit.text[start].isspace():
813
+ start += 1
814
+ end = len(unit.text[:end].rstrip())
815
+ comment_at(document, Located(unit, start, max(end, start + 1)), rationale, author, initials)
816
+
817
+
818
+ def revise(request: dict) -> dict:
819
+ """Paragraph-level revisions with no quoting: the agent gives each paragraph's revised text.
820
+
821
+ On a file with no pending changes, the edits are made untracked on a working copy and paper-docx's
822
+ compare() builds the redline (word granularity, proven to accept and reject cleanly). A paragraph
823
+ compare refuses, and every paragraph of a file that already carries changes, is applied as
824
+ targeted tracked edits instead, and the result says which.
825
+ """
826
+ from docx.package import compare
827
+
828
+ source = request["source"]
829
+ author = request["author"]
830
+ initials = initials_of(author)
831
+ document = docx.Document(source)
832
+ before = census(document)
833
+ found = {u.id: u for u in units(document)}
834
+ results: list[dict] = []
835
+ todo: list[tuple[int, dict, str]] = []
836
+ comment_only: list[tuple[int, dict, str]] = []
837
+ for number, item in enumerate(request["revisions"]):
838
+ unit = found.get(item.get("paragraph"))
839
+ if unit is None:
840
+ results.append({"revision": number, "status": "failed", "reason": "paragraph_not_found",
841
+ "message": f"there is no paragraph {item.get('paragraph')}: use an id from read"})
842
+ continue
843
+ if not (item.get("rationale") or "").strip():
844
+ results.append({"revision": number, "status": "failed", "reason": "invalid_revision", "message": "every revision needs a rationale"})
845
+ continue
846
+ if not word_changes(unit.text, item["text"]):
847
+ comment_only.append((number, item, unit.text)) # unchanged text: a comment on the paragraph
848
+ continue
849
+ if any(other[1]["paragraph"] == item["paragraph"] for other in todo):
850
+ results.append({"revision": number, "status": "failed", "reason": "duplicate_paragraph",
851
+ "message": f"{item['paragraph']} is revised twice in this batch: give its whole revised text once"})
852
+ continue
853
+ todo.append((number, item, unit.text))
854
+
855
+ def working_copy(items):
856
+ working = docx.Document(source)
857
+ for _, item, _ in items:
858
+ working = apply_changes(working, item["paragraph"], item["text"], author, tracked=False)
859
+ buffer = io.BytesIO()
860
+ working.save(buffer)
861
+ return buffer.getvalue()
862
+
863
+ compared, tracked_items, notes = [], [], {}
864
+ result = None
865
+ if todo and not list(document.revisions):
866
+ candidates = list(todo)
867
+ result = None
868
+ try:
869
+ result = compare(source, working_copy(candidates), author=author, granularity="word")
870
+ shape = [child.tag for child in document.element.body]
871
+ if [child.tag for child in result.document.element.body] != shape:
872
+ # Paragraph ids are block positions: a redline that adds or removes blocks would
873
+ # move every id after it, so it is not used.
874
+ raise ValueError("compare changed the paragraph structure")
875
+ compared = candidates
876
+ except Exception as error:
877
+ result = None
878
+ # One compare per batch: isolating the refused clause costs a compare per paragraph
879
+ # (seconds each on a long contract), so the whole batch takes the tracked path instead.
880
+ reason = f"compare refused the batch ({type(error).__name__}: {str(error)[:100]}); applied as tracked edits"
881
+ for entry in candidates:
882
+ tracked_items.append(entry)
883
+ notes[entry[0]] = reason
884
+ if result is not None:
885
+ document = result.document
886
+ else:
887
+ tracked_items = list(todo)
888
+ if todo:
889
+ for entry in todo:
890
+ notes[entry[0]] = "the file already has tracked changes; applied as tracked edits"
891
+
892
+ applied: dict[int, dict] = {}
893
+ for number, item, old in compared:
894
+ applied[number] = {"revision": number, "status": "applied", "paragraph": item["paragraph"], "via": "compare"}
895
+ for number, item, old in sorted(tracked_items):
896
+ saved = snapshot(document)
897
+ try:
898
+ document = apply_changes(document, item["paragraph"], item["text"], author, tracked=True)
899
+ applied[number] = {"revision": number, "status": "applied", "paragraph": item["paragraph"], "via": "tracked"}
900
+ except Exception as error:
901
+ document = docx.Document(saved)
902
+ applied[number] = {"revision": number, "status": "failed", **failure(error)}
903
+ for number, item, old in todo:
904
+ entry = applied[number]
905
+ if entry["status"] != "applied":
906
+ continue
907
+ try:
908
+ comment_changes(document, item["paragraph"], item["text"], old, item["rationale"], author, initials)
909
+ except Exception as error:
910
+ entry["note"] = f"the change went in but its comment could not be anchored ({type(error).__name__})"
911
+ changes = word_changes(old, item["text"])
912
+ o1, o2, n1, n2 = changes[0]
913
+ entry["change"] = change_summary(item["paragraph"], old[o1:o2], item["text"][n1:n2]) + (
914
+ f" (+{len(changes) - 1} more)" if len(changes) > 1 else "")
915
+ if number in notes:
916
+ entry["note"] = "; ".join(filter(None, [entry.get("note"), notes[number]]))
917
+ for number, item, old in comment_only:
918
+ try:
919
+ unit = next(u for u in units(document) if u.id == item["paragraph"])
920
+ text = unit.text
921
+ comment_at(document, Located(unit, len(text) - len(text.lstrip()), len(text.rstrip())), item["rationale"], author, initials)
922
+ applied[number] = {"revision": number, "status": "applied", "paragraph": item["paragraph"], "via": "comment",
923
+ "change": f"comment on {item['paragraph']} (text unchanged)"}
924
+ except Exception as error:
925
+ applied[number] = {"revision": number, "status": "failed", **failure(error)}
926
+ results += list(applied.values())
927
+ results.sort(key=lambda r: r["revision"])
928
+ write(source, document, request["output"])
929
+ return report(results, before, docx.Document(request["output"]), "revision")
930
+
931
+
932
+ COMMANDS = {
933
+ "revise": revise,
934
+ "read": read,
935
+ "propose_edits": propose_edits,
936
+ "comment": comment,
937
+ "respond": respond,
938
+ "remove_section": remove_section,
939
+ }