playbook-engine 2.0.0__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.
- playbook_engine/__init__.py +3 -0
- playbook_engine/absence.py +538 -0
- playbook_engine/agent_judge.py +1325 -0
- playbook_engine/agent_segmenter.py +140 -0
- playbook_engine/artifact_store.py +314 -0
- playbook_engine/cache_format.py +266 -0
- playbook_engine/canonicalize.py +142 -0
- playbook_engine/citation_resolver.py +274 -0
- playbook_engine/clause_aligner.py +860 -0
- playbook_engine/clause_classifier.py +900 -0
- playbook_engine/clause_differ.py +619 -0
- playbook_engine/clause_position_compiler.py +382 -0
- playbook_engine/clause_tree.py +428 -0
- playbook_engine/cli.py +4266 -0
- playbook_engine/config.py +802 -0
- playbook_engine/corpus_linter.py +969 -0
- playbook_engine/deal_dates.py +814 -0
- playbook_engine/deviation_classifier.py +210 -0
- playbook_engine/digest.py +854 -0
- playbook_engine/document_renderer.py +1134 -0
- playbook_engine/docx_ingester.py +659 -0
- playbook_engine/docx_normalizer.py +279 -0
- playbook_engine/dossiers.py +742 -0
- playbook_engine/entity_registry.py +461 -0
- playbook_engine/environment.py +226 -0
- playbook_engine/equivalence.py +1013 -0
- playbook_engine/equivalence_check.py +591 -0
- playbook_engine/extraction.py +2561 -0
- playbook_engine/floor_candidates.py +1832 -0
- playbook_engine/floor_judge.py +328 -0
- playbook_engine/index_page_assets.py +695 -0
- playbook_engine/induction_version_selector.py +163 -0
- playbook_engine/inspection_report.py +483 -0
- playbook_engine/instrument.py +178 -0
- playbook_engine/intake_plan.py +769 -0
- playbook_engine/judgment.py +387 -0
- playbook_engine/llm_segmentation_stage.py +296 -0
- playbook_engine/llm_segmenter.py +367 -0
- playbook_engine/llm_segmenter_batch.py +762 -0
- playbook_engine/natural_sort.py +22 -0
- playbook_engine/observation_builder.py +2009 -0
- playbook_engine/opf_accessors.py +459 -0
- playbook_engine/overrides.py +805 -0
- playbook_engine/pdf_ingester.py +385 -0
- playbook_engine/pipeline.py +6190 -0
- playbook_engine/playbook_assembler.py +633 -0
- playbook_engine/posture.py +837 -0
- playbook_engine/precedent.py +669 -0
- playbook_engine/prior_form_origin_check.py +270 -0
- playbook_engine/prior_reconstruction.py +1015 -0
- playbook_engine/prior_standards.py +218 -0
- playbook_engine/provenance_detector.py +523 -0
- playbook_engine/resegmentation.py +1312 -0
- playbook_engine/reversal_detector.py +304 -0
- playbook_engine/review_rows.py +320 -0
- playbook_engine/rtf_ingester.py +248 -0
- playbook_engine/rubric.py +532 -0
- playbook_engine/run_manifest.py +1262 -0
- playbook_engine/scope_gate.py +392 -0
- playbook_engine/scorecard.py +1444 -0
- playbook_engine/segmentation_grounding.py +258 -0
- playbook_engine/segmentation_qa.py +581 -0
- playbook_engine/segmenter.py +330 -0
- playbook_engine/signed_detector.py +778 -0
- playbook_engine/staging.py +933 -0
- playbook_engine/subsplit.py +603 -0
- playbook_engine/subsplit_check.py +323 -0
- playbook_engine/taxonomy.py +208 -0
- playbook_engine/taxonomy_inductor.py +529 -0
- playbook_engine/template_standards.py +221 -0
- playbook_engine/toaster_install.py +40 -0
- playbook_engine/tracked_changes_overlay.py +363 -0
- playbook_engine/validator.py +2057 -0
- playbook_engine/version_orderer.py +874 -0
- playbook_engine-2.0.0.dist-info/METADATA +322 -0
- playbook_engine-2.0.0.dist-info/RECORD +105 -0
- playbook_engine-2.0.0.dist-info/WHEEL +4 -0
- playbook_engine-2.0.0.dist-info/entry_points.txt +3 -0
- playbook_engine-2.0.0.dist-info/licenses/LICENSE +224 -0
- playbook_engine-2.0.0.dist-info/licenses/NOTICE +15 -0
- spec/CHANGELOG.md +755 -0
- spec/clause-tree.schema.json +86 -0
- spec/conformance/0.6/README.md +44 -0
- spec/conformance/0.6/manifest.json +85 -0
- spec/conformance/0.6/vectors/001-minimal-no-perspective.json +104 -0
- spec/conformance/0.6/vectors/002-variants-refused-and-exclusions.json +682 -0
- spec/conformance/0.6/vectors/003-cap-totals-and-summary.json +638 -0
- spec/conformance/0.6/vectors/004-party-alias-grouping.json +542 -0
- spec/conformance/0.6/vectors/005-opening-evidence.json +621 -0
- spec/conformance/0.6/vectors/006-opening-rules.json +849 -0
- spec/conformance/0.6/vectors/007-equivalence-label-and-coverage.json +1035 -0
- spec/conformance/0.6/vectors/008-hard-rules-and-dossiers.json +1791 -0
- spec/conformance/0.6/vectors/009-standard-parts-and-owner-label.json +788 -0
- spec/conformance/0.6/vectors/010-signing-dates.json +646 -0
- spec/conformance/0.6/vectors/011-settled-outcomes.json +1117 -0
- spec/conformance/0.6/vectors/012-prior-standards.json +943 -0
- spec/conformance/0.6/vectors/013-reconstructed-prior-form.json +568 -0
- spec/conformance/0.6/vectors/014-verified-absence.json +763 -0
- spec/conformance/0.6/vectors/015-number-and-key-rules.json +114 -0
- spec/conformance/README.md +64 -0
- spec/playbook.schema-0.6.json +3208 -0
- spec/taxonomy/affiliation-agreement.yaml +62 -0
- spec/taxonomy/cuad-base.yaml +67 -0
- spec/taxonomy/general-commercial.yaml +32 -0
- spec/taxonomy/nda.yaml +93 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Verified absence counts: "we signed N deals without clause X" (issue #251, OPF 0.6).
|
|
2
|
+
|
|
3
|
+
The toaster reads a new contract that lacks a clause type entirely and asks how
|
|
4
|
+
often we have signed without it. Counting a deal that has no precedent record
|
|
5
|
+
for the clause as "signed without it" is wrong whenever the clause was in the
|
|
6
|
+
deal but never classified (on the private acceptance corpus about one
|
|
7
|
+
observation in six is still unclassified), so a missed classification would read
|
|
8
|
+
as an absence. This module splits every (signed deal, clause type) with no
|
|
9
|
+
signed precedent into two facts, recorded in ``evidence.absence``:
|
|
10
|
+
|
|
11
|
+
``absent_verified``
|
|
12
|
+
None of the deal's signed-text nodes scores at or above :data:`ABSENCE_GUARD`
|
|
13
|
+
against the clause type, so there is no passage of the signed copy that could
|
|
14
|
+
be the clause and was filed elsewhere or not at all.
|
|
15
|
+
``possibly_present``
|
|
16
|
+
Otherwise: at least one signed-text node scores at or above the guard (the
|
|
17
|
+
record cites up to :data:`MAX_CANDIDATES` of them, best first, each with the
|
|
18
|
+
clause type the classifier filed it under or ``null``), or nothing could be
|
|
19
|
+
checked (``basis: "unverifiable"``: the deal's executed copy produced no
|
|
20
|
+
readable text or has big passages that extracted as junk, or the clause type
|
|
21
|
+
has no reference text, or the executed copy is a different instrument than the
|
|
22
|
+
negotiated agreement: an amendment, a cover sheet or order form incorporating
|
|
23
|
+
its terms by reference or URL, a form switch; :mod:`playbook_engine.instrument`).
|
|
24
|
+
|
|
25
|
+
The nodes scored are every clause node of the executed copy: the ones the
|
|
26
|
+
classifier left unclassified AND the ones it filed under another clause type
|
|
27
|
+
(an unclassified node that is no clause, ``x_non_clause``, is left out). The
|
|
28
|
+
second kind matters: a clause stated inside a block the classifier filed under
|
|
29
|
+
a neighbouring type (a removal right inside the students' responsibilities) is
|
|
30
|
+
as good as unclassified for the question "is it in this deal?". The price is
|
|
31
|
+
that a neighbouring clause's shared vocabulary can keep a deal out of the verified
|
|
32
|
+
count; that errs towards ``possibly_present``, the safe side.
|
|
33
|
+
|
|
34
|
+
The score is the content-word Jaccard similarity the content-similarity
|
|
35
|
+
classifier uses (issue #235, :func:`~playbook_engine.clause_classifier._content_tokens`),
|
|
36
|
+
the best of the node as a whole and each of its paragraphs, sentences and list
|
|
37
|
+
items (:func:`~playbook_engine.resegmentation.split_units`; a big node that holds
|
|
38
|
+
the clause among others would otherwise dilute it below any threshold), against
|
|
39
|
+
each reference text of the clause type: the parts of our standard and of every
|
|
40
|
+
former form of ours, and the classified exemplars of the clause (every text
|
|
41
|
+
any deal signed, opened with or had refused for it). :data:`ABSENCE_GUARD` is
|
|
42
|
+
well below the assign threshold (:data:`~playbook_engine.clause_classifier.CONTENT_ASSIGN_THRESHOLD`,
|
|
43
|
+
0.25): a node that is merely *plausibly* the clause keeps a deal out of the
|
|
44
|
+
verified count, while only a node that is confidently the clause is assigned
|
|
45
|
+
to it. A node the classifier assigned to the very clause type but that was too
|
|
46
|
+
short to be precedent (it never reached ``evidence.precedent``) scores 1.0: it
|
|
47
|
+
IS the classifier's finding of the clause.
|
|
48
|
+
|
|
49
|
+
Everything is a fact, never a verdict, and a pure function of the store: two
|
|
50
|
+
compiles of the same store produce byte-identical records. Paper side takes no
|
|
51
|
+
part in it.
|
|
52
|
+
|
|
53
|
+
Agent spot-check. :func:`check_subjects` turns a deterministic sample of the
|
|
54
|
+
verified absences (all of them when there are :data:`CHECK_SAMPLE_MAX` or
|
|
55
|
+
fewer) into ``absence_check`` questions, "is clause type X really absent from
|
|
56
|
+
this signed text?"; the verdicts gate nothing, they measure the guard. A
|
|
57
|
+
confirmed-present item means the guard is too weak (a clause scoring just below
|
|
58
|
+
it was counted as absent): tighten it, that is, lower it, until the checked sample
|
|
59
|
+
has none.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import hashlib
|
|
65
|
+
import json
|
|
66
|
+
import re
|
|
67
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
68
|
+
from dataclasses import dataclass
|
|
69
|
+
from typing import Any
|
|
70
|
+
|
|
71
|
+
from playbook_engine.canonicalize import canonicalize
|
|
72
|
+
from playbook_engine.clause_classifier import _content_tokens
|
|
73
|
+
from playbook_engine.clause_position_compiler import OPFCitation, _is_degenerate_observation_text
|
|
74
|
+
from playbook_engine.instrument import instrument_flags
|
|
75
|
+
from playbook_engine.observation_builder import Observation
|
|
76
|
+
from playbook_engine.resegmentation import split_units
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"ABSENCE_GUARD",
|
|
80
|
+
"ABSENCE_ID_PREFIX",
|
|
81
|
+
"BASES",
|
|
82
|
+
"BASIS_SCORED",
|
|
83
|
+
"BASIS_UNVERIFIABLE",
|
|
84
|
+
"CHECK_KIND",
|
|
85
|
+
"CHECK_SAMPLE_MAX",
|
|
86
|
+
"MAX_CANDIDATES",
|
|
87
|
+
"SCORE_DECIMALS",
|
|
88
|
+
"STATUSES",
|
|
89
|
+
"STATUS_POSSIBLE",
|
|
90
|
+
"STATUS_VERIFIED",
|
|
91
|
+
"AbsenceSubject",
|
|
92
|
+
"absence_id",
|
|
93
|
+
"absence_records",
|
|
94
|
+
"build_absence",
|
|
95
|
+
"check_subjects",
|
|
96
|
+
"counts_by_clause",
|
|
97
|
+
"signed_deal_ids",
|
|
98
|
+
"validate_check_verdict",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
#: The pending-item kind of the agent spot-check (``judge/pending.jsonl``).
|
|
102
|
+
CHECK_KIND = "absence_check"
|
|
103
|
+
|
|
104
|
+
#: A deterministic sample of at most this many verified absences is queued for
|
|
105
|
+
#: the check (all of them when there are this many or fewer).
|
|
106
|
+
CHECK_SAMPLE_MAX = 40
|
|
107
|
+
|
|
108
|
+
#: Every absence id starts with this prefix (OPF-SPEC §3.5.8).
|
|
109
|
+
ABSENCE_ID_PREFIX = "abs."
|
|
110
|
+
_ABSENCE_ID_HEX = 16
|
|
111
|
+
|
|
112
|
+
STATUS_VERIFIED = "absent_verified"
|
|
113
|
+
STATUS_POSSIBLE = "possibly_present"
|
|
114
|
+
STATUSES = (STATUS_VERIFIED, STATUS_POSSIBLE)
|
|
115
|
+
|
|
116
|
+
#: ``basis`` of a record: how the status was reached.
|
|
117
|
+
BASIS_SCORED = "scored"
|
|
118
|
+
BASIS_UNVERIFIABLE = "unverifiable"
|
|
119
|
+
BASES = (BASIS_SCORED, BASIS_UNVERIFIABLE)
|
|
120
|
+
|
|
121
|
+
ABSENCE_GUARD: float = 0.10
|
|
122
|
+
"""A signed-text node that scores at least this against a clause type keeps the
|
|
123
|
+
deal out of that clause's verified absences (it becomes ``possibly_present``).
|
|
124
|
+
|
|
125
|
+
Well below the content-similarity assign threshold (0.25,
|
|
126
|
+
``clause_classifier.CONTENT_ASSIGN_THRESHOLD``, issue #235): assigning a node to
|
|
127
|
+
a clause plants a precedent record, so it needs confidence; declining to *verify
|
|
128
|
+
an absence* only needs plausibility, and a missed classification read as an
|
|
129
|
+
absence is the error this guard exists to prevent. A higher guard
|
|
130
|
+
verifies more absences and a lower one fewer, so the spot-check (``absence_check``)
|
|
131
|
+
is the arbiter: lower it the moment a confirmed-present item turns up, and
|
|
132
|
+
relax it only while the checked sample has none. Scores are rounded to :data:`SCORE_DECIMALS` places
|
|
133
|
+
before they are compared with it, so the number a record shows is the number
|
|
134
|
+
that decided."""
|
|
135
|
+
|
|
136
|
+
UNREADABLE_NODE_CHARS = 200
|
|
137
|
+
"""A signed-text node of at least this many characters is *unreadable* when fewer
|
|
138
|
+
than :data:`UNREADABLE_MAX_WORD_SHARE` of its whitespace-separated tokens are
|
|
139
|
+
words (three letters or more): a font-encoding failure that extracts a page as
|
|
140
|
+
columns of digits. A shorter node (a signature line, a table cell) is never
|
|
141
|
+
unreadable."""
|
|
142
|
+
|
|
143
|
+
UNREADABLE_MAX_WORD_SHARE = 0.3
|
|
144
|
+
|
|
145
|
+
UNREADABLE_DEAL_CHARS = 1000
|
|
146
|
+
"""A signed copy with at least this many characters of unreadable nodes cannot
|
|
147
|
+
have a clause verified absent from it (the clause may sit in the text that did not
|
|
148
|
+
extract): every record of the deal is ``possibly_present`` with ``basis``
|
|
149
|
+
``unverifiable``. Measured on the private acceptance corpus: 2 of 43 signed copies
|
|
150
|
+
(5,030 and 19,496 unreadable characters)."""
|
|
151
|
+
|
|
152
|
+
MAX_CANDIDATES = 3
|
|
153
|
+
"""A ``possibly_present`` record cites at most this many candidate nodes, best
|
|
154
|
+
score first."""
|
|
155
|
+
|
|
156
|
+
SCORE_DECIMALS = 3
|
|
157
|
+
"""Scores are rounded to this many decimal places."""
|
|
158
|
+
|
|
159
|
+
MIN_WINDOW_TOKENS = 3
|
|
160
|
+
"""A paragraph, sentence or list item with fewer content words than this is not
|
|
161
|
+
scored on its own (the node as a whole always is)."""
|
|
162
|
+
|
|
163
|
+
_CLASSIFIED_SCORE = 1.0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _wire_score(score: float) -> int | float:
|
|
167
|
+
"""A candidate score as written to the document (OPF-SPEC §3.10 number rule).
|
|
168
|
+
|
|
169
|
+
A whole-number score (the classifier's 1.0, or a Jaccard of identical token sets) is
|
|
170
|
+
written as the integer ``1``: Python would serialize ``1.0`` into the canonical form,
|
|
171
|
+
which a verifier built on a standard JSON parser (one that cannot tell ``1`` from
|
|
172
|
+
``1.0``) would hash differently."""
|
|
173
|
+
return int(score) if float(score).is_integer() else score
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def absence_id(agreement_type_id: str, document_id: str, taxonomy_id: str) -> str:
|
|
177
|
+
"""The stable id of one absence record.
|
|
178
|
+
|
|
179
|
+
``"abs." + sha256(canonical([agreement_type_id, document_id, taxonomy_id]))[:16]``;
|
|
180
|
+
(document_id, taxonomy_id) is unique per record, and the status is a fact
|
|
181
|
+
about it that may change when the corpus is re-mined, so it is not hashed."""
|
|
182
|
+
payload = canonicalize([agreement_type_id, document_id, taxonomy_id])
|
|
183
|
+
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
184
|
+
return f"{ABSENCE_ID_PREFIX}{digest[:_ABSENCE_ID_HEX]}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def signed_deal_ids(
|
|
188
|
+
corpus_documents: Iterable[Mapping[str, Any]], precedent: Iterable[Mapping[str, Any]]
|
|
189
|
+
) -> list[str]:
|
|
190
|
+
"""The in-scope deals with a detected executed copy, sorted.
|
|
191
|
+
|
|
192
|
+
``corpus.documents[].signed_version`` is authoritative when recorded (the
|
|
193
|
+
digest's ``corpus.n_signed`` rule); a document that records none counts as
|
|
194
|
+
signed when one of its precedent records is signed."""
|
|
195
|
+
signed_by_precedent = {
|
|
196
|
+
p.get("document_id")
|
|
197
|
+
for p in precedent
|
|
198
|
+
if isinstance(p, Mapping) and p.get("signed") is True
|
|
199
|
+
}
|
|
200
|
+
ids: set[str] = set()
|
|
201
|
+
for doc in corpus_documents:
|
|
202
|
+
if not isinstance(doc, Mapping) or not doc.get("in_scope", True):
|
|
203
|
+
continue
|
|
204
|
+
document_id = doc.get("document_id")
|
|
205
|
+
if not isinstance(document_id, str):
|
|
206
|
+
continue
|
|
207
|
+
signed = (
|
|
208
|
+
doc.get("signed_version") is not None
|
|
209
|
+
if "signed_version" in doc
|
|
210
|
+
else document_id in signed_by_precedent
|
|
211
|
+
)
|
|
212
|
+
if signed:
|
|
213
|
+
ids.add(document_id)
|
|
214
|
+
return sorted(ids)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_unreadable(text: str) -> bool:
|
|
218
|
+
"""Whether *text* is a big node that extracted as junk (see :data:`UNREADABLE_NODE_CHARS`)."""
|
|
219
|
+
if len(text) < UNREADABLE_NODE_CHARS:
|
|
220
|
+
return False
|
|
221
|
+
tokens = text.split()
|
|
222
|
+
words = sum(1 for t in tokens if _WORD.fullmatch(t.strip(".,;:()[]\"'")))
|
|
223
|
+
return words < UNREADABLE_MAX_WORD_SHARE * len(tokens)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_WORD = re.compile(r"[A-Za-z]{3,}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _windows(text: str) -> list[frozenset[str]]:
|
|
230
|
+
"""The token sets a node is scored on: the whole node, then each unit of it."""
|
|
231
|
+
whole = _content_tokens(text)
|
|
232
|
+
windows = [whole] if whole else []
|
|
233
|
+
units = split_units(text)
|
|
234
|
+
if len(units) > 1:
|
|
235
|
+
for start, end in units:
|
|
236
|
+
tokens = _content_tokens(text[start:end])
|
|
237
|
+
if len(tokens) >= MIN_WINDOW_TOKENS and tokens != whole:
|
|
238
|
+
windows.append(tokens)
|
|
239
|
+
return windows
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _jaccard(a: frozenset[str], b: frozenset[str]) -> float:
|
|
243
|
+
inter = len(a & b)
|
|
244
|
+
return inter / (len(a) + len(b) - inter) if inter else 0.0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _reference_texts(clause: Mapping[str, Any]) -> list[str]:
|
|
248
|
+
"""The standard texts of one evidence clause: our standard's parts and whole,
|
|
249
|
+
and the same for every former form of ours."""
|
|
250
|
+
texts: list[str] = []
|
|
251
|
+
for std in (clause.get("our_standard"), *(clause.get("prior_standards") or [])):
|
|
252
|
+
if not isinstance(std, Mapping):
|
|
253
|
+
continue
|
|
254
|
+
parts = [str(p.get("text") or "") for p in std.get("parts") or [] if isinstance(p, Mapping)]
|
|
255
|
+
texts.extend(parts)
|
|
256
|
+
if isinstance(std.get("text"), str):
|
|
257
|
+
texts.append(std["text"])
|
|
258
|
+
return [t for t in texts if t.strip()]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _citation(obs: Observation) -> dict[str, Any]:
|
|
262
|
+
return OPFCitation(
|
|
263
|
+
document_id=obs.citation.document_id,
|
|
264
|
+
version=obs.citation.version,
|
|
265
|
+
clause_path=obs.citation.clause_path,
|
|
266
|
+
char_span=obs.citation.char_span,
|
|
267
|
+
).to_dict()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def build_absence(
|
|
271
|
+
*,
|
|
272
|
+
agreement_type_id: str,
|
|
273
|
+
clauses: Sequence[Mapping[str, Any]],
|
|
274
|
+
precedent: Sequence[Mapping[str, Any]],
|
|
275
|
+
observations: Sequence[Observation],
|
|
276
|
+
corpus_documents: Sequence[Mapping[str, Any]],
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
"""The ``evidence.absence`` section: ``{guard, records}``.
|
|
279
|
+
|
|
280
|
+
One record per (signed deal, clause type) with no signed precedent, in
|
|
281
|
+
(document_id, taxonomy_id) order. *clauses* are the ``evidence.clauses``
|
|
282
|
+
entries (their standards are the reference texts); *precedent* the
|
|
283
|
+
``evidence.precedent`` records (what the deals signed, and the exemplars'
|
|
284
|
+
signed texts); *observations* the store's rows (the signed-text nodes).
|
|
285
|
+
"""
|
|
286
|
+
tids = [c["taxonomy_id"] for c in clauses if isinstance(c.get("taxonomy_id"), str)]
|
|
287
|
+
tid_set = set(tids)
|
|
288
|
+
signed_deals = signed_deal_ids(corpus_documents, precedent)
|
|
289
|
+
signed_set = set(signed_deals)
|
|
290
|
+
has_signed_text = {
|
|
291
|
+
(p.get("document_id"), p.get("taxonomy_id"))
|
|
292
|
+
for p in precedent
|
|
293
|
+
if p.get("signed") is True and isinstance(p.get("signed_text"), Mapping)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# Reference token sets per clause type, deduplicated.
|
|
297
|
+
references: dict[str, set[frozenset[str]]] = {tid: set() for tid in tids}
|
|
298
|
+
for clause in clauses:
|
|
299
|
+
tid = clause.get("taxonomy_id")
|
|
300
|
+
if tid not in references:
|
|
301
|
+
continue
|
|
302
|
+
for text in _reference_texts(clause):
|
|
303
|
+
tokens = _content_tokens(text)
|
|
304
|
+
if tokens:
|
|
305
|
+
references[tid].add(tokens)
|
|
306
|
+
for p in precedent:
|
|
307
|
+
p_tid = p.get("taxonomy_id")
|
|
308
|
+
if p_tid not in references:
|
|
309
|
+
continue
|
|
310
|
+
for entry in (p.get("signed_text"), p.get("opening_text"), *(p.get("refused_asks") or [])):
|
|
311
|
+
entry_text = entry.get("text") if isinstance(entry, Mapping) else None
|
|
312
|
+
entry_tokens = (
|
|
313
|
+
_content_tokens(entry_text) if isinstance(entry_text, str) else frozenset()
|
|
314
|
+
)
|
|
315
|
+
if entry_tokens:
|
|
316
|
+
references[p_tid].add(entry_tokens)
|
|
317
|
+
flat_refs = [(tid, ref) for tid in tids for ref in references[tid]]
|
|
318
|
+
|
|
319
|
+
# The signed-text nodes of each signed deal: every row of the executed copy
|
|
320
|
+
# that is a clause (an unclassified node that is no clause is left out).
|
|
321
|
+
nodes: dict[str, list[Observation]] = {d: [] for d in signed_deals}
|
|
322
|
+
unreadable_chars: dict[str, int] = dict.fromkeys(signed_deals, 0)
|
|
323
|
+
for obs in observations:
|
|
324
|
+
document_id = obs.citation.document_id
|
|
325
|
+
if document_id not in signed_set or obs.outcome != "signed":
|
|
326
|
+
continue
|
|
327
|
+
if _is_unreadable(obs.full_text):
|
|
328
|
+
unreadable_chars[document_id] += len(obs.full_text)
|
|
329
|
+
if obs.non_clause is None and obs.full_text.strip():
|
|
330
|
+
nodes[document_id].append(obs)
|
|
331
|
+
|
|
332
|
+
# A signed copy that is a different instrument than the negotiated agreement
|
|
333
|
+
# (an amendment, a cover sheet over incorporated terms, a form switch) cannot
|
|
334
|
+
# verify an absence either: the terms live in another document.
|
|
335
|
+
other_instrument = set(instrument_flags(observations))
|
|
336
|
+
|
|
337
|
+
# A signed copy with big unreadable passages cannot be verified at all.
|
|
338
|
+
unreadable_deals = {
|
|
339
|
+
document_id
|
|
340
|
+
for document_id, chars in unreadable_chars.items()
|
|
341
|
+
if chars >= UNREADABLE_DEAL_CHARS
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Score each distinct node text against every clause type once (boilerplate
|
|
345
|
+
# repeats across deals).
|
|
346
|
+
scores_by_text: dict[str, dict[str, float]] = {}
|
|
347
|
+
|
|
348
|
+
def scores_of(text: str) -> dict[str, float]:
|
|
349
|
+
cached = scores_by_text.get(text)
|
|
350
|
+
if cached is None:
|
|
351
|
+
best: dict[str, float] = {}
|
|
352
|
+
for window in _windows(text):
|
|
353
|
+
for tid, ref in flat_refs:
|
|
354
|
+
s = _jaccard(window, ref)
|
|
355
|
+
if s > best.get(tid, 0.0):
|
|
356
|
+
best[tid] = s
|
|
357
|
+
cached = {tid: round(s, SCORE_DECIMALS) for tid, s in best.items()}
|
|
358
|
+
scores_by_text[text] = cached
|
|
359
|
+
return cached
|
|
360
|
+
|
|
361
|
+
records: list[dict[str, Any]] = []
|
|
362
|
+
for document_id in signed_deals:
|
|
363
|
+
for tid in sorted(tid_set):
|
|
364
|
+
if (document_id, tid) in has_signed_text:
|
|
365
|
+
continue
|
|
366
|
+
candidates: list[tuple[float, Observation]] = []
|
|
367
|
+
checked = 0
|
|
368
|
+
for node in nodes[document_id]:
|
|
369
|
+
if node.taxonomy_id == tid:
|
|
370
|
+
# Filed under this very clause but too short to be precedent
|
|
371
|
+
# (a longer one has a precedent record and no absence record).
|
|
372
|
+
if _is_degenerate_observation_text(node.full_text):
|
|
373
|
+
candidates.append((_CLASSIFIED_SCORE, node))
|
|
374
|
+
checked += 1
|
|
375
|
+
continue
|
|
376
|
+
checked += 1
|
|
377
|
+
score = scores_of(node.full_text).get(tid, 0.0)
|
|
378
|
+
if score >= ABSENCE_GUARD:
|
|
379
|
+
candidates.append((score, node))
|
|
380
|
+
candidates.sort(
|
|
381
|
+
key=lambda c: (-c[0], c[1].citation.clause_path, str(c[1].citation.version))
|
|
382
|
+
)
|
|
383
|
+
unverifiable = (
|
|
384
|
+
not nodes[document_id]
|
|
385
|
+
or not references[tid]
|
|
386
|
+
or document_id in unreadable_deals
|
|
387
|
+
or document_id in other_instrument
|
|
388
|
+
)
|
|
389
|
+
records.append(
|
|
390
|
+
{
|
|
391
|
+
"id": absence_id(agreement_type_id, document_id, tid),
|
|
392
|
+
"taxonomy_id": tid,
|
|
393
|
+
"document_id": document_id,
|
|
394
|
+
"status": STATUS_VERIFIED
|
|
395
|
+
if not (candidates or unverifiable)
|
|
396
|
+
else STATUS_POSSIBLE,
|
|
397
|
+
"basis": BASIS_UNVERIFIABLE if unverifiable else BASIS_SCORED,
|
|
398
|
+
"n_checked": checked,
|
|
399
|
+
"candidates": [
|
|
400
|
+
{
|
|
401
|
+
"ref": _citation(node),
|
|
402
|
+
"score": _wire_score(score),
|
|
403
|
+
"filed_under": node.taxonomy_id,
|
|
404
|
+
}
|
|
405
|
+
for score, node in candidates[:MAX_CANDIDATES]
|
|
406
|
+
],
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
return {"guard": ABSENCE_GUARD, "records": records}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def absence_records(playbook: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
413
|
+
"""``evidence.absence.records`` of a document (``[]`` when absent)."""
|
|
414
|
+
evidence = playbook.get("evidence")
|
|
415
|
+
section = evidence.get("absence") if isinstance(evidence, Mapping) else None
|
|
416
|
+
records = section.get("records") if isinstance(section, Mapping) else None
|
|
417
|
+
return [r for r in records if isinstance(r, dict)] if isinstance(records, list) else []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def counts_by_clause(records: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, int]]:
|
|
421
|
+
"""``{taxonomy_id: {verified, possible}}`` over *records* (distinct deals)."""
|
|
422
|
+
deals: dict[str, dict[str, set[Any]]] = {}
|
|
423
|
+
for record in records:
|
|
424
|
+
tid = record.get("taxonomy_id")
|
|
425
|
+
if not isinstance(tid, str):
|
|
426
|
+
continue
|
|
427
|
+
bucket = deals.setdefault(tid, {STATUS_VERIFIED: set(), STATUS_POSSIBLE: set()})
|
|
428
|
+
if record.get("status") in bucket:
|
|
429
|
+
bucket[record["status"]].add(record.get("document_id"))
|
|
430
|
+
return {
|
|
431
|
+
tid: {
|
|
432
|
+
"verified": len(b[STATUS_VERIFIED]),
|
|
433
|
+
"possible": len(b[STATUS_POSSIBLE]),
|
|
434
|
+
}
|
|
435
|
+
for tid, b in sorted(deals.items())
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
# Agent spot-check (absence_check)
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass(frozen=True)
|
|
445
|
+
class AbsenceSubject:
|
|
446
|
+
"""One ``absence_check`` question; ``payload`` is exactly what the judge sees.
|
|
447
|
+
|
|
448
|
+
The payload carries the clause type, our standard and the deal's whole signed
|
|
449
|
+
text, and no document id, counterparty or paper side. ``absence_id`` names
|
|
450
|
+
the record the question was built from, for the operator's own bookkeeping;
|
|
451
|
+
it is not part of the payload or the key."""
|
|
452
|
+
|
|
453
|
+
key: str
|
|
454
|
+
payload: dict[str, Any]
|
|
455
|
+
absence_id: str
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _payload_key(payload: Any) -> str:
|
|
459
|
+
"""The pending-queue key of *payload* (same hash as ``agent_judge._payload_key``)."""
|
|
460
|
+
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
461
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def check_subjects(
|
|
465
|
+
*,
|
|
466
|
+
clauses: Sequence[Mapping[str, Any]],
|
|
467
|
+
records: Iterable[Mapping[str, Any]],
|
|
468
|
+
observations: Sequence[Observation],
|
|
469
|
+
limit: int = CHECK_SAMPLE_MAX,
|
|
470
|
+
) -> list[AbsenceSubject]:
|
|
471
|
+
"""The ``absence_check`` sample of one compile.
|
|
472
|
+
|
|
473
|
+
Every ``absent_verified`` record becomes a question (the clause type, our
|
|
474
|
+
standard, and the deal's signed text as an ordered list of its nodes, each
|
|
475
|
+
with the clause-type label the classifier gave it or ``null``); duplicates
|
|
476
|
+
(the same question for two deals with the same signed text) are asked once,
|
|
477
|
+
and the *limit* questions with the lowest payload hash are kept, all of them
|
|
478
|
+
when there are *limit* or fewer. A pure function of the compile: the sample is
|
|
479
|
+
the same on every run over the same store.
|
|
480
|
+
"""
|
|
481
|
+
records = list(records)
|
|
482
|
+
clause_by_tid = {c["taxonomy_id"]: c for c in clauses if isinstance(c.get("taxonomy_id"), str)}
|
|
483
|
+
label_of = {tid: str(c.get("title") or tid) for tid, c in clause_by_tid.items()}
|
|
484
|
+
nodes_by_doc: dict[str, list[dict[str, Any]]] = {}
|
|
485
|
+
wanted = {r.get("document_id") for r in records if r.get("status") == STATUS_VERIFIED}
|
|
486
|
+
for obs in observations:
|
|
487
|
+
document_id = obs.citation.document_id
|
|
488
|
+
if document_id not in wanted or obs.outcome != "signed" or not obs.full_text.strip():
|
|
489
|
+
continue
|
|
490
|
+
nodes_by_doc.setdefault(document_id, []).append(
|
|
491
|
+
{
|
|
492
|
+
"clause_type": label_of.get(obs.taxonomy_id, obs.taxonomy_id)
|
|
493
|
+
if obs.taxonomy_id
|
|
494
|
+
else None,
|
|
495
|
+
"text": obs.full_text,
|
|
496
|
+
}
|
|
497
|
+
)
|
|
498
|
+
questions: dict[str, AbsenceSubject] = {}
|
|
499
|
+
for record in records:
|
|
500
|
+
if record.get("status") != STATUS_VERIFIED:
|
|
501
|
+
continue
|
|
502
|
+
rec_tid, rec_doc = record.get("taxonomy_id"), record.get("document_id")
|
|
503
|
+
clause = clause_by_tid.get(rec_tid) if isinstance(rec_tid, str) else None
|
|
504
|
+
if clause is None or not isinstance(rec_doc, str):
|
|
505
|
+
continue
|
|
506
|
+
std = clause.get("our_standard")
|
|
507
|
+
payload = {
|
|
508
|
+
"stage": CHECK_KIND,
|
|
509
|
+
"question": (
|
|
510
|
+
"Is the clause type below really absent from this signed text, "
|
|
511
|
+
"that is, does no passage of it state this clause?"
|
|
512
|
+
),
|
|
513
|
+
"clause_type": {"id": str(rec_tid), "label": label_of[str(rec_tid)]},
|
|
514
|
+
"our_standard": std.get("text") if isinstance(std, Mapping) else None,
|
|
515
|
+
"signed_text": nodes_by_doc.get(rec_doc, []),
|
|
516
|
+
}
|
|
517
|
+
key = _payload_key(payload)
|
|
518
|
+
questions.setdefault(
|
|
519
|
+
key, AbsenceSubject(key=key, payload=payload, absence_id=str(record.get("id")))
|
|
520
|
+
)
|
|
521
|
+
keys = sorted(questions)[: max(0, limit)]
|
|
522
|
+
return [questions[k] for k in keys]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def validate_check_verdict(verdict: Mapping[str, Any]) -> None:
|
|
526
|
+
"""Check a producer-supplied ``absence_check`` verdict.
|
|
527
|
+
|
|
528
|
+
``{"absent": true | false, "reason": "<optional short text>"}`` -- ``true``
|
|
529
|
+
when the clause type is really absent from the signed text, ``false`` when a
|
|
530
|
+
passage of it states the clause (a confirmed-present item: the guard is too
|
|
531
|
+
high).
|
|
532
|
+
"""
|
|
533
|
+
absent = verdict.get("absent")
|
|
534
|
+
if not isinstance(absent, bool):
|
|
535
|
+
raise ValueError("'absent' must be a JSON boolean")
|
|
536
|
+
reason = verdict.get("reason")
|
|
537
|
+
if reason is not None and not isinstance(reason, str):
|
|
538
|
+
raise ValueError("'reason' must be a string when given")
|