active_sanction 1.0.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/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/entity"
|
|
7
|
+
require "active_sanction/name"
|
|
8
|
+
require "active_sanction/address"
|
|
9
|
+
require "active_sanction/identifier"
|
|
10
|
+
require "active_sanction/sources/remarks"
|
|
11
|
+
require "active_sanction/sources/ofac/remarks_parser"
|
|
12
|
+
|
|
13
|
+
module ActiveSanction
|
|
14
|
+
module Sources
|
|
15
|
+
class Ofac < Base
|
|
16
|
+
# One joined OFAC record -- a row of the primary file plus the ALT and
|
|
17
|
+
# ADD rows that share its `ent_num` -- turned into an Entity.
|
|
18
|
+
#
|
|
19
|
+
# Separate from the adapter because they are two jobs: the adapter says
|
|
20
|
+
# what the list is and where it lives, and this says what OFAC's columns
|
|
21
|
+
# mean. The mapping is where all the judgment sits, so it is worth being
|
|
22
|
+
# able to read it on its own.
|
|
23
|
+
#
|
|
24
|
+
# Shared by both OFAC adapters, because SDN.CSV and CONS_PRIM.CSV are
|
|
25
|
+
# the same twelve columns with the same conventions -- the same is true
|
|
26
|
+
# of ALT and ADD -- and the only thing that differs between them is
|
|
27
|
+
# which list a row is on. `source` is passed in rather than hard-coded
|
|
28
|
+
# for that reason, and #remark_fields is the hook a subclass overrides
|
|
29
|
+
# to record anything its own list publishes on top.
|
|
30
|
+
class Record
|
|
31
|
+
extend T::Sig
|
|
32
|
+
|
|
33
|
+
# OFAC's `SDN_Type` as published, and what each maps to. Blank is the
|
|
34
|
+
# one that matters: 9,923 of 19,321 rows leave it empty and every one
|
|
35
|
+
# of them is an organization. Defaulting blank to "unknown" would
|
|
36
|
+
# mis-type the largest group in the list.
|
|
37
|
+
TYPES = T.let(
|
|
38
|
+
{ "individual" => :individual, "vessel" => :vessel, "aircraft" => :aircraft }.freeze,
|
|
39
|
+
T::Hash[String, Symbol]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# The SDN_Type values this record class knows how to map, as a method
|
|
43
|
+
# rather than as the constant itself: a subclass reading a list that
|
|
44
|
+
# publishes a fourth one declares its own TYPES, and the caller asking
|
|
45
|
+
# -- Ofac#column_assertions -- has only the class in hand.
|
|
46
|
+
sig { returns(T::Array[String]) }
|
|
47
|
+
def self.published_types = TYPES.keys
|
|
48
|
+
|
|
49
|
+
# ALT.CSV's `alt_type`, which OFAC publishes as exactly these three.
|
|
50
|
+
ALIAS_KINDS = T.let({ "aka" => :aka, "fka" => :fka, "nka" => :nka }.freeze, T::Hash[String, Symbol])
|
|
51
|
+
|
|
52
|
+
# Multiple programs arrive in one field separated by `] [`:
|
|
53
|
+
# `"IRAQ2] [IRGC] [SDGT"` is three sanctions programs, not one.
|
|
54
|
+
PROGRAM_SEPARATOR = T.let(/\]\s*\[/, Regexp)
|
|
55
|
+
|
|
56
|
+
# Columns OFAC publishes outside its Remarks field that the canonical
|
|
57
|
+
# model has no home for. Appended to remarks rather than dropped: a
|
|
58
|
+
# vessel's flag and owner are real screening signal, and losing them to
|
|
59
|
+
# keep a schema tidy is the wrong trade.
|
|
60
|
+
COLUMNS_IN_REMARKS = T.let({
|
|
61
|
+
title: "Title", vessel_type: "Vessel type", tonnage: "Tonnage",
|
|
62
|
+
gross_registered_tonnage: "GRT", vessel_flag: "Vessel flag", vessel_owner: "Vessel owner"
|
|
63
|
+
}.freeze, T::Hash[Symbol, String])
|
|
64
|
+
|
|
65
|
+
# A name matches one already on the record when the letters and digits
|
|
66
|
+
# agree; OFAC's own punctuation does not have to. 24 of the 4,349
|
|
67
|
+
# inline aliases repeat an ALT.CSV row, and the rest are names the list
|
|
68
|
+
# publishes nowhere else.
|
|
69
|
+
INSIGNIFICANT = T.let(/[^[:alnum:]]+/, Regexp)
|
|
70
|
+
|
|
71
|
+
sig { returns(Parsers::DelimitedTable::Row) }
|
|
72
|
+
attr_reader :row
|
|
73
|
+
|
|
74
|
+
# The list this row is on, passed in rather than hard-coded: SDN.CSV
|
|
75
|
+
# and CONS_PRIM.CSV are read by this same class.
|
|
76
|
+
sig { returns(Symbol) }
|
|
77
|
+
attr_reader :source
|
|
78
|
+
|
|
79
|
+
sig { returns(T::Array[Parsers::DelimitedTable::Row]) }
|
|
80
|
+
attr_reader :aliases
|
|
81
|
+
|
|
82
|
+
sig { returns(T::Array[Parsers::DelimitedTable::Row]) }
|
|
83
|
+
attr_reader :addresses
|
|
84
|
+
|
|
85
|
+
sig do
|
|
86
|
+
params(row: Parsers::DelimitedTable::Row, source: Symbol,
|
|
87
|
+
aliases: T::Array[Parsers::DelimitedTable::Row],
|
|
88
|
+
addresses: T::Array[Parsers::DelimitedTable::Row]).void
|
|
89
|
+
end
|
|
90
|
+
def initialize(row:, source:, aliases: [], addresses: [])
|
|
91
|
+
@row = T.let(row, Parsers::DelimitedTable::Row)
|
|
92
|
+
@source = T.let(source, Symbol)
|
|
93
|
+
@aliases = T.let(aliases, T::Array[Parsers::DelimitedTable::Row])
|
|
94
|
+
@addresses = T.let(addresses, T::Array[Parsers::DelimitedTable::Row])
|
|
95
|
+
@parsed_remarks = T.let(nil, T.nilable(RemarksParser))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The entity, or nil for a row with no name -- which cannot be screened
|
|
99
|
+
# against and is never what OFAC meant to publish.
|
|
100
|
+
sig { returns(T.nilable(Entity)) }
|
|
101
|
+
def entity
|
|
102
|
+
return nil if row.null?(:sdn_name)
|
|
103
|
+
|
|
104
|
+
# `new(**hash)` past required keyword parameters is one of the few
|
|
105
|
+
# things Sorbet cannot check statically. #from_remarks below is the
|
|
106
|
+
# hash, and it carries the two members it names and nothing else.
|
|
107
|
+
T.unsafe(Entity).new(source: source, source_ref: row[:ent_num], type: type,
|
|
108
|
+
names: names, addresses: places, identifiers: identifiers,
|
|
109
|
+
programs: programs, remarks: remarks, **from_remarks)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The members no OFAC column feeds. De-duplicated because one entity's
|
|
113
|
+
# remark can report the same nationality twice -- "nationality Iran;
|
|
114
|
+
# alt. nationality Iran" -- and a record that claims one thing twice
|
|
115
|
+
# is not a record that claims it more strongly.
|
|
116
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
117
|
+
def from_remarks
|
|
118
|
+
{ dates_of_birth: parsed_remarks.dates_of_birth.uniq,
|
|
119
|
+
nationalities: parsed_remarks.nationalities.uniq }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# OFAC's free text, read for the fields it has no columns for. Exposed
|
|
123
|
+
# rather than kept private because the adapter folds every record's
|
|
124
|
+
# into one coverage figure, which is how drift in a heuristic parser
|
|
125
|
+
# gets noticed at all.
|
|
126
|
+
sig { returns(RemarksParser) }
|
|
127
|
+
def parsed_remarks
|
|
128
|
+
@parsed_remarks ||= RemarksParser.new(row[:remarks])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
sig { returns(Symbol) }
|
|
132
|
+
def type
|
|
133
|
+
published = row[:sdn_type]
|
|
134
|
+
return :organization if published.nil?
|
|
135
|
+
|
|
136
|
+
TYPES.fetch(published.downcase, :organization)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# True when OFAC published a type this adapter does not know. Worth
|
|
140
|
+
# surfacing rather than silently absorbing: a new value here means the
|
|
141
|
+
# list grew a category, and everything in it is currently being called
|
|
142
|
+
# an organization.
|
|
143
|
+
sig { returns(T::Boolean) }
|
|
144
|
+
def unknown_type?
|
|
145
|
+
published = row[:sdn_type]
|
|
146
|
+
!published.nil? && !TYPES.key?(published.downcase)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The primary name first, then every alias in the order OFAC filed it.
|
|
150
|
+
# `alt_num` ordering is the closest thing these aliases have to a
|
|
151
|
+
# priority, so it is preserved rather than sorted away. Last come the
|
|
152
|
+
# aliases that appear only inside the remark -- 4,325 names that are in
|
|
153
|
+
# no other column of any of the three files.
|
|
154
|
+
sig { returns(T::Array[Name]) }
|
|
155
|
+
def names
|
|
156
|
+
published = [Name.new(value: row[:sdn_name], kind: :primary)] + alias_names
|
|
157
|
+
published + new_names(published, parsed_remarks.aliases)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
sig { returns(T::Array[Address]) }
|
|
161
|
+
def places
|
|
162
|
+
addresses.filter_map { |address| place(address) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The call sign, then every document number the remark named. Compared
|
|
166
|
+
# through Identifier's own equality, which already treats `AB-123 456`
|
|
167
|
+
# and `ab123456` as one document, so a number OFAC wrote twice does not
|
|
168
|
+
# become two.
|
|
169
|
+
sig { returns(T::Array[Identifier]) }
|
|
170
|
+
def identifiers
|
|
171
|
+
(call_sign + parsed_remarks.identifiers).uniq
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A vessel's call sign is a registered, near-unique string, which makes
|
|
175
|
+
# it far more like a document number than like a name -- and an
|
|
176
|
+
# Identifier is matchable where a line of remarks is not. Filed as
|
|
177
|
+
# :other because it is not any of the document kinds the model names.
|
|
178
|
+
sig { returns(T::Array[Identifier]) }
|
|
179
|
+
def call_sign
|
|
180
|
+
return [] if row.null?(:call_sign)
|
|
181
|
+
|
|
182
|
+
[Identifier.new(kind: :other, value: row[:call_sign], note: "call sign")]
|
|
183
|
+
rescue ArgumentError
|
|
184
|
+
[]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
sig { returns(T::Array[String]) }
|
|
188
|
+
def programs
|
|
189
|
+
return [] if row.null?(:program)
|
|
190
|
+
|
|
191
|
+
T.must(row[:program]).split(PROGRAM_SEPARATOR).map { |program| program.strip.delete("[]") }.reject(&:empty?)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# OFAC's remark verbatim, then the columns that have nowhere else to
|
|
195
|
+
# go, behind the marker that makes them trivial to strip again.
|
|
196
|
+
sig { returns(T.nilable(String)) }
|
|
197
|
+
def remarks
|
|
198
|
+
Remarks.build(row[:remarks], remark_fields)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The label/value pairs appended behind the marker. A subclass reading
|
|
202
|
+
# a list that publishes something more -- which sub-list of the
|
|
203
|
+
# consolidated file a row is on -- prepends to this rather than
|
|
204
|
+
# rewriting #remarks.
|
|
205
|
+
sig { returns(T::Array[T.untyped]) }
|
|
206
|
+
def remark_fields
|
|
207
|
+
COLUMNS_IN_REMARKS.map { |column, label| [label, row[column]] }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
sig { params(published: T::Array[Name], candidates: T::Array[Name]).returns(T::Array[Name]) }
|
|
213
|
+
def new_names(published, candidates)
|
|
214
|
+
seen = published.map { |name| key(name) }
|
|
215
|
+
candidates.reject { |name| seen.include?(key(name)) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
sig { params(name: Name).returns(String) }
|
|
219
|
+
def key(name) = name.value.upcase.gsub(INSIGNIFICANT, "")
|
|
220
|
+
|
|
221
|
+
sig { returns(T::Array[Name]) }
|
|
222
|
+
def alias_names
|
|
223
|
+
aliases.filter_map do |alt|
|
|
224
|
+
next nil if alt.null?(:alt_name)
|
|
225
|
+
|
|
226
|
+
Name.new(value: alt[:alt_name], kind: ALIAS_KINDS.fetch(alt.fetch(:alt_type).to_s.downcase, :aka))
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# ADD.CSV combines city, state, province and postal code into one
|
|
231
|
+
# column, so "London EC3N 1DY" arrives undivided. It is filed under
|
|
232
|
+
# `city` whole rather than split on a guess: a rule that turns
|
|
233
|
+
# "London EC3N 1DY" into a city and a postcode also turns "Dubai" into
|
|
234
|
+
# a city and turns half of Latin America into nonsense.
|
|
235
|
+
# 3,211 of ADD.CSV's 25,078 rows carry an `ent_num` and an `add_num`
|
|
236
|
+
# and then nothing at all -- no street, no city, no country, no
|
|
237
|
+
# remark. They are dropped rather than kept as empty addresses, which
|
|
238
|
+
# is why a full sync yields ~21.9k addresses from ~25.1k rows. An
|
|
239
|
+
# Address that locates nothing cannot be screened on and would only
|
|
240
|
+
# inflate the count.
|
|
241
|
+
sig { params(address: Parsers::DelimitedTable::Row).returns(T.nilable(Address)) }
|
|
242
|
+
def place(address)
|
|
243
|
+
Address.new(street: address[:address], city: address[:city_state_province_postal_code],
|
|
244
|
+
country: address[:country], note: address[:add_remarks])
|
|
245
|
+
rescue ArgumentError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Sources
|
|
8
|
+
class Ofac < Base
|
|
9
|
+
class RemarksParser
|
|
10
|
+
# How much of OFAC's free text the parser understood, accumulated over
|
|
11
|
+
# a whole sync.
|
|
12
|
+
#
|
|
13
|
+
# coverage = source.remarks_coverage
|
|
14
|
+
# coverage.percentage # => 93.4
|
|
15
|
+
# coverage.to_s # => "recognized 82943 of 88827 segments (93.4%), 41216 extracted"
|
|
16
|
+
# coverage.top(3)
|
|
17
|
+
# # => [["Member of the", 615], ["ICTY indictee.", 45], ["all offices worldwide.", 43]]
|
|
18
|
+
#
|
|
19
|
+
# ### Why a number and not a pass/fail
|
|
20
|
+
#
|
|
21
|
+
# RemarksParser is heuristic against text a government writes for
|
|
22
|
+
# people, and OFAC changes how it writes things without telling
|
|
23
|
+
# anybody. The failure that matters is not a crash -- nothing here
|
|
24
|
+
# raises, and an unread segment is still in the remark -- it is the
|
|
25
|
+
# quiet one, where a re-spelled label stops producing passports for six
|
|
26
|
+
# months and no one notices because the import still succeeds.
|
|
27
|
+
#
|
|
28
|
+
# A percentage recorded on every sync makes that visible: it moves when
|
|
29
|
+
# the file's vocabulary moves. #top is what turns the movement into
|
|
30
|
+
# work, since the shapes that suddenly appear in the unrecognized
|
|
31
|
+
# histogram are the new spellings, ranked by how many records they cost.
|
|
32
|
+
#
|
|
33
|
+
# `recognized` counts a segment matched as prose as well as one that
|
|
34
|
+
# produced a value, because "we know this citation carries no fields"
|
|
35
|
+
# and "we have never seen this" are different states and only the
|
|
36
|
+
# second is actionable. `extracted` counts the second kind alone.
|
|
37
|
+
class Coverage
|
|
38
|
+
extend T::Sig
|
|
39
|
+
|
|
40
|
+
# A shape, not a segment: enough leading words to recognize the
|
|
41
|
+
# pattern, with digits masked so that 4,000 distinct tax numbers
|
|
42
|
+
# collapse into one line rather than flooding the histogram.
|
|
43
|
+
SHAPE_WORDS = T.let(3, Integer)
|
|
44
|
+
|
|
45
|
+
sig { returns(Integer) }
|
|
46
|
+
attr_reader :segments
|
|
47
|
+
|
|
48
|
+
sig { returns(Integer) }
|
|
49
|
+
attr_reader :extracted
|
|
50
|
+
|
|
51
|
+
# Extracted plus prose: a citation known to carry no fields is
|
|
52
|
+
# recognized, and only what is neither is actionable.
|
|
53
|
+
sig { returns(Integer) }
|
|
54
|
+
attr_reader :recognized
|
|
55
|
+
|
|
56
|
+
# Unrecognized shapes to how many segments each cost.
|
|
57
|
+
sig { returns(T::Hash[String, Integer]) }
|
|
58
|
+
attr_reader :unknown
|
|
59
|
+
|
|
60
|
+
sig { void }
|
|
61
|
+
def initialize
|
|
62
|
+
@segments = T.let(0, Integer)
|
|
63
|
+
@extracted = T.let(0, Integer)
|
|
64
|
+
@recognized = T.let(0, Integer)
|
|
65
|
+
@unknown = T.let(Hash.new(0), T::Hash[String, Integer])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Folds one parsed remark in. Returns self, so a caller can chain it
|
|
69
|
+
# into a fold over the file.
|
|
70
|
+
sig { params(parsed: RemarksParser).returns(T.self_type) }
|
|
71
|
+
def record(parsed)
|
|
72
|
+
@segments += parsed.segments.size
|
|
73
|
+
@extracted += parsed.extracted.size
|
|
74
|
+
@recognized += parsed.extracted.size + parsed.prose.size
|
|
75
|
+
parsed.unrecognized.each do |segment|
|
|
76
|
+
shape = shape(segment)
|
|
77
|
+
@unknown[shape] = @unknown.fetch(shape, 0) + 1
|
|
78
|
+
end
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 0.0 for an empty run rather than a division by zero: a sync that
|
|
83
|
+
# read no remarks has no coverage to report, and neither perfect nor
|
|
84
|
+
# nil is an honest way to say so.
|
|
85
|
+
sig { returns(Float) }
|
|
86
|
+
def ratio = segments.zero? ? 0.0 : recognized.fdiv(segments)
|
|
87
|
+
|
|
88
|
+
sig { returns(Float) }
|
|
89
|
+
def percentage = (ratio * 100).round(1).to_f
|
|
90
|
+
|
|
91
|
+
# The unrecognized shapes that cost the most segments, which is where
|
|
92
|
+
# a new label shows up first.
|
|
93
|
+
sig { params(count: Integer).returns(T::Array[T.untyped]) }
|
|
94
|
+
def top(count = 10) = unknown.sort_by { |shape, tally| [-tally, shape] }.first(count)
|
|
95
|
+
|
|
96
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
97
|
+
def to_h
|
|
98
|
+
{ segments: segments, extracted: extracted, recognized: recognized,
|
|
99
|
+
ratio: ratio, unknown: unknown.size }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sig { returns(String) }
|
|
103
|
+
def to_s
|
|
104
|
+
"recognized #{recognized} of #{segments} segments (#{percentage}%), #{extracted} extracted"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
sig { returns(String) }
|
|
108
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
sig { params(segment: String).returns(String) }
|
|
113
|
+
def shape(segment)
|
|
114
|
+
-segment.split(/\s+/).first(SHAPE_WORDS).join(" ").gsub(/\d/, "#")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Sources
|
|
8
|
+
class Ofac < Base
|
|
9
|
+
class RemarksParser
|
|
10
|
+
# What OFAC calls things: every label the parser knows how to find at
|
|
11
|
+
# the front of a remark segment, and the patterns compiled from them.
|
|
12
|
+
#
|
|
13
|
+
# Kept apart from the parsing because they change for different
|
|
14
|
+
# reasons. How a segment is read is settled; which words OFAC opens one
|
|
15
|
+
# with is not, and is what a maintainer edits when Coverage#top shows a
|
|
16
|
+
# spelling nobody has seen before. The tail of that list is long and
|
|
17
|
+
# thin -- a dozen country-specific labels appear fewer than 30 times
|
|
18
|
+
# each -- so this table is deliberately the high-volume ones rather
|
|
19
|
+
# than an attempt at all of them.
|
|
20
|
+
module Vocabulary
|
|
21
|
+
extend T::Sig
|
|
22
|
+
|
|
23
|
+
# The labels that map onto a canonical field. `citizen` and
|
|
24
|
+
# `nationality` are one field written two ways, and OFAC uses both.
|
|
25
|
+
FIELDS = T.let({
|
|
26
|
+
"DOB" => :date_of_birth,
|
|
27
|
+
"POB" => :place_of_birth,
|
|
28
|
+
"nationality" => :nationality,
|
|
29
|
+
"citizen" => :nationality,
|
|
30
|
+
"Gender" => :gender,
|
|
31
|
+
"a.k.a." => :aka,
|
|
32
|
+
"f.k.a." => :fka,
|
|
33
|
+
"n.k.a." => :nka
|
|
34
|
+
}.freeze, T::Hash[String, Symbol])
|
|
35
|
+
|
|
36
|
+
# Every document label OFAC writes, grouped by the Identifier kind it
|
|
37
|
+
# means. One kind covers many labels because governments name the
|
|
38
|
+
# same document differently -- an R.F.C. is Mexico's tax number and a
|
|
39
|
+
# NIT is Colombia's -- and a matcher comparing numbers should not
|
|
40
|
+
# have to know whose vocabulary it is holding. The published label is
|
|
41
|
+
# not lost: it becomes the identifier's `note`, because :tax_id is
|
|
42
|
+
# our word for it and "R.F.C." is theirs, and a user justifying a hit
|
|
43
|
+
# needs to see theirs.
|
|
44
|
+
DOCUMENTS = T.let({
|
|
45
|
+
passport: ["Passport", "Diplomatic Passport"],
|
|
46
|
+
national_id: [
|
|
47
|
+
"National ID No.", "Identification Number", "Cedula No.", "C.U.R.P.", "C.U.I.P.",
|
|
48
|
+
"D.N.I.", "C.I.N.", "Driver's License No.", "Birth Certificate Number", "Residency Number"
|
|
49
|
+
],
|
|
50
|
+
tax_id: [
|
|
51
|
+
"Tax ID No.", "R.F.C.", "V.A.T. Number", "NIT", "RUC", "RIF",
|
|
52
|
+
"Unified Social Credit Code (USCC)"
|
|
53
|
+
],
|
|
54
|
+
registration_number: [
|
|
55
|
+
"Registration Number", "Business Registration Number", "Business Registration Document",
|
|
56
|
+
"Company Number", "Commercial Registry Number", "Registration ID", "Government Gazette Number",
|
|
57
|
+
"Folio Mercantil No.", "Matricula Mercantil No", "Legal Entity Number", "Chamber of Commerce Number",
|
|
58
|
+
"Trade License No.", "License", "C.R. No.", "Economic Register Number (CBLS)",
|
|
59
|
+
"Central Registration System Number", "D-U-N-S Number", "Enterprise Number", "Business Number",
|
|
60
|
+
"Entity Code", "Public Registration Number"
|
|
61
|
+
],
|
|
62
|
+
# Registered strings that are not documents: a vessel's IMO number,
|
|
63
|
+
# a bank's SWIFT code, an aircraft's serial. :other is a real
|
|
64
|
+
# answer -- a number we cannot classify still matches on its number.
|
|
65
|
+
other: [
|
|
66
|
+
"MMSI", "Vessel Registration Identification IMO", "Vessel Registration Identification",
|
|
67
|
+
"Identification Number IMO", "Company Number IMO", "SWIFT/BIC",
|
|
68
|
+
"Aircraft Manufacturer's Serial Number (MSN)", "Aircraft Tail Number",
|
|
69
|
+
# Published with its own gloss attached, every time, all 52 of them.
|
|
70
|
+
"Aircraft Construction Number (also called L/N or S/N or F/N)", "Aircraft Construction Number"
|
|
71
|
+
]
|
|
72
|
+
}.freeze, T::Hash[Symbol, T::Array[String]])
|
|
73
|
+
|
|
74
|
+
# Shapes that are recognized and carry nothing to extract: statutory
|
|
75
|
+
# citations, relationship notes, contact details, the date a company
|
|
76
|
+
# rather than a person was established. Naming them is what makes the
|
|
77
|
+
# coverage statistic mean something -- without this list the number
|
|
78
|
+
# would sit near half forever and real drift would hide in the noise.
|
|
79
|
+
PROSE = T.let([
|
|
80
|
+
"Secondary sanctions risk", "Additional Sanctions Information", "Linked To",
|
|
81
|
+
"Transactions Prohibited For Persons Owned or Controlled By U.S. Financial Institutions",
|
|
82
|
+
"Organization Established Date", "Organization Type", "Target Type", "Executive Order",
|
|
83
|
+
"CAATSA Section", "For more information", "Website", "Email Address", "Phone Number",
|
|
84
|
+
"Telephone", "Fax", "Vessel Year of Build", "Former Vessel Flag", "Aircraft Manufacture Date",
|
|
85
|
+
"Aircraft Model", "Aircraft Operator"
|
|
86
|
+
].freeze, T::Array[String])
|
|
87
|
+
|
|
88
|
+
# A label is followed by whitespace, a `#` (`NIT # 123`) or a colon,
|
|
89
|
+
# and may carry one full stop this table does not spell ("Matricula
|
|
90
|
+
# Mercantil No." against "Matricula Mercantil No"). The lookahead is
|
|
91
|
+
# what stops "Passport" from matching the first word of a longer
|
|
92
|
+
# label, and the longest-first ordering is what stops "Business
|
|
93
|
+
# Registration Number" from being read as a stray word and then
|
|
94
|
+
# "Registration Number".
|
|
95
|
+
#
|
|
96
|
+
# The alternation is escaped and joined by hand rather than built
|
|
97
|
+
# with Regexp.union, which embeds its own `(?-mix:...)` and would
|
|
98
|
+
# switch case-insensitivity back off for the labels inside it.
|
|
99
|
+
sig { params(labels: T::Array[String], tail: String).returns(Regexp) }
|
|
100
|
+
def self.pattern(labels, tail)
|
|
101
|
+
alternation = labels.sort_by { |label| -label.length }.map { |label| Regexp.escape(label) }.join("|")
|
|
102
|
+
/\A(?<label>#{alternation})\.?(?=[\s#:]|\z)[\s#:]*#{tail}\z/i
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
DOCUMENT_KINDS = T.let(
|
|
106
|
+
DOCUMENTS.each_with_object({}) do |(kind, labels), lookup|
|
|
107
|
+
labels.each { |label| lookup[label.downcase] = kind }
|
|
108
|
+
end.freeze,
|
|
109
|
+
T::Hash[String, Symbol]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
FIELD_KINDS = T.let(FIELDS.transform_keys(&:downcase).freeze, T::Hash[String, Symbol])
|
|
113
|
+
|
|
114
|
+
FIELD_PATTERN = T.let(pattern(FIELDS.keys, "(?<value>.*)"), Regexp)
|
|
115
|
+
DOCUMENT_PATTERN = T.let(pattern(DOCUMENTS.values.flatten, "(?<rest>.*)"), Regexp)
|
|
116
|
+
PROSE_PATTERN = T.let(pattern(PROSE, ".*"), Regexp)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|