@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,186 @@
1
+ """Resolve a quoted anchor to one place in one paragraph, or say precisely why not.
2
+
3
+ The folding table, the paragraph-scoped search and the structured failure follow RedlineBench's
4
+ redline_engine/anchoring.py and references/anchors.md (https://github.com/crosbylegal/redline-bench,
5
+ commit b29d1113af5d98e2d577f4f17673ebb72834f107, MIT License, Copyright (c) 2026 Crosby Legal; see
6
+ LICENSE-redline-bench). Changes: offsets are mapped back to the raw text so the edit lands on the
7
+ original runs; soft hyphens and zero-width characters are dropped; a leading list number is
8
+ ignored when the quote only matches without it; a failure lists the closest paragraphs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from difflib import SequenceMatcher
16
+
17
+ from .model import Unit
18
+
19
+ FOLD = {
20
+ "‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
21
+ "“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
22
+ "‐": "-", "‑": "-", "‒": "-", "–": "-", "—": "-", "―": "-", "−": "-",
23
+ " ": " ", " ": " ", " ": " ", " ": " ", " ": " ", "\t": " ",
24
+ }
25
+ DROP = {"­", "​", "‌", "‍", "⁠", ""}
26
+ NUMBER_PREFIX = re.compile(
27
+ r"^\s*(?:(?:section|article|clause|schedule|exhibit|annex)\s+)?"
28
+ r"(?:\(?[0-9]{1,3}[a-z]?\)?(?:\.[0-9]{1,3})*\.?|\([a-z]{1,4}\)|[a-z]{1,4}[.)]|[ivxlc]{1,6}[.)])\s+",
29
+ re.IGNORECASE,
30
+ )
31
+
32
+
33
+ def fold(text: str, casefold: bool = False) -> tuple[str, list[int]]:
34
+ """Text normalized for matching, and for each folded character the raw index it came from."""
35
+ out: list[str] = []
36
+ index: list[int] = []
37
+ space = False
38
+ for i, ch in enumerate(text):
39
+ if ch in DROP:
40
+ continue
41
+ ch = FOLD.get(ch, ch)
42
+ if ch.isspace():
43
+ if space:
44
+ continue
45
+ ch, space = " ", True
46
+ else:
47
+ space = False
48
+ if casefold:
49
+ lower = ch.lower()
50
+ ch = lower if len(lower) == 1 else ch
51
+ out.append(ch)
52
+ index.append(i)
53
+ return "".join(out), index
54
+
55
+
56
+ @dataclass
57
+ class Located:
58
+ unit: Unit
59
+ start: int # raw offsets into unit.text
60
+ end: int
61
+ notes: list[str] = field(default_factory=list)
62
+
63
+ @property
64
+ def text(self) -> str:
65
+ return self.unit.text[self.start:self.end]
66
+
67
+
68
+ class AnchorFailure(Exception):
69
+ def __init__(self, reason: str, message: str, candidates: list[dict] | None = None):
70
+ super().__init__(message)
71
+ self.reason = reason
72
+ self.candidates = candidates or []
73
+
74
+ def as_dict(self) -> dict:
75
+ found = {"reason": self.reason, "message": str(self)}
76
+ if self.candidates:
77
+ found["candidates"] = self.candidates
78
+ return found
79
+
80
+
81
+ def occurrences(unit: Unit, needle: str, casefold: bool) -> list[tuple[int, int]]:
82
+ hay, index = fold(unit.text, casefold)
83
+ found = []
84
+ start = hay.find(needle)
85
+ while start != -1 and needle:
86
+ end = start + len(needle)
87
+ found.append((index[start], index[end - 1] + 1))
88
+ start = hay.find(needle, end)
89
+ return found
90
+
91
+
92
+ def snippet(text: str, limit: int = 160) -> str:
93
+ text = " ".join(text.split())
94
+ return text if len(text) <= limit else text[: limit - 1] + "…"
95
+
96
+
97
+ def closest(units: list[Unit], quote: str, limit: int = 3) -> list[dict]:
98
+ """The paragraphs whose text is most like the quote, with the most similar stretch of each."""
99
+ needle, _ = fold(quote.strip(), casefold=True)
100
+ if not needle:
101
+ return []
102
+ scored = []
103
+ for unit in units:
104
+ hay, index = fold(unit.text, casefold=True)
105
+ if len(hay) < 3:
106
+ continue
107
+ matcher = SequenceMatcher(None, needle, hay, autojunk=False)
108
+ block = matcher.find_longest_match(0, len(needle), 0, len(hay))
109
+ if block.size < min(6, len(needle)):
110
+ continue
111
+ start = max(0, block.b - block.a)
112
+ end = min(len(hay), start + len(needle))
113
+ ratio = SequenceMatcher(None, needle, hay[start:end], autojunk=False).ratio()
114
+ if ratio >= 0.45:
115
+ raw = unit.text[index[start]: index[end - 1] + 1]
116
+ scored.append((ratio, unit.id, raw))
117
+ scored.sort(key=lambda item: -item[0])
118
+ return [{"paragraph": pid, "text": snippet(raw), "similarity": round(r, 2)} for r, pid, raw in scored[:limit]]
119
+
120
+
121
+ def locate(units: list[Unit], quote: str, paragraph: str | None = None, near: str | None = None,
122
+ occurrence: int | None = None) -> Located:
123
+ """One place for the quote, or an AnchorFailure naming the reason and the closest candidates."""
124
+ if not quote or not quote.strip():
125
+ raise AnchorFailure("empty_quote", "quote is empty")
126
+ pool = units
127
+ if paragraph:
128
+ pool = [u for u in units if u.id == paragraph or u.id.startswith(f"{paragraph}.")]
129
+ if not pool:
130
+ raise AnchorFailure("paragraph_not_found", f"there is no paragraph {paragraph}: use an id from read")
131
+
132
+ attempts: list[tuple[str, bool, str | None]] = []
133
+ folded, _ = fold(quote.strip())
134
+ attempts.append((folded, False, None))
135
+ attempts.append((fold(quote.strip(), True)[0], True, "matched ignoring case"))
136
+ stripped = NUMBER_PREFIX.sub("", quote.strip(), count=1)
137
+ if stripped and stripped != quote.strip() and len(stripped) >= 4:
138
+ note = f"ignored the list number {quote.strip()[: len(quote.strip()) - len(stripped)].strip()!r}"
139
+ attempts.append((fold(stripped)[0], False, note))
140
+ attempts.append((fold(stripped, True)[0], True, note + ", matched ignoring case"))
141
+
142
+ for needle, casefold, note in attempts:
143
+ matches = [(u, s, e) for u in pool for s, e in occurrences(u, needle, casefold)]
144
+ if not matches:
145
+ continue
146
+ notes = [note] if note else []
147
+ if near and len(matches) > 1:
148
+ near_folded = fold(near.strip(), True)[0]
149
+ ranked = []
150
+ for u, s, e in matches:
151
+ hay, index = fold(u.text, True)
152
+ positions = [index[m.start()] for m in re.finditer(re.escape(near_folded), hay)] if near_folded else []
153
+ if positions:
154
+ ranked.append((min(abs(p - s) for p in positions), u, s, e))
155
+ if ranked:
156
+ ranked.sort(key=lambda item: item[0])
157
+ best = [item for item in ranked if item[0] == ranked[0][0]]
158
+ if len(best) == 1:
159
+ _, u, s, e = best[0]
160
+ return Located(u, s, e, notes)
161
+ matches = [(u, s, e) for _, u, s, e in best]
162
+ if occurrence:
163
+ if occurrence > len(matches):
164
+ raise AnchorFailure(
165
+ "occurrence_out_of_range",
166
+ f"the quote matches {len(matches)} time(s); occurrence {occurrence} doesn't exist",
167
+ [{"paragraph": u.id, "text": snippet(u.text[max(0, s - 40): e + 40])} for u, s, e in matches[:5]],
168
+ )
169
+ u, s, e = matches[occurrence - 1]
170
+ return Located(u, s, e, notes)
171
+ if len(matches) > 1:
172
+ raise AnchorFailure(
173
+ "ambiguous",
174
+ f"the quote matches {len(matches)} places: add paragraph, near or occurrence",
175
+ [{"paragraph": u.id, "text": snippet(u.text[max(0, s - 40): e + 40])} for u, s, e in matches[:5]],
176
+ )
177
+ u, s, e = matches[0]
178
+ return Located(u, s, e, notes)
179
+
180
+ where = f"paragraph {paragraph}" if paragraph else "the document"
181
+ raise AnchorFailure(
182
+ "not_found",
183
+ f"the quote isn't in {where} (after forgiving whitespace, quotes, dashes and case): copy it from the "
184
+ "current text in read; it may already include an earlier edit",
185
+ closest(units, quote),
186
+ )
@@ -0,0 +1,279 @@
1
+ """The Word document as the `documents` tool shows it: paragraphs with stable ids and their current text.
2
+
3
+ Paragraph ids are body-block positions: `p-012` is the 13th block of the body, and a paragraph in a
4
+ table cell is `p-012.r2c3` (`.2` after it for the cell's second paragraph, a nested table's included). A block-level content
5
+ control is one block: its paragraphs are `p-012`, `p-012.2`, `p-012.3` and so on. Edits never add or remove
6
+ blocks, so the ids hold for the whole redline.
7
+
8
+ The inline markup walk and the margin, change and thread listings are from recipe-redlining's
9
+ lib/view_docx.py and skills/reading-a-redline/read_markup.py (Apache-2.0, Introspection).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import Counter
15
+ from dataclasses import dataclass, field
16
+ from itertools import groupby
17
+
18
+ from docx.commentops import comment_thread
19
+ from docx.numbering import list_numbering
20
+
21
+ W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
22
+ ADDED = {f"{W}ins": "+", f"{W}moveTo": "+"}
23
+ REMOVED = {f"{W}del": "-", f"{W}moveFrom": "-"}
24
+ CONTAINERS = (f"{W}hyperlink", f"{W}smartTag", f"{W}sdt", f"{W}sdtContent", f"{W}fldSimple", f"{W}customXml")
25
+ BODY = "word/document.xml"
26
+
27
+
28
+ @dataclass
29
+ class Unit:
30
+ """One paragraph: its id, the live element, Word's list label and the text a quote matches."""
31
+
32
+ id: str
33
+ element: object
34
+ text: str
35
+ style: str = ""
36
+ label: str = ""
37
+ marked: bool = False
38
+ extras: dict = field(default_factory=dict)
39
+
40
+
41
+ def signed(author, date) -> str:
42
+ return f"{author or 'unknown author'}, {str(date or 'no date')[:10]}"
43
+
44
+
45
+ def run_text(run) -> str:
46
+ parts = []
47
+ for node in run:
48
+ if node.tag in (f"{W}t", f"{W}delText"):
49
+ parts.append(node.text or "")
50
+ elif node.tag == f"{W}tab":
51
+ parts.append("\t")
52
+ elif node.tag in (f"{W}br", f"{W}cr"):
53
+ parts.append("\n")
54
+ elif node.tag == f"{W}noBreakHyphen":
55
+ parts.append("-")
56
+ return "".join(parts)
57
+
58
+
59
+ def current_text(element) -> str:
60
+ """The paragraph as it reads with every tracked change accepted: what a quote is matched against."""
61
+ parts = []
62
+ for node in element:
63
+ if node.tag == f"{W}r":
64
+ parts.append(run_text(node))
65
+ elif node.tag in ADDED or node.tag in CONTAINERS:
66
+ parts.append(current_text(node))
67
+ return "".join(parts)
68
+
69
+
70
+ INLINE_COMMENT = 60
71
+
72
+
73
+ def walk(element, comments: dict, seen: set) -> str:
74
+ """Read only: the order of runs, changes and comment ranges in a paragraph."""
75
+ parts = []
76
+ for node in element:
77
+ if node.tag == f"{W}r":
78
+ parts.append(run_text(node))
79
+ elif node.tag in ADDED or node.tag in REMOVED:
80
+ mark = ADDED.get(node.tag) or REMOVED[node.tag]
81
+ who = signed(node.get(f"{W}author"), node.get(f"{W}date"))
82
+ parts.append(f"[{mark}{walk(node, comments, seen)}{mark}]({who})")
83
+ elif node.tag == f"{W}commentRangeStart":
84
+ parts.append("«")
85
+ elif node.tag == f"{W}commentRangeEnd":
86
+ number = int(node.get(f"{W}id"))
87
+ seen.add(number)
88
+ comment = comments.get(number)
89
+ who = signed(comment.author, comment.timestamp) if comment else "unknown author"
90
+ body = " ".join((comment.text if comment else "").split())
91
+ # The full text is in the thread listing, which pages; inline keeps a paragraph's line bounded.
92
+ if len(body) > INLINE_COMMENT:
93
+ body = body[:INLINE_COMMENT].rstrip() + "… (see its thread)"
94
+ parts.append(f"»[comment {number}, {who}: {body}]")
95
+ elif node.tag in CONTAINERS:
96
+ parts.append(walk(node, comments, seen))
97
+ return "".join(parts)
98
+
99
+
100
+ def has_markup(element) -> bool:
101
+ for tag in (*ADDED, *REMOVED, f"{W}commentRangeStart", f"{W}commentReference"):
102
+ if next(element.iter(tag), None) is not None:
103
+ return True
104
+ return False
105
+
106
+
107
+ ROMAN = [(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i")]
108
+
109
+
110
+ def formatted(number: int, fmt: str | None) -> str:
111
+ if fmt in ("lowerLetter", "upperLetter"):
112
+ letters = ""
113
+ n = number
114
+ while n > 0:
115
+ n, rest = divmod(n - 1, 26)
116
+ letters = chr(97 + rest) + letters
117
+ return letters.upper() if fmt == "upperLetter" else letters
118
+ if fmt in ("lowerRoman", "upperRoman"):
119
+ out, n = "", number
120
+ for value, digits in ROMAN:
121
+ while n >= value:
122
+ out, n = out + digits, n - value
123
+ return out.upper() if fmt == "upperRoman" else out
124
+ if fmt in ("bullet", "none"):
125
+ return ""
126
+ return str(number)
127
+
128
+
129
+ def list_labels(document) -> dict[tuple, str]:
130
+ """Approximate list numbers ("1.2.") by block position. Restarts and overrides are not modelled."""
131
+ try:
132
+ report = list_numbering(document)
133
+ except Exception: # an unreadable numbering part only costs the labels
134
+ return {}
135
+ definitions = {d.num_id: d for d in report.definitions}
136
+ counters: dict[int, list[int]] = {}
137
+ labels: dict[tuple, str] = {}
138
+ for paragraph in report.numbered_paragraphs:
139
+ if paragraph.story != BODY:
140
+ continue
141
+ definition = definitions.get(paragraph.num_id)
142
+ if definition is None:
143
+ continue
144
+ levels = {level.level: level for level in definition.levels}
145
+ level = levels.get(paragraph.level)
146
+ if level is None or level.num_fmt in ("bullet", "none"):
147
+ continue
148
+ counts = counters.setdefault(definition.abstract_num_id, [0] * 9)
149
+ counts[paragraph.level] += 1
150
+ for deeper in range(paragraph.level + 1, 9):
151
+ counts[deeper] = 0
152
+ text = level.lvl_text or ""
153
+ for n in range(1, 10):
154
+ source = levels.get(n - 1)
155
+ value = counts[n - 1] or 1
156
+ text = text.replace(f"%{n}", formatted(value, source.num_fmt if source else "decimal"))
157
+ key = (paragraph.index, paragraph.table_cell) if paragraph.table_cell else (paragraph.index,)
158
+ labels[key] = text.strip()
159
+ return labels
160
+
161
+
162
+ def units(document, labels: bool = False) -> list[Unit]:
163
+ """Every body paragraph, table cells included, in reading order. List labels only for read: they cost a numbering walk."""
164
+ labels = list_labels(document) if labels else {}
165
+ out: list[Unit] = []
166
+ body = document.element.body
167
+ index = -1
168
+ for block in body.iterchildren():
169
+ if block.tag == f"{W}p":
170
+ index += 1
171
+ style = style_name(document, block)
172
+ out.append(Unit(f"p-{index:03d}", block, current_text(block), style, labels.get((index,), ""), has_markup(block)))
173
+ elif block.tag == f"{W}tbl":
174
+ index += 1
175
+ # Only this table's own rows and cells: a table nested in a cell is part of that cell, and its
176
+ # paragraphs follow the cell's own as `.2`, `.3` ..., each visited once.
177
+ for r, row in enumerate(direct(block, f"{W}tr"), 1):
178
+ for c, cell in enumerate(direct(row, f"{W}tc"), 1):
179
+ for k, p in enumerate(cell.iter(f"{W}p"), 1):
180
+ suffix = f".{k}" if k > 1 else ""
181
+ out.append(Unit(f"p-{index:03d}.r{r}c{c}{suffix}", p, current_text(p), "", "", has_markup(p)))
182
+ elif block.tag == f"{W}sdt":
183
+ index += 1
184
+ # A block-level content control can hold several paragraphs: each gets the control's id,
185
+ # with `.2`, `.3` ... after it for the second and later, as in a table cell.
186
+ for k, p in enumerate(block.iter(f"{W}p"), 1):
187
+ suffix = f".{k}" if k > 1 else ""
188
+ out.append(Unit(f"p-{index:03d}{suffix}", p, current_text(p), style_name(document, p), "", has_markup(p)))
189
+ return out
190
+
191
+
192
+ def direct(parent, tag: str):
193
+ """`parent`'s own `tag` children, through any content control or custom XML wrapping them, not deeper."""
194
+ for child in parent.iterchildren():
195
+ if child.tag == tag:
196
+ yield child
197
+ elif child.tag in (f"{W}sdt", f"{W}sdtContent", f"{W}customXml"):
198
+ yield from direct(child, tag)
199
+
200
+
201
+ def style_name(document, element) -> str:
202
+ p_pr = element.find(f"{W}pPr")
203
+ style = p_pr.find(f"{W}pStyle") if p_pr is not None else None
204
+ if style is None:
205
+ return ""
206
+ style_id = style.get(f"{W}val")
207
+ try:
208
+ return document.styles.get_by_id(style_id, 1).name or style_id # WD_STYLE_TYPE.PARAGRAPH
209
+ except Exception:
210
+ return style_id or ""
211
+
212
+
213
+ def margin_lines(document, kind: str) -> list[str]:
214
+ even_pages = document.settings.odd_and_even_pages_header_footer
215
+ lines = []
216
+ shown = set()
217
+ for number, section in enumerate(document.sections, 1):
218
+ where = f", section {number}" if number > 1 else ""
219
+ variants = [("", getattr(section, kind))]
220
+ if section.different_first_page_header_footer:
221
+ variants.append(("first-page ", getattr(section, f"first_page_{kind}")))
222
+ if even_pages:
223
+ variants.append(("even-page ", getattr(section, f"even_page_{kind}")))
224
+ for variant, margin in variants:
225
+ if margin.part.partname not in shown:
226
+ shown.add(margin.part.partname)
227
+ lines += [f"[{variant}{kind}{where}] {p.text}" for p in margin.paragraphs if p.text.strip()]
228
+ return lines
229
+
230
+
231
+ def day(value) -> str:
232
+ return str(value or "no date")[:10]
233
+
234
+
235
+ def change_lines(document) -> list[str]:
236
+ """Word splits one edit into many revisions, so neighbours of one kind and author read as one."""
237
+
238
+ def key(item):
239
+ _, r = item
240
+ return r.revision_type, r.author, r.story, r.anchor.index, r.is_paragraph_mark
241
+
242
+ lines = []
243
+ for (kind, author, story, _, mark), group in groupby(enumerate(document.revisions), key):
244
+ group = list(group)
245
+ first, last = group[0][0], group[-1][0]
246
+ number = str(first) if first == last else f"{first}-{last}"
247
+ text = "(paragraph mark)" if mark else "".join(r.text for _, r in group)
248
+ where = "" if story == BODY else f" in {story}"
249
+ lines.append(f"{number} {kind}{where}, {author}, {day(group[0][1].date)}: {text}")
250
+ return lines
251
+
252
+
253
+ def thread_lines(document) -> list[str]:
254
+ comments = comment_thread(document)
255
+ parents = {c["comment_id"]: c["parent_id"] for c in comments}
256
+
257
+ def root(number):
258
+ while parents.get(number) is not None:
259
+ number = parents[number]
260
+ return number
261
+
262
+ lines = []
263
+ for comment in comments:
264
+ if comment["parent_id"] is None:
265
+ state = "resolved" if comment["resolved"] else "open"
266
+ anchor = " ".join((comment.get("anchored_text") or "").split())[:120]
267
+ lines.append(f"thread {comment['comment_id']} ({state}) on: {anchor}")
268
+ for entry in comments:
269
+ if root(entry["comment_id"]) == comment["comment_id"]:
270
+ found = document.comments.get(entry["comment_id"])
271
+ when = day(found.timestamp if found is not None else None)
272
+ lines.append(f" {entry['comment_id']}, {entry['author']}, {when}: {entry['text'].strip()}")
273
+ return lines
274
+
275
+
276
+ def census(document) -> Counter:
277
+ counts = Counter(f"{r.author}: {r.revision_type}" for r in document.revisions)
278
+ counts.update(f"{c['author']}: comment" for c in comment_thread(document))
279
+ return counts