vicary 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +57 -0
- data/assets/MANIFEST.json +33 -0
- data/assets/notability.txt.gz +0 -0
- data/assets/stop_words.txt +63 -0
- data/lib/vicary/asset.rb +190 -0
- data/lib/vicary/candidates.rb +1702 -0
- data/lib/vicary/conformance.rb +242 -0
- data/lib/vicary/gazetteer.rb +399 -0
- data/lib/vicary/lexicon.rb +174 -0
- data/lib/vicary/minter.rb +95 -0
- data/lib/vicary/redact.rb +172 -0
- data/lib/vicary/structured.rb +343 -0
- data/lib/vicary/version.rb +10 -0
- data/lib/vicary.rb +41 -0
- metadata +66 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Vicary
|
|
7
|
+
# Read a language-neutral word list that ships beside the gazetteer.
|
|
8
|
+
#
|
|
9
|
+
# The stoplist used to be a literal in Python's `name_candidates.py`, which was
|
|
10
|
+
# fine while there was one front door. With three, a hand-transliterated word
|
|
11
|
+
# list is a second detector wearing the first one's name: the divergence shows
|
|
12
|
+
# up as prose corruption in one language and not the others, and no parity
|
|
13
|
+
# check on *masked output* would catch it, because a stop word going missing
|
|
14
|
+
# changes what gets masked in essays nobody put in a fixture.
|
|
15
|
+
#
|
|
16
|
+
# So the list is authored once, language-neutrally, under `asset/lexicon/` in
|
|
17
|
+
# the repository, and vendored into each package the same way the gazetteer is.
|
|
18
|
+
# This is the reader; it owns nothing.
|
|
19
|
+
#
|
|
20
|
+
# Format
|
|
21
|
+
# ------
|
|
22
|
+
# `#!` lines are directives, `#` lines are comments, blank lines are skipped,
|
|
23
|
+
# and every other line contributes whitespace-separated words. One directive is
|
|
24
|
+
# required:
|
|
25
|
+
#
|
|
26
|
+
# #!lexicon 1 format version
|
|
27
|
+
# #!list <name> <count> the list's name, and its DISTINCT word count
|
|
28
|
+
#
|
|
29
|
+
# The count is asserted, not trusted. A short read here makes the redactor
|
|
30
|
+
# **more** aggressive — fewer stop words means more capitalised ordinary words
|
|
31
|
+
# become name candidates — which looks privacy-safe, corrupts prose, and passes
|
|
32
|
+
# any check that only asks whether something was masked. Same reasoning as the
|
|
33
|
+
# gazetteer's per-tier counts; same failure mode if it is skipped.
|
|
34
|
+
#
|
|
35
|
+
# This is the fourth reader of this format, after `asset/vicary_build/lexicon.py`,
|
|
36
|
+
# `python/src/vicary/lexicon.py` and `typescript/src/lexicon.ts`. The duplication
|
|
37
|
+
# is deliberate — the build tool must not import one of the implementations it
|
|
38
|
+
# feeds — and it is only honest because something compares the results.
|
|
39
|
+
# `test/lexicon_test.rb` pins this reader's output against the count and the
|
|
40
|
+
# spot-checks the other three are pinned against.
|
|
41
|
+
module Lexicon
|
|
42
|
+
# On-disk format version for a lexicon file. Bump when the parse changes, so
|
|
43
|
+
# a stale vendored copy fails loudly rather than parsing to a different list.
|
|
44
|
+
LEXICON_FORMAT = 1
|
|
45
|
+
|
|
46
|
+
# Filename suffix. Named so a second list costs a file rather than a refactor.
|
|
47
|
+
SUFFIX = ".txt"
|
|
48
|
+
|
|
49
|
+
# A lexicon is absent, unreadable, or not the shape this reader understands.
|
|
50
|
+
#
|
|
51
|
+
# Its own class rather than a bare RuntimeError for the same reason
|
|
52
|
+
# {Asset::FormatError} has one: a caller that wants to tell "the install is
|
|
53
|
+
# incomplete" apart from "something else raised" cannot do it on a message.
|
|
54
|
+
class LexiconError < StandardError; end
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# Where the vendored copy of +name+ lives, whether or not it exists.
|
|
58
|
+
#
|
|
59
|
+
# Searched along the same path as the gazetteer, in the same order, so a
|
|
60
|
+
# package cannot end up reading its stoplist from one cut and its
|
|
61
|
+
# gazetteer from another.
|
|
62
|
+
def path(name)
|
|
63
|
+
filename = "#{name}#{SUFFIX}"
|
|
64
|
+
tried = Asset.search_path
|
|
65
|
+
found = tried.find { |dir| dir.join(filename).file? }
|
|
66
|
+
return found.join(filename) if found
|
|
67
|
+
|
|
68
|
+
# Return the first location rather than raising, so `load` reports the
|
|
69
|
+
# same "missing" error whether the directory is absent or the file
|
|
70
|
+
# inside it is.
|
|
71
|
+
(tried.first || Pathname.new(".")).join(filename)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse lexicon text into its case-folded distinct words.
|
|
75
|
+
#
|
|
76
|
+
# Public so a test can feed it a deliberately malformed document. A parser
|
|
77
|
+
# reachable only through a vendored file on disk is a parser whose failure
|
|
78
|
+
# paths are never exercised — and every one of this parser's failure paths
|
|
79
|
+
# exists to turn a silent short read into a loud one.
|
|
80
|
+
#
|
|
81
|
+
# @param where [String] a path, used only in error messages, so a failure
|
|
82
|
+
# names a file.
|
|
83
|
+
def parse(name, text, where)
|
|
84
|
+
declared = nil
|
|
85
|
+
saw_format = false
|
|
86
|
+
words = Set.new
|
|
87
|
+
|
|
88
|
+
text.split(/\r?\n/, -1).each_with_index do |line, index|
|
|
89
|
+
lineno = index + 1
|
|
90
|
+
stripped = line.strip
|
|
91
|
+
|
|
92
|
+
if stripped.start_with?("#!")
|
|
93
|
+
parts = stripped[2..].to_s.split(/\s+/).reject(&:empty?)
|
|
94
|
+
raise LexiconError, "#{where}:#{lineno}: empty directive" if parts.empty?
|
|
95
|
+
|
|
96
|
+
case parts[0]
|
|
97
|
+
when "lexicon"
|
|
98
|
+
saw_format = true
|
|
99
|
+
unless parts.length == 2 && parts[1] == LEXICON_FORMAT.to_s
|
|
100
|
+
raise LexiconError,
|
|
101
|
+
"#{where}:#{lineno}: lexicon format " \
|
|
102
|
+
"#{parts[1..].join(' ').inspect}, this build reads " \
|
|
103
|
+
"#{LEXICON_FORMAT}"
|
|
104
|
+
end
|
|
105
|
+
when "list"
|
|
106
|
+
unless parts.length == 3 && parts[1] == name
|
|
107
|
+
raise LexiconError,
|
|
108
|
+
"#{where}:#{lineno}: expected `\#!list #{name} <count>`, " \
|
|
109
|
+
"got #{stripped.inspect}"
|
|
110
|
+
end
|
|
111
|
+
# `to_i` would read "3x" as 3, where Python's `int()` raises. The
|
|
112
|
+
# difference is the whole guard: a count this reader silently
|
|
113
|
+
# repaired is a count that no longer proves anything about the parse.
|
|
114
|
+
unless /\A\d+\z/.match?(parts[2])
|
|
115
|
+
raise LexiconError,
|
|
116
|
+
"#{where}:#{lineno}: count #{parts[2].inspect} is not an integer"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
declared = parts[2].to_i
|
|
120
|
+
else
|
|
121
|
+
# Refused rather than ignored: an unrecognised directive means the
|
|
122
|
+
# file was written by something that knows more than this reader,
|
|
123
|
+
# and guessing which lines are still words is how a partial list
|
|
124
|
+
# loads as a whole one.
|
|
125
|
+
raise LexiconError, "#{where}:#{lineno}: unknown directive #{parts[0].inspect}"
|
|
126
|
+
end
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
next if stripped.empty? || stripped.start_with?("#")
|
|
131
|
+
|
|
132
|
+
stripped.split(/\s+/).each do |word|
|
|
133
|
+
words << word.downcase unless word.empty?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless saw_format
|
|
138
|
+
raise LexiconError, "#{where}: no `\#!lexicon` directive; not a lexicon file"
|
|
139
|
+
end
|
|
140
|
+
raise LexiconError, "#{where}: no `\#!list #{name} <count>` directive" if declared.nil?
|
|
141
|
+
|
|
142
|
+
unless words.size == declared
|
|
143
|
+
raise LexiconError,
|
|
144
|
+
"#{where}: declares #{declared} distinct words, parsed " \
|
|
145
|
+
"#{words.size}. A short read makes the redactor more aggressive, " \
|
|
146
|
+
"which is why this is an error and not a warning."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
words
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The case-folded distinct words of lexicon +name+.
|
|
153
|
+
#
|
|
154
|
+
# Raises {LexiconError} rather than returning a partial list. An empty or
|
|
155
|
+
# truncated stoplist is the quiet failure this whole module exists to
|
|
156
|
+
# prevent.
|
|
157
|
+
def load(name, path: nil)
|
|
158
|
+
location = Pathname.new(path || self.path(name))
|
|
159
|
+
begin
|
|
160
|
+
text = location.read(encoding: "UTF-8")
|
|
161
|
+
rescue Errno::ENOENT
|
|
162
|
+
raise LexiconError,
|
|
163
|
+
"lexicon #{name.inspect} missing at #{location}. The installed " \
|
|
164
|
+
"vicary gem is incomplete — reinstall it, or vendor the asset " \
|
|
165
|
+
"with `rake sync_assets` from a checkout."
|
|
166
|
+
rescue SystemCallError => e
|
|
167
|
+
raise LexiconError, "cannot read lexicon at #{location}: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
parse(name, text, location.to_s)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vicary
|
|
4
|
+
# Hands out `{KIND_n}` placeholders, stable per distinct original.
|
|
5
|
+
#
|
|
6
|
+
# The Ruby port of `python/src/vicary/redaction.py`'s minter.
|
|
7
|
+
#
|
|
8
|
+
# Why numbering, stated as a measurement rather than a preference: a bare
|
|
9
|
+
# `{NAME}` standing for every person in a document is **not reversible**. On 25
|
|
10
|
+
# injected essays the unnumbered masker produced 37 not-restorable violations
|
|
11
|
+
# and only 36% of essays round-tripped — one token meant `Marisol` in one
|
|
12
|
+
# paragraph and `Terrence Okonkwo` in the next, so no map keyed on the token
|
|
13
|
+
# can put either back.
|
|
14
|
+
#
|
|
15
|
+
# Two properties, and the second is the one that needs care:
|
|
16
|
+
#
|
|
17
|
+
# * **Injective** — distinct originals never share a placeholder, which is what
|
|
18
|
+
# makes restore well-defined.
|
|
19
|
+
# * **Stable within a document** — the *same* original always gets the same
|
|
20
|
+
# index, so a name written five times masks to one placeholder rather than
|
|
21
|
+
# five. That matters beyond restorability: a scoring model reading
|
|
22
|
+
# `{NAME_1} argued … {NAME_1} concluded` can still see one person doing two
|
|
23
|
+
# things, where `{NAME_1} … {NAME_5}` reads as two strangers.
|
|
24
|
+
#
|
|
25
|
+
# Keyed on the exact original text, because restore must return the exact
|
|
26
|
+
# bytes. `Terrence` and `Terrence's` are therefore different keys — correct but
|
|
27
|
+
# unsatisfying, and the reason surname-folding does NOT belong here: folding
|
|
28
|
+
# them together would make the mapping non-injective again.
|
|
29
|
+
#
|
|
30
|
+
# **Indices follow mint order, which is discovery order, not position in the
|
|
31
|
+
# text.** One minter serves the whole document precisely so that holds;
|
|
32
|
+
# per-pass minters would restart each counter and emit `{NAME_1}` twice for two
|
|
33
|
+
# different people, which is the bug numbering exists to remove.
|
|
34
|
+
class PlaceholderMinter
|
|
35
|
+
# Off reproduces the unnumbered output byte for byte, so the two arms stay
|
|
36
|
+
# separately measurable.
|
|
37
|
+
attr_reader :number
|
|
38
|
+
|
|
39
|
+
def initialize(number: true)
|
|
40
|
+
@number = number
|
|
41
|
+
# `[kind, original]` -> index. A tuple key rather than the joined string
|
|
42
|
+
# the other two ports use: Ruby hashes take array keys directly, so there
|
|
43
|
+
# is no separator to pick and no way for a name containing one to collide.
|
|
44
|
+
# Insertion-ordered, which is what makes {#assigned} come back in discovery
|
|
45
|
+
# order.
|
|
46
|
+
@assigned = {}
|
|
47
|
+
@high = Hash.new(0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The placeholder `original` should be replaced by.
|
|
51
|
+
def mint(kind, original)
|
|
52
|
+
return "{#{kind}}" unless @number
|
|
53
|
+
|
|
54
|
+
key = [kind, original]
|
|
55
|
+
index = @assigned[key]
|
|
56
|
+
if index.nil?
|
|
57
|
+
index = @high[kind] + 1
|
|
58
|
+
@high[kind] = index
|
|
59
|
+
@assigned[key] = index
|
|
60
|
+
end
|
|
61
|
+
"{#{kind}_#{index}}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Replace every match with a minted placeholder; returns the text and count.
|
|
65
|
+
def substitute(kind, pattern, text)
|
|
66
|
+
count = 0
|
|
67
|
+
replaced = text.gsub(pattern) do |match|
|
|
68
|
+
count += 1
|
|
69
|
+
mint(kind, match)
|
|
70
|
+
end
|
|
71
|
+
[replaced, count]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `{placeholder => original}` — the restore map, for free.
|
|
75
|
+
#
|
|
76
|
+
# Insertion-ordered, so the map reads in the order the document discovered
|
|
77
|
+
# each span rather than in the order the placeholders sort.
|
|
78
|
+
def assigned
|
|
79
|
+
@assigned.keys.to_h { |kind, original| [mint(kind, original), original] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Put the originals back.
|
|
84
|
+
#
|
|
85
|
+
# Longest placeholder first, so `{NAME_1}` cannot be partially consumed while
|
|
86
|
+
# `{NAME_11}` is still pending.
|
|
87
|
+
def self.restore(text, map)
|
|
88
|
+
map.keys.sort_by { |k| -k.length }.reduce(text) do |out, placeholder|
|
|
89
|
+
# Block form, not the two-argument one: a replacement *string* interprets
|
|
90
|
+
# `\1`, `\&` and `\\`, so a restored name containing a backslash would come
|
|
91
|
+
# back altered. The block returns the original bytes untouched.
|
|
92
|
+
out.gsub(placeholder) { map[placeholder] }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vicary
|
|
6
|
+
# Retained, and no longer raised by anything.
|
|
7
|
+
#
|
|
8
|
+
# The reason it existed still holds: the alternative to an error is a silent
|
|
9
|
+
# no-op redactor, which satisfies every caller and redacts nothing. Kept so a
|
|
10
|
+
# future incomplete arm can raise it rather than inventing a new type.
|
|
11
|
+
class NotPortedError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Whether this build detects third-party names.
|
|
14
|
+
#
|
|
15
|
+
# Exported so a host can assert on it rather than infer it from a version
|
|
16
|
+
# number. True since candidate generation landed; it is still meaningful,
|
|
17
|
+
# because {NAMES_IDENTITY} turns the whole route back off at runtime.
|
|
18
|
+
DETECTS_NAMES = true
|
|
19
|
+
|
|
20
|
+
# Only the identity the caller handed over, plus the structured entities. No
|
|
21
|
+
# gazetteer is loaded and no candidate is generated — 0% recall on third-party
|
|
22
|
+
# names, which is a defensible choice only when it is a chosen one.
|
|
23
|
+
NAMES_IDENTITY = "identity"
|
|
24
|
+
# Generation plus the offline notability oracle: the shippable arm.
|
|
25
|
+
NAMES_GAZETTEER = "gazetteer"
|
|
26
|
+
# ...and the lowercase route, the only one that reaches a student who writes
|
|
27
|
+
# without capitals.
|
|
28
|
+
NAMES_LOWERCASE = "gazetteer-lowercase"
|
|
29
|
+
|
|
30
|
+
# The default, matching the reference. Recall is what to buy inbound.
|
|
31
|
+
DEFAULT_NAME_DETECTION = NAMES_LOWERCASE
|
|
32
|
+
|
|
33
|
+
NAME_DETECTION_ENV_VAR = "VICARY_NAME_DETECTION"
|
|
34
|
+
|
|
35
|
+
IDENTITY_ALIASES = Set.new(%w[identity off none 0 false no]).freeze
|
|
36
|
+
GAZETTEER_ALIASES = Set.new(%w[gazetteer on 1 true yes names]).freeze
|
|
37
|
+
LOWERCASE_ALIASES = Set.new(%w[gazetteer-lowercase gazetteer_lowercase lowercase full max]).freeze
|
|
38
|
+
|
|
39
|
+
# The redaction entry point — the whole detector, wired end to end.
|
|
40
|
+
#
|
|
41
|
+
# Two passes over one document, in this order and for this reason:
|
|
42
|
+
#
|
|
43
|
+
# 1. **The identity and structured pass** ({Vicary::Structured}) — the
|
|
44
|
+
# student's own name, school and school acronym, then every syntactic
|
|
45
|
+
# entity: email, URL, SSN, IP, phone, street address, date of birth,
|
|
46
|
+
# `@handle`, payment card behind a Luhn gate, ZIP and age. These are exact
|
|
47
|
+
# patterns, and they run first so that no looser match can consume part of
|
|
48
|
+
# one.
|
|
49
|
+
# 2. **Candidate generation** ({Vicary::Candidates}) — the third-party names
|
|
50
|
+
# nothing hands over: the classmate, the teacher, the relative, the
|
|
51
|
+
# neighbour. High recall by construction, filtered by the offline notability
|
|
52
|
+
# oracle so the public figures a student writes *about* survive.
|
|
53
|
+
#
|
|
54
|
+
# Generation runs LAST, for the same reason it does in the reference: a broad
|
|
55
|
+
# capitalised-word match run early would swallow the first token of an address
|
|
56
|
+
# or the local part of an email, and a name half-eaten by another pattern leaks
|
|
57
|
+
# the remainder.
|
|
58
|
+
#
|
|
59
|
+
# **One minter for the whole document.** Placeholder indices follow mint order
|
|
60
|
+
# across both passes, so `{NAME_1}` means one person from the first line to the
|
|
61
|
+
# last. Two minters would restart each counter and hand the same token to two
|
|
62
|
+
# different people, which is the defect numbering exists to remove.
|
|
63
|
+
#
|
|
64
|
+
# The arm this reproduces is `local-gazetteer-lowercase` — generation, plus the
|
|
65
|
+
# gazetteer notability oracle, plus the lowercase route. That is the arm the
|
|
66
|
+
# conformance golden was produced by, and a port comparing against those bytes
|
|
67
|
+
# while implementing a different arm is measuring two changes at once.
|
|
68
|
+
class << self
|
|
69
|
+
# Resolve how hard the detector looks for names it was not handed.
|
|
70
|
+
#
|
|
71
|
+
# Explicit argument, then `VICARY_NAME_DETECTION`, then the code default.
|
|
72
|
+
#
|
|
73
|
+
# An unrecognized non-empty value resolves to the **default**, not to
|
|
74
|
+
# `identity`. Dropping silently to `identity` would leave redaction on and
|
|
75
|
+
# reporting spans while finding none of the names a reader would call PII — a
|
|
76
|
+
# failure that looks exactly like success from every log line and metric.
|
|
77
|
+
def name_detection(value = nil)
|
|
78
|
+
raw = (value || ENV[NAME_DETECTION_ENV_VAR] || "").strip.downcase
|
|
79
|
+
return NAMES_IDENTITY if !raw.empty? && IDENTITY_ALIASES.include?(raw)
|
|
80
|
+
return NAMES_GAZETTEER if GAZETTEER_ALIASES.include?(raw)
|
|
81
|
+
return NAMES_LOWERCASE if LOWERCASE_ALIASES.include?(raw)
|
|
82
|
+
|
|
83
|
+
DEFAULT_NAME_DETECTION
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Wire the bundled gazetteer into a detection level.
|
|
87
|
+
#
|
|
88
|
+
# Generation and the oracle are ONE decision, not two: generation alone masks
|
|
89
|
+
# every public figure a student writes about, and the oracle alone has
|
|
90
|
+
# nothing to judge. There is deliberately no supported way to ask for half of
|
|
91
|
+
# it.
|
|
92
|
+
#
|
|
93
|
+
# At {NAMES_IDENTITY} this returns nothing and the 2.1 MB asset is never
|
|
94
|
+
# touched. At the other two levels the first lookup pays the decompression;
|
|
95
|
+
# call `Vicary::Gazetteer.load` at process start to move that off the first
|
|
96
|
+
# request.
|
|
97
|
+
def gazetteer_oracles(level)
|
|
98
|
+
return { candidates: false } if level == NAMES_IDENTITY
|
|
99
|
+
|
|
100
|
+
oracles = {
|
|
101
|
+
candidates: true,
|
|
102
|
+
notable: ->(name) { Gazetteer.notable?(name) },
|
|
103
|
+
notability_tier: ->(name) { Gazetteer.notability(name) },
|
|
104
|
+
title: ->(name) { Gazetteer.title?(name) },
|
|
105
|
+
title_prefix: ->(key) { Gazetteer.title_prefix?(key) },
|
|
106
|
+
# Wired at BOTH gazetteer levels, unlike `given_name` below. This one
|
|
107
|
+
# decides a placeholder's type, not a verdict, so it has nothing to do
|
|
108
|
+
# with which candidate routes are on.
|
|
109
|
+
settlement: ->(name) { Gazetteer.settlement?(name) },
|
|
110
|
+
}
|
|
111
|
+
# The one difference between the two gazetteer levels. Absent rather than
|
|
112
|
+
# nil, so `gazetteer` and `gazetteer-lowercase` differ by the presence of a
|
|
113
|
+
# key rather than by a value the merge would have to strip.
|
|
114
|
+
oracles[:given_name] = ->(token) { Gazetteer.common_given_name?(token) } if level == NAMES_LOWERCASE
|
|
115
|
+
oracles
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Redact `text`, returning the masked bytes and everything needed to undo it.
|
|
119
|
+
#
|
|
120
|
+
# One minter for the whole document, because placeholder indices follow mint
|
|
121
|
+
# order across every pass.
|
|
122
|
+
#
|
|
123
|
+
# Returns `[masked_text, n_masked, restore_map]`.
|
|
124
|
+
def redact_with_report(text, identity, options = {})
|
|
125
|
+
names = options[:names]
|
|
126
|
+
keep = options[:keep] || Set.new
|
|
127
|
+
number_placeholders = options.fetch(:number_placeholders, true)
|
|
128
|
+
headings_are_orthographic = options.fetch(:headings_are_orthographic, true)
|
|
129
|
+
corroborate = options.fetch(:corroborate, true)
|
|
130
|
+
relation_refusal = options.fetch(:relation_refusal, true)
|
|
131
|
+
title_relation_refusal = options.fetch(:title_relation_refusal, true)
|
|
132
|
+
|
|
133
|
+
minter = PlaceholderMinter.new(number: number_placeholders)
|
|
134
|
+
return [text, 0, minter.assigned] if text.nil? || text.empty?
|
|
135
|
+
|
|
136
|
+
masked, n = Structured.mask(text, identity, minter)
|
|
137
|
+
|
|
138
|
+
# Candidate generation runs LAST, so every exact pattern has already
|
|
139
|
+
# claimed its span.
|
|
140
|
+
oracles = gazetteer_oracles(name_detection(names))
|
|
141
|
+
if oracles.delete(:candidates)
|
|
142
|
+
masked, count = Candidates.mask_candidates(
|
|
143
|
+
masked,
|
|
144
|
+
oracles.merge(
|
|
145
|
+
keep: keep,
|
|
146
|
+
corroborate: corroborate,
|
|
147
|
+
minter: minter,
|
|
148
|
+
headings_are_orthographic: headings_are_orthographic,
|
|
149
|
+
relation_refusal: relation_refusal,
|
|
150
|
+
title_relation_refusal: title_relation_refusal,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
n += count
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[masked, n, minter.assigned]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Redact personal names and structured PII from `text`.
|
|
160
|
+
#
|
|
161
|
+
# @param text [String] the composition to redact.
|
|
162
|
+
# @param identity [Object] the student the detector is told about — anything
|
|
163
|
+
# answering `first_name`, `last_name` and `school_name`. Every reference arm
|
|
164
|
+
# interpolates these strings, so a caller that omits them is measuring a
|
|
165
|
+
# different system and misses the easiest spans in the fixture.
|
|
166
|
+
# @param options [Hash] the defaults are the reference arm; every flag exists
|
|
167
|
+
# so its arm stays separately measurable.
|
|
168
|
+
def redact(text, identity, options = {})
|
|
169
|
+
redact_with_report(text, identity, options)[0]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|