humanus 0.33.1__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.
- humanus/__init__.py +45 -0
- humanus/__main__.py +8 -0
- humanus/_util.py +18 -0
- humanus/builtin_checks.py +303 -0
- humanus/calibration.py +131 -0
- humanus/check.py +209 -0
- humanus/classifier_lane.py +390 -0
- humanus/cli.py +80 -0
- humanus/contract_commands.py +127 -0
- humanus/contracts.py +84 -0
- humanus/data/SKILL.md +57 -0
- humanus/data/__init__.py +1 -0
- humanus/emoji.py +11 -0
- humanus/finding_ids.py +38 -0
- humanus/openai_responses.py +94 -0
- humanus/packs/NOTICE.md +56 -0
- humanus/packs/__init__.py +27 -0
- humanus/packs/ai-writing-tells.yml +1119 -0
- humanus/packs/asd-ste100.yml +179 -0
- humanus/packs/plain-language.yml +322 -0
- humanus/policy_pack.py +267 -0
- humanus/rules_lane.py +92 -0
- humanus/schemas/calibration-v1.json +46 -0
- humanus/schemas/check-request-v1.json +126 -0
- humanus/schemas/check-result-v1.json +273 -0
- humanus/schemas/draft-questions-request-v1.json +57 -0
- humanus/schemas/draft-questions-result-v1.json +33 -0
- humanus/schemas/feedback-labels-v1.json +47 -0
- humanus/schemas/finding-v1.json +105 -0
- humanus/schemas/policy-pack-v1.json +443 -0
- humanus/schemas/suggest-request-v1.json +79 -0
- humanus/schemas/suggest-result-v1.json +104 -0
- humanus/segmentation.py +390 -0
- humanus/skill.py +13 -0
- humanus/standard_checks.py +262 -0
- humanus/style_profile_import.py +175 -0
- humanus/suggest.py +421 -0
- humanus/usability_commands.py +38 -0
- humanus/usability_profile.py +256 -0
- humanus/usability_scan.py +1024 -0
- humanus-0.33.1.dist-info/METADATA +182 -0
- humanus-0.33.1.dist-info/RECORD +46 -0
- humanus-0.33.1.dist-info/WHEEL +5 -0
- humanus-0.33.1.dist-info/entry_points.txt +2 -0
- humanus-0.33.1.dist-info/licenses/LICENSE +21 -0
- humanus-0.33.1.dist-info/top_level.txt +1 -0
humanus/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Humanus: a configurable editorial compliance engine.
|
|
2
|
+
|
|
3
|
+
A caller sends content and a policy pack; ``check`` returns where the content
|
|
4
|
+
breaks the pack's policies, ``suggest`` offers rewrites for a finding or any
|
|
5
|
+
selection, ``draft_questions`` helps write classifier detectors, and
|
|
6
|
+
``calibrate`` learns from editors' decisions. The JSON Schemas under
|
|
7
|
+
``humanus/schemas`` are the contract (``load_schema``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.33.1"
|
|
11
|
+
|
|
12
|
+
from .calibration import apply_calibration, calibrate
|
|
13
|
+
from .check import CheckRequestValidationError, check
|
|
14
|
+
from .contracts import ContractValidationError, load_schema, validate_payload
|
|
15
|
+
from .finding_ids import finding_id
|
|
16
|
+
from .packs import builtin_pack, list_builtin_packs
|
|
17
|
+
from .policy_pack import PolicyPack, PolicyPackValidationError, load_policy_pack
|
|
18
|
+
from .segmentation import SegmentedDocument, segment_document
|
|
19
|
+
from .style_profile_import import StyleProfileImportError, policy_pack_from_style_profile
|
|
20
|
+
from .suggest import SuggestRequestValidationError, SuggestUnavailableError, draft_questions, suggest
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CheckRequestValidationError",
|
|
24
|
+
"ContractValidationError",
|
|
25
|
+
"PolicyPack",
|
|
26
|
+
"PolicyPackValidationError",
|
|
27
|
+
"SegmentedDocument",
|
|
28
|
+
"StyleProfileImportError",
|
|
29
|
+
"SuggestRequestValidationError",
|
|
30
|
+
"SuggestUnavailableError",
|
|
31
|
+
"__version__",
|
|
32
|
+
"apply_calibration",
|
|
33
|
+
"builtin_pack",
|
|
34
|
+
"calibrate",
|
|
35
|
+
"check",
|
|
36
|
+
"draft_questions",
|
|
37
|
+
"finding_id",
|
|
38
|
+
"list_builtin_packs",
|
|
39
|
+
"load_policy_pack",
|
|
40
|
+
"load_schema",
|
|
41
|
+
"policy_pack_from_style_profile",
|
|
42
|
+
"segment_document",
|
|
43
|
+
"suggest",
|
|
44
|
+
"validate_payload",
|
|
45
|
+
]
|
humanus/__main__.py
ADDED
humanus/_util.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hash_stable(value: Any) -> str:
|
|
9
|
+
if isinstance(value, str):
|
|
10
|
+
payload = value
|
|
11
|
+
else:
|
|
12
|
+
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
13
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hash_short(value: Any) -> str:
|
|
17
|
+
return hash_stable(value)[:16]
|
|
18
|
+
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Built-in prose checks for the rules lane.
|
|
2
|
+
|
|
3
|
+
Each check reads the segmented document (prose only: code, quotes, directives
|
|
4
|
+
and the other protected regions never reach it) and yields matches as
|
|
5
|
+
(start, end, message, replacement) in document offsets.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import gzip
|
|
10
|
+
import re
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import Callable, Iterable
|
|
13
|
+
|
|
14
|
+
from .emoji import EMOJI_PATTERN
|
|
15
|
+
from .segmentation import Segment, SegmentedDocument, mask_inline, normalize_text
|
|
16
|
+
from .standard_checks import STANDARD_CHECKS
|
|
17
|
+
|
|
18
|
+
Match = tuple[int, int, "str | None", "str | None"]
|
|
19
|
+
BuiltinCheck = Callable[[SegmentedDocument, list[Segment], list[Segment], dict], Iterable[Match]]
|
|
20
|
+
|
|
21
|
+
EMPTY_LEADIN_PATTERNS = (
|
|
22
|
+
"^In today's\\b",
|
|
23
|
+
'^It is important to note\\b',
|
|
24
|
+
'^When it comes to\\b',
|
|
25
|
+
'^At the end of the day\\b',
|
|
26
|
+
'^In conclusion\\b',
|
|
27
|
+
)
|
|
28
|
+
CRUTCH_WORDS = ('actually', 'really', 'very', 'basically', 'essentially', 'literally', 'simply', 'clearly', 'obviously')
|
|
29
|
+
CONTRACTION_PAIRS = (
|
|
30
|
+
('that is', "that's"),
|
|
31
|
+
('it is', "it's"),
|
|
32
|
+
('there is', "there's"),
|
|
33
|
+
('who is', "who's"),
|
|
34
|
+
('what is', "what's"),
|
|
35
|
+
('do not', "don't"),
|
|
36
|
+
('does not', "doesn't"),
|
|
37
|
+
('did not', "didn't"),
|
|
38
|
+
('cannot', "can't"),
|
|
39
|
+
('can not', "can't"),
|
|
40
|
+
('will not', "won't"),
|
|
41
|
+
('would not', "wouldn't"),
|
|
42
|
+
('should not', "shouldn't"),
|
|
43
|
+
('could not', "couldn't"),
|
|
44
|
+
('is not', "isn't"),
|
|
45
|
+
('are not', "aren't"),
|
|
46
|
+
('was not', "wasn't"),
|
|
47
|
+
('were not', "weren't"),
|
|
48
|
+
('have not', "haven't"),
|
|
49
|
+
('has not', "hasn't"),
|
|
50
|
+
('had not', "hadn't"),
|
|
51
|
+
('they are', "they're"),
|
|
52
|
+
('we are', "we're"),
|
|
53
|
+
('you are', "you're"),
|
|
54
|
+
('i am', "I'm"),
|
|
55
|
+
('let us', "let's"),
|
|
56
|
+
)
|
|
57
|
+
PHRASE_CERTAINTY = re.compile('\\b(everyone knows|undeniably|proven)\\b', re.IGNORECASE)
|
|
58
|
+
ALWAYS_NEVER = re.compile('\\b(always|never)\\b', re.IGNORECASE)
|
|
59
|
+
HYPHENATED_ALWAYS_NEVER = re.compile('\\b(always|never)-\\w+', re.IGNORECASE)
|
|
60
|
+
NEVER_AUXILIARY = re.compile('\\bnever\\s+(had|would|could|should|might|may|will|can)\\b', re.IGNORECASE)
|
|
61
|
+
CLEARLY_ASSERTIVE = re.compile('\\bclearly\\s+(shows|demonstrates|proves|will|is)\\b', re.IGNORECASE)
|
|
62
|
+
CITATION = re.compile('(https?://|\\[[0-9]+\\]|according to|\\([12][0-9]{3}\\)|\\b20[0-9]{2}\\b)', re.IGNORECASE)
|
|
63
|
+
LIST_SHAPED = re.compile('\\bFirst,\\s+.+\\bSecond,\\s+.+\\bThird,\\s+', re.IGNORECASE)
|
|
64
|
+
INTENSIFIER_VAGUE = re.compile('\\b(very|significantly|dramatically|truly|really)\\b.+\\b(transform|revolutionize|leverage)\\b', re.IGNORECASE)
|
|
65
|
+
IRREGULAR_PARTICIPLES = (
|
|
66
|
+
'made', 'done', 'given', 'taken', 'held', 'kept', 'put', 'set', 'sent', 'shown', 'known', 'seen', 'written',
|
|
67
|
+
'built', 'found', 'paid', 'told', 'brought', 'bought', 'caught', 'taught', 'thought', 'lost', 'begun', 'chosen',
|
|
68
|
+
'driven', 'eaten', 'forgotten', 'gotten', 'hidden', 'broken', 'spoken', 'stolen', 'worn', 'torn', 'drawn',
|
|
69
|
+
'grown', 'thrown', 'sold', 'shut', 'led', 'fed', 'laid', 'said', 'heard', 'meant', 'understood', 'struck',
|
|
70
|
+
'hung', 'won', 'spent', 'bound',
|
|
71
|
+
)
|
|
72
|
+
PASSIVE = re.compile(
|
|
73
|
+
rf"\b(is|are|was|were|be|been|being)\s+(\w+ed|{'|'.join(IRREGULAR_PARTICIPLES)})\b", re.IGNORECASE
|
|
74
|
+
)
|
|
75
|
+
STAT_PERCENT = re.compile('\\d+(?:\\.\\d+)?%')
|
|
76
|
+
STAT_MULTIPLIER = re.compile('\\d+\\s*[×x]\\b|\\d+\\s+times\\b', re.IGNORECASE)
|
|
77
|
+
STAT_COUNT_UNIT = re.compile('\\b\\d{3,}\\s+(users|ms|seconds|minutes|hours|days|requests|tokens)\\b', re.IGNORECASE)
|
|
78
|
+
STICKER_NUMBER = re.compile('#\\d+\\b')
|
|
79
|
+
REFRAIN_MAX_WORDS = 12
|
|
80
|
+
TOKEN = re.compile(r"[A-Za-z0-9']+")
|
|
81
|
+
|
|
82
|
+
# Function-word inventory aligned with Ure (1971) / Halliday lexical-density practice.
|
|
83
|
+
FUNCTION_WORDS = frozenset(
|
|
84
|
+
"""
|
|
85
|
+
a about above after again against all am an and any are as at be because been before being
|
|
86
|
+
below between both but by can could did do does doing down during each few for from further
|
|
87
|
+
had has have having he her here hers herself him himself his how i if in into is it its
|
|
88
|
+
itself just me more most my myself no nor not now of off on once only or other our ours
|
|
89
|
+
ourselves out over own same she should so some such than that the their theirs them
|
|
90
|
+
themselves then there these they this those through to too under until up very was we were
|
|
91
|
+
what when where which while who whom why will with would you your yours yourself yourselves
|
|
92
|
+
""".split()
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- sentence predicates ----------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def has_unsupported_certainty(sentence: str) -> bool:
|
|
100
|
+
if PHRASE_CERTAINTY.search(sentence) or CLEARLY_ASSERTIVE.search(sentence):
|
|
101
|
+
return True
|
|
102
|
+
if not ALWAYS_NEVER.search(HYPHENATED_ALWAYS_NEVER.sub(" ", sentence)):
|
|
103
|
+
return False
|
|
104
|
+
if NEVER_AUXILIARY.search(sentence) or re.match(r"^Never\s+\w", sentence.strip()):
|
|
105
|
+
return False
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def has_statistical_claim(sentence: str) -> bool:
|
|
110
|
+
return bool(STAT_PERCENT.search(sentence) or STAT_MULTIPLIER.search(sentence) or STAT_COUNT_UNIT.search(sentence))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sentence_check(predicate: Callable[[Segment], bool], message: str) -> BuiltinCheck:
|
|
114
|
+
def run(document, paragraphs, sentences, params):
|
|
115
|
+
for sentence in sentences:
|
|
116
|
+
if predicate(sentence):
|
|
117
|
+
yield sentence.start, sentence.end, message, None
|
|
118
|
+
|
|
119
|
+
return run
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _empty_leadin(sentence: Segment) -> bool:
|
|
123
|
+
return any(re.search(pattern, sentence.text, re.IGNORECASE) for pattern in EMPTY_LEADIN_PATTERNS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _unsupported_certainty(sentence: Segment) -> bool:
|
|
127
|
+
return has_unsupported_certainty(sentence.text) and not CITATION.search(sentence.source)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _missing_attribution(sentence: Segment) -> bool:
|
|
131
|
+
if not has_statistical_claim(sentence.text) or CITATION.search(sentence.source):
|
|
132
|
+
return False
|
|
133
|
+
return not (STICKER_NUMBER.search(sentence.text) and not STAT_PERCENT.search(sentence.text))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- paragraph and document checks --------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _list_shaped_prose(document, paragraphs, sentences, params):
|
|
140
|
+
for paragraph in paragraphs:
|
|
141
|
+
if LIST_SHAPED.search(paragraph.text):
|
|
142
|
+
yield paragraph.start, paragraph.end, "Paragraph reads like an inline list without substantive development.", None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _uniform_cadence(document, paragraphs, sentences, params):
|
|
146
|
+
prose = [s for s in sentences if s.block == "paragraph"]
|
|
147
|
+
run_start = 0
|
|
148
|
+
while len(prose) >= 5 and run_start < len(prose):
|
|
149
|
+
anchor = len(prose[run_start].text.split())
|
|
150
|
+
run_end = run_start + 1
|
|
151
|
+
while run_end < len(prose) and abs(len(prose[run_end].text.split()) - anchor) <= 3:
|
|
152
|
+
run_end += 1
|
|
153
|
+
if run_end - run_start >= 5:
|
|
154
|
+
yield prose[run_start].start, prose[run_end - 1].end, "Five or more consecutive sentences share nearly identical length.", None
|
|
155
|
+
run_start = run_end
|
|
156
|
+
else:
|
|
157
|
+
run_start += 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _overused_words(document, paragraphs, sentences, params):
|
|
161
|
+
words = " ".join(p.text for p in paragraphs)
|
|
162
|
+
total = max(len(words.split()), 1)
|
|
163
|
+
threshold = max(3, round(total / 250))
|
|
164
|
+
for word in CRUTCH_WORDS:
|
|
165
|
+
pattern = re.compile(rf"\b{re.escape(word)}\b", re.IGNORECASE)
|
|
166
|
+
uses = len(pattern.findall(words))
|
|
167
|
+
if uses <= threshold:
|
|
168
|
+
continue
|
|
169
|
+
for paragraph in paragraphs:
|
|
170
|
+
first = pattern.search(mask_inline(paragraph.source))
|
|
171
|
+
if first:
|
|
172
|
+
yield (
|
|
173
|
+
paragraph.start + first.start(),
|
|
174
|
+
paragraph.start + first.end(),
|
|
175
|
+
f"'{word}' appears {uses} times across {total} words; cut most of them.",
|
|
176
|
+
None,
|
|
177
|
+
)
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _uncontracted_forms(document, paragraphs, sentences, params):
|
|
182
|
+
for paragraph in paragraphs:
|
|
183
|
+
masked = mask_inline(paragraph.source)
|
|
184
|
+
taken: list[tuple[int, int]] = []
|
|
185
|
+
for formal, contracted in CONTRACTION_PAIRS:
|
|
186
|
+
for match in re.finditer(rf"\b{re.escape(formal)}\b", masked, re.IGNORECASE):
|
|
187
|
+
start, end = match.span()
|
|
188
|
+
if any(start < b and end > a for a, b in taken):
|
|
189
|
+
continue
|
|
190
|
+
if formal == "that is" and masked[end : end + 1] == ",":
|
|
191
|
+
continue # the "i.e." sense; contracting it changes the meaning
|
|
192
|
+
matched = paragraph.source[start:end]
|
|
193
|
+
replacement = contracted[0].upper() + contracted[1:] if matched[0].isupper() else contracted
|
|
194
|
+
taken.append((start, end))
|
|
195
|
+
yield paragraph.start + start, paragraph.start + end, f"'{matched}' could contract to '{replacement}'.", replacement
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _emphasis_spans(source: str) -> list[tuple[int, int]]:
|
|
199
|
+
"""Spans inside single *...* or _..._ emphasis (not ** bold): italic quotations and formulas."""
|
|
200
|
+
return [m.span(2) for m in re.finditer(r"(?<![*_\w])([*_])(?!\1)(.+?)(?<!\1)\1(?![*_\w])", source, re.S)]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _redundancy(document, paragraphs, sentences, params):
|
|
204
|
+
"""Sentences in different places that share a four-word phrase.
|
|
205
|
+
|
|
206
|
+
Italic passages, prose inside container directives (pull quotes repeat the
|
|
207
|
+
body on purpose) and short identical refrains are not redundancy.
|
|
208
|
+
"""
|
|
209
|
+
shingles: dict[str, list[Segment]] = defaultdict(list)
|
|
210
|
+
for sentence in sentences:
|
|
211
|
+
if sentence.block == "heading" or sentence.container is not None:
|
|
212
|
+
continue
|
|
213
|
+
masked = mask_inline(sentence.source)
|
|
214
|
+
italic = _emphasis_spans(sentence.source)
|
|
215
|
+
tokens = [
|
|
216
|
+
(match.group().lower(), match.start(), match.end())
|
|
217
|
+
for match in TOKEN.finditer(masked)
|
|
218
|
+
if sentence.source[match.start() : match.end()] == match.group()
|
|
219
|
+
]
|
|
220
|
+
seen: set[str] = set()
|
|
221
|
+
for index in range(len(tokens) - 3):
|
|
222
|
+
start, end = tokens[index][1], tokens[index + 3][2]
|
|
223
|
+
if any(a <= start and end <= b for a, b in italic):
|
|
224
|
+
continue
|
|
225
|
+
shingle = " ".join(token for token, _, _ in tokens[index : index + 4])
|
|
226
|
+
if shingle not in seen:
|
|
227
|
+
seen.add(shingle)
|
|
228
|
+
shingles[shingle].append(sentence)
|
|
229
|
+
flagged: set[str] = set()
|
|
230
|
+
for members in shingles.values():
|
|
231
|
+
unique = list({s.id: s for s in members}.values())
|
|
232
|
+
if len(unique) < 2:
|
|
233
|
+
continue
|
|
234
|
+
texts = {normalize_text(s.text).lower() for s in unique}
|
|
235
|
+
if len(texts) == 1 and len(next(iter(texts)).split()) <= REFRAIN_MAX_WORDS:
|
|
236
|
+
continue
|
|
237
|
+
for sentence in unique:
|
|
238
|
+
if sentence.id not in flagged:
|
|
239
|
+
flagged.add(sentence.id)
|
|
240
|
+
yield sentence.start, sentence.end, "Repeats a four-word phrase used elsewhere in the document.", None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def lexical_density(tokens: list[str]) -> float:
|
|
244
|
+
return sum(1 for token in tokens if token not in FUNCTION_WORDS) / len(tokens) if tokens else 0.0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def gzip_ratio(text: str) -> float:
|
|
248
|
+
encoded = text.encode("utf-8")
|
|
249
|
+
return len(gzip.compress(encoded)) / len(encoded) if encoded else 1.0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _information_density(document, paragraphs, sentences, params):
|
|
253
|
+
prose = "\n\n".join(p.text for p in paragraphs)
|
|
254
|
+
tokens = [token.lower() for token in TOKEN.findall(prose)]
|
|
255
|
+
if len(tokens) < params.get("minWords", 400):
|
|
256
|
+
return
|
|
257
|
+
density = lexical_density(tokens)
|
|
258
|
+
minimum = params.get("minLexicalDensity", 0.45)
|
|
259
|
+
if density < minimum:
|
|
260
|
+
yield 0, len(document.text), f"Lexical density {density:.3f} is below {minimum:.3f}: many words, little content.", None
|
|
261
|
+
ratio = gzip_ratio(prose)
|
|
262
|
+
maximum = params.get("maxGzipRatio", 0.35)
|
|
263
|
+
if ratio <= maximum:
|
|
264
|
+
yield 0, len(document.text), f"The text compresses unusually well (gzip ratio {ratio:.3f} <= {maximum:.3f}): it repeats itself.", None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _contrast_cap(document, paragraphs, sentences, params):
|
|
268
|
+
cap = params["max"]
|
|
269
|
+
matches = [
|
|
270
|
+
(p.start + m.start(), p.start + m.end())
|
|
271
|
+
for p in paragraphs
|
|
272
|
+
for m in re.finditer(r",\s*not\s+\w+", mask_inline(p.source), re.IGNORECASE)
|
|
273
|
+
]
|
|
274
|
+
if len(matches) > cap:
|
|
275
|
+
for start, end in matches:
|
|
276
|
+
yield start, end, f"{len(matches)} 'X, not Y' contrasts in this document; the cap is {cap}.", None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _no_emojis(document, paragraphs, sentences, params):
|
|
280
|
+
for paragraph in paragraphs:
|
|
281
|
+
for match in EMOJI_PATTERN.finditer(paragraph.source):
|
|
282
|
+
yield paragraph.start + match.start(), paragraph.start + match.end(), "Contains an emoji.", ""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
BUILTIN_CHECKS: dict[str, BuiltinCheck] = {
|
|
286
|
+
"empty_leadin": _sentence_check(_empty_leadin, "Sentence opens with empty boilerplate instead of a concrete stake."),
|
|
287
|
+
"list_shaped_prose": _list_shaped_prose,
|
|
288
|
+
"vague_claim": _sentence_check(
|
|
289
|
+
lambda s: bool(INTENSIFIER_VAGUE.search(s.text)),
|
|
290
|
+
"Sentence pairs empty intensifiers with vague transformation language.",
|
|
291
|
+
),
|
|
292
|
+
"unsupported_certainty": _sentence_check(_unsupported_certainty, "Sentence states certainty without attributable evidence."),
|
|
293
|
+
"uniform_cadence": _uniform_cadence,
|
|
294
|
+
"redundancy": _redundancy,
|
|
295
|
+
"passive_voice": _sentence_check(lambda s: bool(PASSIVE.search(s.text)), "Sentence uses passive voice."),
|
|
296
|
+
"missing_attribution": _sentence_check(_missing_attribution, "Numeric or statistical claim lacks attributable evidence."),
|
|
297
|
+
"information_density": _information_density,
|
|
298
|
+
"overused_words": _overused_words,
|
|
299
|
+
"uncontracted_forms": _uncontracted_forms,
|
|
300
|
+
"contrast_cap": _contrast_cap,
|
|
301
|
+
"no_emojis": _no_emojis,
|
|
302
|
+
**STANDARD_CHECKS,
|
|
303
|
+
}
|
humanus/calibration.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Turn editor decisions into per-detector probability calibration.
|
|
2
|
+
|
|
3
|
+
Raw decision-model probabilities are overconfident. Each accepted, fixed or
|
|
4
|
+
dismissed classifier finding is a label: the model's probability and whether
|
|
5
|
+
the editor agreed it was a violation. ``calibrate`` fits a monotone map per
|
|
6
|
+
detector, which the caller stores on the detector (``calibration``) so that
|
|
7
|
+
``check`` thresholds on what editors actually mean.
|
|
8
|
+
|
|
9
|
+
Isotonic regression needs more labels than Platt scaling; with very few
|
|
10
|
+
labels no calibration is returned at all.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from .contracts import ContractValidationError, validate_payload
|
|
19
|
+
|
|
20
|
+
MIN_LABELS = 10
|
|
21
|
+
MIN_ISOTONIC_LABELS = 50
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FeedbackLabelsValidationError(ContractValidationError):
|
|
25
|
+
"""The payload is not a valid ``feedback-labels-v1`` document."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def calibrate(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
29
|
+
"""Fit a calibration for every detector that has labels (``calibration-v1``)."""
|
|
30
|
+
validate_payload("feedback-labels-v1", payload, FeedbackLabelsValidationError)
|
|
31
|
+
grouped: dict[tuple[str, str], list[Mapping[str, Any]]] = defaultdict(list)
|
|
32
|
+
for label in payload["labels"]:
|
|
33
|
+
grouped[(label["policyId"], label["detectorId"])].append(label)
|
|
34
|
+
|
|
35
|
+
detectors = []
|
|
36
|
+
for (policy_id, detector_id), labels in sorted(grouped.items()):
|
|
37
|
+
probabilities = [float(label["probability"]) for label in labels]
|
|
38
|
+
outcomes = [bool(label["violates"]) for label in labels]
|
|
39
|
+
entry: dict[str, Any] = {"policyId": policy_id, "detectorId": detector_id, "labels": len(labels)}
|
|
40
|
+
if len(labels) < MIN_LABELS or len(set(outcomes)) < 2:
|
|
41
|
+
entry["note"] = f"not enough labels to calibrate (need {MIN_LABELS}, with both outcomes)"
|
|
42
|
+
elif len(labels) < MIN_ISOTONIC_LABELS:
|
|
43
|
+
a, b = fit_platt(probabilities, outcomes)
|
|
44
|
+
entry["calibration"] = {"method": "platt", "a": round(a, 6), "b": round(b, 6), "labels": len(labels)}
|
|
45
|
+
else:
|
|
46
|
+
points = fit_isotonic(probabilities, outcomes)
|
|
47
|
+
entry["calibration"] = {"method": "isotonic", "points": points, "labels": len(labels)}
|
|
48
|
+
detectors.append(entry)
|
|
49
|
+
return {"schemaVersion": 1, "detectors": detectors}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def apply_calibration(calibration: Mapping[str, Any] | None, probability: float) -> float:
|
|
53
|
+
"""Map a raw probability through a stored calibration (identity when there is none)."""
|
|
54
|
+
if not calibration:
|
|
55
|
+
return probability
|
|
56
|
+
if calibration["method"] == "platt":
|
|
57
|
+
return _sigmoid(calibration["a"] * _logit(probability) + calibration["b"])
|
|
58
|
+
points = calibration["points"]
|
|
59
|
+
if probability <= points[0][0]:
|
|
60
|
+
return float(points[0][1])
|
|
61
|
+
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
|
62
|
+
if probability <= x1:
|
|
63
|
+
return float(y0 if x1 == x0 else y0 + (y1 - y0) * (probability - x0) / (x1 - x0))
|
|
64
|
+
return float(points[-1][1])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fit_isotonic(probabilities: Sequence[float], outcomes: Sequence[bool]) -> list[list[float]]:
|
|
68
|
+
"""Pool-adjacent-violators; returns increasing (raw, calibrated) knots."""
|
|
69
|
+
pairs = sorted(zip(probabilities, (1.0 if y else 0.0 for y in outcomes)))
|
|
70
|
+
blocks: list[list[float]] = [] # [sum_x, sum_y, count]
|
|
71
|
+
for x, y in pairs:
|
|
72
|
+
blocks.append([x, y, 1.0])
|
|
73
|
+
while len(blocks) > 1 and blocks[-2][1] / blocks[-2][2] >= blocks[-1][1] / blocks[-1][2]:
|
|
74
|
+
x2, y2, n2 = blocks.pop()
|
|
75
|
+
blocks[-1][0] += x2
|
|
76
|
+
blocks[-1][1] += y2
|
|
77
|
+
blocks[-1][2] += n2
|
|
78
|
+
return [[round(sx / n, 6), round(sy / n, 6)] for sx, sy, n in blocks]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fit_platt(probabilities: Sequence[float], outcomes: Sequence[bool], iterations: int = 100) -> tuple[float, float]:
|
|
82
|
+
"""Logistic regression of the outcome on logit(p), by Newton's method."""
|
|
83
|
+
xs = [_logit(p) for p in probabilities]
|
|
84
|
+
ys = [1.0 if y else 0.0 for y in outcomes]
|
|
85
|
+
a, b = 1.0, 0.0
|
|
86
|
+
for _ in range(iterations):
|
|
87
|
+
grad_a = grad_b = h_aa = h_ab = h_bb = 0.0
|
|
88
|
+
for x, y in zip(xs, ys):
|
|
89
|
+
p = _sigmoid(a * x + b)
|
|
90
|
+
w = p * (1 - p) + 1e-9
|
|
91
|
+
grad_a += (p - y) * x
|
|
92
|
+
grad_b += p - y
|
|
93
|
+
h_aa += w * x * x
|
|
94
|
+
h_ab += w * x
|
|
95
|
+
h_bb += w
|
|
96
|
+
det = h_aa * h_bb - h_ab * h_ab
|
|
97
|
+
if abs(det) < 1e-12:
|
|
98
|
+
break
|
|
99
|
+
step_a = (h_bb * grad_a - h_ab * grad_b) / det
|
|
100
|
+
step_b = (h_aa * grad_b - h_ab * grad_a) / det
|
|
101
|
+
a, b = a - step_a, b - step_b
|
|
102
|
+
if abs(step_a) + abs(step_b) < 1e-9:
|
|
103
|
+
break
|
|
104
|
+
return a, b
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def expected_calibration_error(probabilities: Iterable[float], outcomes: Iterable[bool], bins: int = 10) -> float:
|
|
108
|
+
"""Average |confidence - accuracy| over equal-width probability bins, weighted by size."""
|
|
109
|
+
buckets: list[list[tuple[float, float]]] = [[] for _ in range(bins)]
|
|
110
|
+
total = 0
|
|
111
|
+
for p, y in zip(probabilities, outcomes):
|
|
112
|
+
buckets[min(int(p * bins), bins - 1)].append((p, 1.0 if y else 0.0))
|
|
113
|
+
total += 1
|
|
114
|
+
if not total:
|
|
115
|
+
return 0.0
|
|
116
|
+
error = 0.0
|
|
117
|
+
for bucket in buckets:
|
|
118
|
+
if bucket:
|
|
119
|
+
mean_p = sum(p for p, _ in bucket) / len(bucket)
|
|
120
|
+
mean_y = sum(y for _, y in bucket) / len(bucket)
|
|
121
|
+
error += len(bucket) / total * abs(mean_p - mean_y)
|
|
122
|
+
return error
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _logit(p: float) -> float:
|
|
126
|
+
p = min(max(p, 1e-6), 1 - 1e-6)
|
|
127
|
+
return math.log(p / (1 - p))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _sigmoid(x: float) -> float:
|
|
131
|
+
return 1 / (1 + math.exp(-x)) if x >= 0 else math.exp(x) / (1 + math.exp(x))
|