doc-control 0.0.1.dev0__py3-none-any.whl
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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Quote anchors: a passage named by its text and context, found again after edits.
|
|
2
|
+
|
|
3
|
+
An anchor is a quote plus the characters before and after it (``prefix``, ``suffix``),
|
|
4
|
+
the paragraph number it was taken from (``hint``) and that paragraph's fingerprint.
|
|
5
|
+
Unlike an address it survives edits: :func:`resolve` looks the quote up again and says
|
|
6
|
+
where it is now, how well it matches, or that it cannot tell.
|
|
7
|
+
|
|
8
|
+
Matching
|
|
9
|
+
Exact first: every place the quote occurs verbatim is a candidate. Only when no exact
|
|
10
|
+
candidate is good enough does the fuzzy search run, over a short list of paragraphs
|
|
11
|
+
(the paragraph with the anchor's fingerprint, the ones near the hint, and the ones
|
|
12
|
+
sharing the most character runs of the quote, rare runs counting more). In each,
|
|
13
|
+
``difflib`` aligns the quote with the text and the densest run of matching blocks gives
|
|
14
|
+
a window, whose ends are then moved by whole words while that helps; the same paragraph
|
|
15
|
+
is searched again with that window masked, so a repeated phrase shows up as several
|
|
16
|
+
candidates. Quotes of three words or fewer are never matched fuzzily.
|
|
17
|
+
|
|
18
|
+
Score
|
|
19
|
+
``(0.5*quote + 0.2*prefix + 0.2*suffix) / 0.9``, each part in ``[0, 1]``, compared word
|
|
20
|
+
by word: a word with a typo (character similarity >= 0.8) counts by that similarity,
|
|
21
|
+
a different word counts nothing, longer words weigh more, and in a fuzzy quote a
|
|
22
|
+
different word also scales the quote part down. The context is the text before and
|
|
23
|
+
after (it may reach into neighbouring paragraphs, joined by ``"\\n"``), and counts by how
|
|
24
|
+
much of the old context is still there. Position is not scored: it only orders equal
|
|
25
|
+
scores. A candidate in the anchor's own paragraph (same fingerprint) or within
|
|
26
|
+
:data:`NEAR_PARAGRAPHS` of its hint needs :data:`MIN_SCORE`; any other needs
|
|
27
|
+
:data:`FAR_MIN_SCORE`; a short quote needs :data:`SHORT_MIN_SCORE` wherever it is.
|
|
28
|
+
When anything scoring at least :data:`MIN_SCORE` is within :data:`TIE_MARGIN` of the
|
|
29
|
+
best, the anchor is ambiguous and the candidates are reported, unless exactly one of
|
|
30
|
+
them is in a paragraph with the anchor's fingerprint.
|
|
31
|
+
|
|
32
|
+
Offsets are code points into a paragraph's visible text, like every other address. A
|
|
33
|
+
range never starts or ends between a character and the combining marks after it.
|
|
34
|
+
Quotes are limited to one paragraph.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import difflib
|
|
40
|
+
import itertools
|
|
41
|
+
import math
|
|
42
|
+
import re
|
|
43
|
+
import unicodedata
|
|
44
|
+
from collections import Counter
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from typing import Any, Literal
|
|
47
|
+
|
|
48
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
49
|
+
from doc_control.model.index import DocumentIndex, Paragraph
|
|
50
|
+
from doc_control.model.segment import DEFAULT_LANGUAGE, split_sentences
|
|
51
|
+
|
|
52
|
+
MIN_SCORE = 0.80
|
|
53
|
+
TIE_MARGIN = 0.02
|
|
54
|
+
MAX_REPORTED = 5
|
|
55
|
+
CONTEXT_CHARS = 32
|
|
56
|
+
MAX_CONTEXT_CHARS = 200
|
|
57
|
+
MAX_QUOTE_CHARS = 1000
|
|
58
|
+
FAR_MIN_SCORE = 0.85 # minimum for a candidate that is not where the anchor came from
|
|
59
|
+
NEAR_PARAGRAPHS = 8 # "where the anchor came from": the hint's paragraph number +- this
|
|
60
|
+
SHORT_QUOTE_WORDS = 3 # quotes this short are never matched fuzzily
|
|
61
|
+
SHORT_MIN_SCORE = 0.95 # and need nearly all of their context to match
|
|
62
|
+
W_QUOTE, W_PREFIX, W_SUFFIX = 0.5, 0.2, 0.2
|
|
63
|
+
TOKEN_MATCH = 0.8 # character similarity at which two words count as the same word, typo aside
|
|
64
|
+
|
|
65
|
+
_FLOOR = 1e-9 # slack so 0.8 computed as 0.7999999999 is still 0.8
|
|
66
|
+
_MAX_FUZZY_PARAGRAPHS = 40
|
|
67
|
+
_NEIGHBOURS = 3 # paragraphs on each side of the hint always searched
|
|
68
|
+
_MAX_WINDOWS = 3 # fuzzy windows taken from one paragraph
|
|
69
|
+
_MIN_WINDOW_RATIO = 0.4
|
|
70
|
+
_WORD = re.compile(r"\w+")
|
|
71
|
+
_GRAM = 5 # characters in a run used to shortlist paragraphs
|
|
72
|
+
_GRAM_SHARE = 0.25 # share of the quote's runs a shortlisted paragraph must hold
|
|
73
|
+
|
|
74
|
+
MatchKind = Literal["exact", "fuzzy"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Anchor:
|
|
79
|
+
"""The JSON form is :meth:`to_dict`; only ``exact`` is required."""
|
|
80
|
+
|
|
81
|
+
exact: str
|
|
82
|
+
prefix: str = ""
|
|
83
|
+
suffix: str = ""
|
|
84
|
+
hint: int | None = None # paragraph number at creation
|
|
85
|
+
fingerprint: str | None = None # of that paragraph then
|
|
86
|
+
at: str | None = None # the range at creation, informative
|
|
87
|
+
sentence: str | None = None # sentence address at creation, if one was anchored
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
out: dict[str, Any] = {"exact": self.exact, "prefix": self.prefix, "suffix": self.suffix}
|
|
91
|
+
if self.hint is not None:
|
|
92
|
+
out["hint"] = f"p{self.hint}"
|
|
93
|
+
if self.fingerprint is not None:
|
|
94
|
+
out["fingerprint"] = self.fingerprint
|
|
95
|
+
if self.at is not None:
|
|
96
|
+
out["at"] = self.at
|
|
97
|
+
if self.sentence is not None:
|
|
98
|
+
out["sentence"] = self.sentence
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_dict(cls, data: dict[str, Any]) -> Anchor:
|
|
103
|
+
hint = data.get("hint")
|
|
104
|
+
number: int | None = None
|
|
105
|
+
if hint is not None:
|
|
106
|
+
m = re.fullmatch(r"p([1-9][0-9]{0,11})", hint)
|
|
107
|
+
if m is None:
|
|
108
|
+
raise DocControlError(
|
|
109
|
+
ErrorCode.SCHEMA_INVALID,
|
|
110
|
+
f"hint {hint!r} is not a paragraph number like p142",
|
|
111
|
+
path="$.anchor.hint",
|
|
112
|
+
)
|
|
113
|
+
number = int(m[1])
|
|
114
|
+
return cls(
|
|
115
|
+
exact=data["exact"],
|
|
116
|
+
prefix=data.get("prefix", ""),
|
|
117
|
+
suffix=data.get("suffix", ""),
|
|
118
|
+
hint=number,
|
|
119
|
+
fingerprint=data.get("fingerprint"),
|
|
120
|
+
at=data.get("at"),
|
|
121
|
+
sentence=data.get("sentence"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Candidate:
|
|
127
|
+
"""A place the anchor may now be: paragraph ``number`` characters ``[start, end)``."""
|
|
128
|
+
|
|
129
|
+
number: int
|
|
130
|
+
fp: str
|
|
131
|
+
start: int
|
|
132
|
+
end: int
|
|
133
|
+
score: float
|
|
134
|
+
kind: MatchKind
|
|
135
|
+
near: bool = True # in the anchor's own paragraph (by fingerprint) or close to its hint
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def threshold(self) -> float:
|
|
139
|
+
return MIN_SCORE if self.near else FAR_MIN_SCORE
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def at(self) -> str:
|
|
143
|
+
return f"p{self.number}#{self.fp}:{self.start}-{self.end}"
|
|
144
|
+
|
|
145
|
+
def to_dict(self) -> dict[str, Any]:
|
|
146
|
+
return {"at": self.at, "score": round(self.score, 4)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class Resolution:
|
|
151
|
+
"""The outcome of :func:`resolve`.
|
|
152
|
+
|
|
153
|
+
``found``: ``best`` is the answer. ``ambiguous``: ``candidates`` (best first, at most
|
|
154
|
+
:data:`MAX_REPORTED`) are within the tie margin. ``not_found``: ``best`` is the closest
|
|
155
|
+
thing below the minimum score, if anything came close at all.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
status: Literal["found", "ambiguous", "not_found"]
|
|
159
|
+
best: Candidate | None
|
|
160
|
+
candidates: tuple[Candidate, ...] = ()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_mark(ch: str) -> bool:
|
|
164
|
+
return unicodedata.category(ch).startswith("M")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _nfc(text: str) -> str:
|
|
168
|
+
return unicodedata.normalize("NFC", text)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _tokens(text: str) -> list[str]:
|
|
172
|
+
return _WORD.findall(_nfc(text).casefold())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _token_credit(ta: list[str], tb: list[str]) -> float:
|
|
176
|
+
return _token_credit_pair(ta, tb)[0]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _token_credit_pair(ta: list[str], tb: list[str]) -> tuple[float, int]:
|
|
180
|
+
"""Characters of ``ta`` that ``tb`` matches: whole words fully, a word with a typo
|
|
181
|
+
(character similarity of at least :data:`TOKEN_MATCH`) by that similarity, a different
|
|
182
|
+
word not at all."""
|
|
183
|
+
credit = 0.0
|
|
184
|
+
matched = 0
|
|
185
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, ta, tb, autojunk=False).get_opcodes():
|
|
186
|
+
if tag == "equal":
|
|
187
|
+
n = sum(len(t) for t in ta[i1:i2])
|
|
188
|
+
credit += n
|
|
189
|
+
matched += n
|
|
190
|
+
elif tag == "replace":
|
|
191
|
+
for x, y in zip(ta[i1:i2], tb[j1:j2], strict=False):
|
|
192
|
+
r = difflib.SequenceMatcher(None, x, y, autojunk=False).ratio()
|
|
193
|
+
if r >= TOKEN_MATCH:
|
|
194
|
+
credit += r * len(x)
|
|
195
|
+
matched += len(x)
|
|
196
|
+
return credit, matched
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _similarity(a: str, b: str) -> float:
|
|
200
|
+
"""How alike two stretches of text are, word by word, in ``[0, 1]``.
|
|
201
|
+
|
|
202
|
+
Longer words weigh more, so changing a content word costs far more than changing
|
|
203
|
+
"la"; a word with a typo still counts. Text without words falls back to character
|
|
204
|
+
similarity.
|
|
205
|
+
"""
|
|
206
|
+
if a == b:
|
|
207
|
+
return 1.0
|
|
208
|
+
ta, tb = _tokens(a), _tokens(b)
|
|
209
|
+
if not ta and not tb:
|
|
210
|
+
return difflib.SequenceMatcher(None, _nfc(a), _nfc(b), autojunk=False).ratio()
|
|
211
|
+
if not ta or not tb:
|
|
212
|
+
return 0.0
|
|
213
|
+
total = max(sum(map(len, ta)), sum(map(len, tb)))
|
|
214
|
+
return min(1.0, _token_credit(ta, tb) / total)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _quote_similarity(quote: str, found: str) -> float:
|
|
218
|
+
"""Word-level similarity of a quote to what was found, for fuzzy matches.
|
|
219
|
+
|
|
220
|
+
Typos cost a little. A word that is simply different costs twice: it earns no credit
|
|
221
|
+
and it also scales the whole score down, so a near-copy with one content word changed
|
|
222
|
+
cannot pass for the quote.
|
|
223
|
+
"""
|
|
224
|
+
ta, tb = _tokens(quote), _tokens(found)
|
|
225
|
+
if not ta or not tb:
|
|
226
|
+
return _similarity(quote, found)
|
|
227
|
+
credit, matched = _token_credit_pair(ta, tb)
|
|
228
|
+
total = max(sum(map(len, ta)), sum(map(len, tb)))
|
|
229
|
+
return min(1.0, credit / total) * (matched / sum(map(len, ta)))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _word_edges(text: str, lo: int, hi: int) -> list[tuple[int, int]]:
|
|
233
|
+
"""Windows one word wider or narrower than ``text[lo:hi]`` at either end."""
|
|
234
|
+
out: list[tuple[int, int]] = []
|
|
235
|
+
k = lo
|
|
236
|
+
while k > 0 and text[k - 1].isspace():
|
|
237
|
+
k -= 1
|
|
238
|
+
if k > 0:
|
|
239
|
+
while k > 0 and (text[k - 1].isalnum() or _is_mark(text[k - 1])):
|
|
240
|
+
k -= 1
|
|
241
|
+
out.append((k, hi))
|
|
242
|
+
k = hi
|
|
243
|
+
while k < len(text) and text[k].isspace():
|
|
244
|
+
k += 1
|
|
245
|
+
if k < len(text):
|
|
246
|
+
while k < len(text) and (text[k].isalnum() or _is_mark(text[k])):
|
|
247
|
+
k += 1
|
|
248
|
+
out.append((lo, k))
|
|
249
|
+
cut = text.find(" ", lo, hi)
|
|
250
|
+
if cut >= 0:
|
|
251
|
+
out.append((cut + 1, hi))
|
|
252
|
+
cut = text.rfind(" ", lo, hi)
|
|
253
|
+
if cut >= 0:
|
|
254
|
+
out.append((lo, cut))
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _refine(quote: str, text: str, lo: int, hi: int) -> tuple[int, int]:
|
|
259
|
+
"""Move the ends of a window by whole words while that makes it more like the quote."""
|
|
260
|
+
best = _quote_similarity(quote, text[lo:hi])
|
|
261
|
+
for _ in range(6):
|
|
262
|
+
scored = [
|
|
263
|
+
(_quote_similarity(quote, text[a:b]), a, b)
|
|
264
|
+
for a, b in _word_edges(text, lo, hi)
|
|
265
|
+
if "\x00" not in text[a:b] and b > a
|
|
266
|
+
]
|
|
267
|
+
if not scored:
|
|
268
|
+
break
|
|
269
|
+
top = max(scored)
|
|
270
|
+
if top[0] <= best + 0.005:
|
|
271
|
+
break
|
|
272
|
+
best, lo, hi = top
|
|
273
|
+
return lo, hi
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _context_similarity(anchor_side: str, actual: str, *, before: bool) -> float:
|
|
277
|
+
"""How much of the anchor's prefix (``before``) or suffix is found in the text now.
|
|
278
|
+
|
|
279
|
+
``actual`` is a longer stretch than the anchor's, so words inserted nearby do not push
|
|
280
|
+
the old ones out of view; the outermost word on each side may be cut and is ignored.
|
|
281
|
+
What counts is how much of the old context is still there, word for word.
|
|
282
|
+
"""
|
|
283
|
+
ta, tb = _tokens(anchor_side), _tokens(actual)
|
|
284
|
+
if len(ta) > 2:
|
|
285
|
+
ta = ta[1:] if before else ta[:-1]
|
|
286
|
+
if len(tb) > 2:
|
|
287
|
+
tb = tb[1:] if before else tb[:-1]
|
|
288
|
+
total = sum(map(len, ta))
|
|
289
|
+
if total == 0:
|
|
290
|
+
return 1.0
|
|
291
|
+
return min(1.0, _token_credit(ta, tb) / total)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# -- context -----------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _text_before(index: DocumentIndex, number: int, offset: int, size: int) -> str:
|
|
298
|
+
"""Up to ``size`` characters ending at ``offset``, reaching back over earlier paragraphs."""
|
|
299
|
+
if size <= 0:
|
|
300
|
+
return ""
|
|
301
|
+
text = index.paragraphs[number - 1].text
|
|
302
|
+
start = max(0, offset - size)
|
|
303
|
+
while 0 < start < offset and _is_mark(text[start]):
|
|
304
|
+
start -= 1
|
|
305
|
+
out = text[start:offset]
|
|
306
|
+
k = number - 1
|
|
307
|
+
while len(out) < size and k >= 1:
|
|
308
|
+
prev = index.paragraphs[k - 1].text
|
|
309
|
+
room = size - len(out) - 1
|
|
310
|
+
out = (prev[max(0, len(prev) - room) :] if room > 0 else "") + "\n" + out
|
|
311
|
+
k -= 1
|
|
312
|
+
return out[-size:] if len(out) > size else out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _text_after(index: DocumentIndex, number: int, offset: int, size: int) -> str:
|
|
316
|
+
if size <= 0:
|
|
317
|
+
return ""
|
|
318
|
+
text = index.paragraphs[number - 1].text
|
|
319
|
+
end = min(len(text), offset + size)
|
|
320
|
+
while offset < end < len(text) and _is_mark(text[end]):
|
|
321
|
+
end += 1
|
|
322
|
+
out = text[offset:end]
|
|
323
|
+
k = number + 1
|
|
324
|
+
while len(out) < size and k <= len(index):
|
|
325
|
+
nxt = index.paragraphs[k - 1].text
|
|
326
|
+
room = size - len(out) - 1
|
|
327
|
+
out = out + "\n" + (nxt[:room] if room > 0 else "")
|
|
328
|
+
k += 1
|
|
329
|
+
return out[:size] if len(out) > size else out
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# -- create ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _snap(text: str, start: int, end: int) -> tuple[int, int]:
|
|
336
|
+
"""Widen ``[start, end)`` so it neither starts on nor ends before a combining mark."""
|
|
337
|
+
while 0 < start < len(text) and _is_mark(text[start]):
|
|
338
|
+
start -= 1
|
|
339
|
+
while 0 < end < len(text) and _is_mark(text[end]):
|
|
340
|
+
end += 1
|
|
341
|
+
return start, end
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def create(
|
|
345
|
+
index: DocumentIndex,
|
|
346
|
+
at: str,
|
|
347
|
+
context: int = CONTEXT_CHARS,
|
|
348
|
+
language: str = DEFAULT_LANGUAGE,
|
|
349
|
+
) -> Anchor:
|
|
350
|
+
"""The anchor for the range ``at`` (any address form), which must lie in one paragraph."""
|
|
351
|
+
resolved = index.resolve_range(at, language)
|
|
352
|
+
if resolved.first is not resolved.last:
|
|
353
|
+
raise DocControlError(
|
|
354
|
+
ErrorCode.SCHEMA_INVALID,
|
|
355
|
+
"an anchor covers one paragraph; anchor each paragraph of the range separately",
|
|
356
|
+
path="$.at",
|
|
357
|
+
)
|
|
358
|
+
para = resolved.first
|
|
359
|
+
start, end = _snap(para.text, resolved.start_offset or 0, resolved.end_offset or 0)
|
|
360
|
+
quote = para.text[start:end]
|
|
361
|
+
if not quote.strip():
|
|
362
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "cannot anchor empty text", path="$.at")
|
|
363
|
+
if len(quote) > MAX_QUOTE_CHARS:
|
|
364
|
+
raise DocControlError(
|
|
365
|
+
ErrorCode.SCHEMA_INVALID,
|
|
366
|
+
f"quote is {len(quote)} characters; an anchor holds at most {MAX_QUOTE_CHARS}",
|
|
367
|
+
path="$.at",
|
|
368
|
+
)
|
|
369
|
+
sentence = None
|
|
370
|
+
if resolved.sentence is not None:
|
|
371
|
+
sentence = f"{para.address}.s{resolved.sentence}"
|
|
372
|
+
return Anchor(
|
|
373
|
+
exact=quote,
|
|
374
|
+
prefix=_text_before(index, para.number, start, context),
|
|
375
|
+
suffix=_text_after(index, para.number, end, context),
|
|
376
|
+
hint=para.number,
|
|
377
|
+
fingerprint=para.fp,
|
|
378
|
+
at=f"{para.address}:{start}-{end}",
|
|
379
|
+
sentence=sentence,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# -- resolve -----------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _candidate(
|
|
387
|
+
index: DocumentIndex,
|
|
388
|
+
anchor: Anchor,
|
|
389
|
+
para: Paragraph,
|
|
390
|
+
start: int,
|
|
391
|
+
end: int,
|
|
392
|
+
quote_score: float,
|
|
393
|
+
kind: MatchKind,
|
|
394
|
+
) -> Candidate:
|
|
395
|
+
near = (para.fp == anchor.fingerprint) or (
|
|
396
|
+
anchor.hint is not None and abs(para.number - anchor.hint) <= NEAR_PARAGRAPHS
|
|
397
|
+
)
|
|
398
|
+
wide = len(anchor.prefix) * 3 // 2 + 1, len(anchor.suffix) * 3 // 2 + 1
|
|
399
|
+
prefix = (
|
|
400
|
+
_context_similarity(
|
|
401
|
+
anchor.prefix, _text_before(index, para.number, start, wide[0]), before=True
|
|
402
|
+
)
|
|
403
|
+
if anchor.prefix
|
|
404
|
+
else 1.0
|
|
405
|
+
)
|
|
406
|
+
suffix = (
|
|
407
|
+
_context_similarity(
|
|
408
|
+
anchor.suffix, _text_after(index, para.number, end, wide[1]), before=False
|
|
409
|
+
)
|
|
410
|
+
if anchor.suffix
|
|
411
|
+
else 1.0
|
|
412
|
+
)
|
|
413
|
+
# Normalised to [0, 1]; position is not part of it, it only orders equal scores.
|
|
414
|
+
score = (W_QUOTE * quote_score + W_PREFIX * prefix + W_SUFFIX * suffix) / (
|
|
415
|
+
W_QUOTE + W_PREFIX + W_SUFFIX
|
|
416
|
+
)
|
|
417
|
+
return Candidate(para.number, para.fp, start, end, score, kind, near)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _exact_spans(text: str, quote: str) -> list[tuple[int, int]]:
|
|
421
|
+
"""Verbatim occurrences of ``quote`` that do not cut a character from its marks."""
|
|
422
|
+
out: list[tuple[int, int]] = []
|
|
423
|
+
i = text.find(quote)
|
|
424
|
+
while i >= 0:
|
|
425
|
+
j = i + len(quote)
|
|
426
|
+
if not (j < len(text) and _is_mark(text[j])) and not (i > 0 and _is_mark(text[i])):
|
|
427
|
+
out.append((i, j))
|
|
428
|
+
i = text.find(quote, i + 1)
|
|
429
|
+
return out
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def resolve(index: DocumentIndex, anchor: Anchor, *, exact_only: bool = False) -> Resolution:
|
|
433
|
+
"""Find ``anchor`` in ``index``.
|
|
434
|
+
|
|
435
|
+
With ``exact_only`` (``anchor.resolve`` ``mode: "exact"``) the fuzzy search is skipped
|
|
436
|
+
and every verbatim occurrence of the quote is accepted whatever its context score: the
|
|
437
|
+
score only breaks ties, and several occurrences about equally good are ambiguous.
|
|
438
|
+
"""
|
|
439
|
+
quote = anchor.exact
|
|
440
|
+
found: list[Candidate] = []
|
|
441
|
+
taken: dict[int, list[tuple[int, int]]] = {}
|
|
442
|
+
for para in index.paragraphs:
|
|
443
|
+
if len(para.text) < len(quote):
|
|
444
|
+
continue
|
|
445
|
+
for start, end in _exact_spans(para.text, quote):
|
|
446
|
+
taken.setdefault(para.number, []).append((start, end))
|
|
447
|
+
found.append(_candidate(index, anchor, para, start, end, 1.0, "exact"))
|
|
448
|
+
short = len(_WORD.findall(quote)) <= SHORT_QUOTE_WORDS
|
|
449
|
+
|
|
450
|
+
def accepted_of(pool: list[Candidate]) -> list[Candidate]:
|
|
451
|
+
# A short quote has too little text to prove a match: it needs the stricter bar
|
|
452
|
+
# even where it came from.
|
|
453
|
+
if exact_only:
|
|
454
|
+
return list(pool)
|
|
455
|
+
return [c for c in pool if c.score + _FLOOR >= (SHORT_MIN_SCORE if short else c.threshold)]
|
|
456
|
+
|
|
457
|
+
accepted = accepted_of(found)
|
|
458
|
+
if not accepted and not exact_only and not short:
|
|
459
|
+
found += _fuzzy(index, anchor, taken)
|
|
460
|
+
accepted = accepted_of(found)
|
|
461
|
+
order = anchor.hint or 0
|
|
462
|
+
|
|
463
|
+
def key(c: Candidate) -> tuple[float, int, int, int]:
|
|
464
|
+
# Position only orders results that score the same; it never lifts a score.
|
|
465
|
+
return (-round(c.score, 6), abs(c.number - order), c.number, c.start)
|
|
466
|
+
|
|
467
|
+
ranked = sorted(found, key=key)
|
|
468
|
+
if not accepted:
|
|
469
|
+
return Resolution("not_found", ranked[0] if ranked else None)
|
|
470
|
+
accepted.sort(key=key)
|
|
471
|
+
best = accepted[0]
|
|
472
|
+
# Everything that scores nearly as well counts, accepted or not: a runner-up the
|
|
473
|
+
# stricter bar rejected still shows the passage is not unique.
|
|
474
|
+
tied = [
|
|
475
|
+
c
|
|
476
|
+
for c in sorted(found, key=key)
|
|
477
|
+
if (exact_only or c.score + _FLOOR >= MIN_SCORE)
|
|
478
|
+
and best.score - c.score <= TIE_MARGIN + _FLOOR
|
|
479
|
+
]
|
|
480
|
+
if len(tied) > 1:
|
|
481
|
+
# The one paragraph that still has the anchor's own fingerprint is where it came from.
|
|
482
|
+
own = [c for c in tied if c.fp == anchor.fingerprint]
|
|
483
|
+
if len(own) == 1 and own[0] in accepted:
|
|
484
|
+
return Resolution("found", own[0])
|
|
485
|
+
return Resolution("ambiguous", None, tuple(tied[:MAX_REPORTED]))
|
|
486
|
+
return Resolution("found", best)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _fuzzy_paragraphs(index: DocumentIndex, anchor: Anchor) -> list[Paragraph]:
|
|
490
|
+
"""The paragraphs worth a fuzzy comparison, in a stable order."""
|
|
491
|
+
chosen: dict[int, Paragraph] = {}
|
|
492
|
+
if anchor.hint is not None:
|
|
493
|
+
for n in range(anchor.hint - _NEIGHBOURS, anchor.hint + _NEIGHBOURS + 1):
|
|
494
|
+
if 1 <= n <= len(index):
|
|
495
|
+
chosen[n] = index.paragraphs[n - 1]
|
|
496
|
+
if anchor.fingerprint is not None:
|
|
497
|
+
for para in index.paragraphs:
|
|
498
|
+
if para.fp == anchor.fingerprint:
|
|
499
|
+
chosen[para.number] = para
|
|
500
|
+
# Character runs of the quote that survive a typo: the paragraphs holding the most of
|
|
501
|
+
# them (rare runs counting more) are the likeliest homes of an edited quote.
|
|
502
|
+
quote = unicodedata.normalize("NFC", anchor.exact)
|
|
503
|
+
size = min(_GRAM, len(quote))
|
|
504
|
+
wanted = {quote[i : i + size] for i in range(len(quote) - size + 1)}
|
|
505
|
+
wanted = {g for g in wanted if g.strip()}
|
|
506
|
+
if wanted:
|
|
507
|
+
need = max(1, math.ceil(len(wanted) * _GRAM_SHARE))
|
|
508
|
+
shared: list[tuple[Paragraph, list[str]]] = []
|
|
509
|
+
df: Counter[str] = Counter()
|
|
510
|
+
for para in index.paragraphs:
|
|
511
|
+
if len(para.text) < size:
|
|
512
|
+
continue
|
|
513
|
+
common = [g for g in wanted if g in para.text]
|
|
514
|
+
if len(common) >= need:
|
|
515
|
+
shared.append((para, common))
|
|
516
|
+
df.update(common)
|
|
517
|
+
hint = anchor.hint or 0
|
|
518
|
+
|
|
519
|
+
def rank(item: tuple[Paragraph, list[str]]) -> tuple[float, int, int]:
|
|
520
|
+
para, common = item
|
|
521
|
+
weight = sum(1.0 / df[g] for g in common)
|
|
522
|
+
return (-weight, abs(para.number - hint), para.number)
|
|
523
|
+
|
|
524
|
+
for para, _ in sorted(shared, key=rank)[:_MAX_FUZZY_PARAGRAPHS]:
|
|
525
|
+
chosen[para.number] = para
|
|
526
|
+
return [chosen[n] for n in sorted(chosen)]
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _fuzzy(
|
|
530
|
+
index: DocumentIndex, anchor: Anchor, taken: dict[int, list[tuple[int, int]]]
|
|
531
|
+
) -> list[Candidate]:
|
|
532
|
+
out: list[Candidate] = []
|
|
533
|
+
quote = anchor.exact
|
|
534
|
+
for para in _fuzzy_paragraphs(index, anchor):
|
|
535
|
+
text = para.text
|
|
536
|
+
for s, e in taken.get(para.number, ()):
|
|
537
|
+
text = text[:s] + "\x00" * (e - s) + text[e:]
|
|
538
|
+
for _ in range(_MAX_WINDOWS):
|
|
539
|
+
window = _best_window(quote, text)
|
|
540
|
+
if window is None:
|
|
541
|
+
break
|
|
542
|
+
start, end, _ = window
|
|
543
|
+
lo, hi = start, end
|
|
544
|
+
while lo > 0 and (text[lo - 1].isalnum() or _is_mark(text[lo - 1])):
|
|
545
|
+
lo -= 1
|
|
546
|
+
while hi < len(text) and (text[hi].isalnum() or _is_mark(text[hi])):
|
|
547
|
+
hi += 1
|
|
548
|
+
if "\x00" in text[lo:hi]:
|
|
549
|
+
lo, hi = start, end
|
|
550
|
+
lo, hi = _refine(quote, text, lo, hi)
|
|
551
|
+
quote_score = _quote_similarity(quote, text[lo:hi])
|
|
552
|
+
out.append(_candidate(index, anchor, para, lo, hi, quote_score, "fuzzy"))
|
|
553
|
+
text = text[:start] + "\x00" * (end - start) + text[end:]
|
|
554
|
+
return out
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _best_window(quote: str, text: str) -> tuple[int, int, float] | None:
|
|
558
|
+
"""The stretch of ``text`` most like ``quote``, with its similarity, or ``None``."""
|
|
559
|
+
m = len(quote)
|
|
560
|
+
if m == 0 or len(text) == 0:
|
|
561
|
+
return None
|
|
562
|
+
matcher = difflib.SequenceMatcher(None, text, quote, autojunk=False)
|
|
563
|
+
floor = max(1, min(3, m // 2))
|
|
564
|
+
blocks = [b for b in matcher.get_matching_blocks() if b.size >= floor]
|
|
565
|
+
if not blocks:
|
|
566
|
+
return None
|
|
567
|
+
# Group blocks that sit where the quote would: the text between two blocks may be
|
|
568
|
+
# longer than the quote between them (an insertion), but only by a bounded amount.
|
|
569
|
+
slack = max(10, m // 4)
|
|
570
|
+
groups: list[list[difflib.Match]] = [[blocks[0]]]
|
|
571
|
+
for prev, cur in itertools.pairwise(blocks):
|
|
572
|
+
gap_text = cur.a - (prev.a + prev.size)
|
|
573
|
+
gap_quote = cur.b - (prev.b + prev.size)
|
|
574
|
+
if gap_text <= gap_quote + slack:
|
|
575
|
+
groups[-1].append(cur)
|
|
576
|
+
else:
|
|
577
|
+
groups.append([cur])
|
|
578
|
+
group = max(groups, key=lambda g: sum(b.size for b in g))
|
|
579
|
+
first, last = group[0], group[-1]
|
|
580
|
+
starts = {max(0, first.a - first.b), first.a}
|
|
581
|
+
ends = {min(len(text), last.a + last.size + (m - last.b - last.size)), last.a + last.size}
|
|
582
|
+
best: tuple[int, int, float] | None = None
|
|
583
|
+
for start in sorted(starts):
|
|
584
|
+
for end in sorted(ends, reverse=True):
|
|
585
|
+
if end <= start:
|
|
586
|
+
continue
|
|
587
|
+
s, e = _snap(text, start, end)
|
|
588
|
+
piece = text[s:e]
|
|
589
|
+
if "\x00" in piece:
|
|
590
|
+
continue
|
|
591
|
+
ratio = difflib.SequenceMatcher(None, _nfc(quote), _nfc(piece), autojunk=False).ratio()
|
|
592
|
+
if best is None or ratio > best[2] + 0.005:
|
|
593
|
+
best = (s, e, ratio)
|
|
594
|
+
if best is None or best[2] < _MIN_WINDOW_RATIO:
|
|
595
|
+
return None
|
|
596
|
+
return best
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def sentence_of(
|
|
600
|
+
index: DocumentIndex, candidate: Candidate, language: str = DEFAULT_LANGUAGE
|
|
601
|
+
) -> str:
|
|
602
|
+
"""The address of the sentence that holds the start of ``candidate``."""
|
|
603
|
+
para = index.paragraphs[candidate.number - 1]
|
|
604
|
+
spans = split_sentences(para.text, language)
|
|
605
|
+
k = 1
|
|
606
|
+
for i, span in enumerate(spans, 1):
|
|
607
|
+
if span.start <= candidate.start:
|
|
608
|
+
k = i
|
|
609
|
+
return f"{para.address}.s{k}"
|