gov_codes 0.1.1 → 0.1.2
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 +4 -4
- data/.simplecov +13 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +31 -26
- data/README.md +180 -25
- data/Rakefile +7 -2
- data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
- data/lib/gov_codes/afsc/enlisted.rb +111 -80
- data/lib/gov_codes/afsc/officer.rb +121 -65
- data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml +2726 -0
- data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/ri.yml +369 -0
- data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/officer.yml +2393 -0
- data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/ri.yml +193 -0
- data/lib/gov_codes/afsc/releases.rb +219 -0
- data/lib/gov_codes/afsc/releases.yml +11 -0
- data/lib/gov_codes/afsc/ri.rb +161 -126
- data/lib/gov_codes/afsc.rb +29 -8
- data/lib/gov_codes/dafecd/index_builder.rb +416 -0
- data/lib/gov_codes/dafecd/patterns.rb +50 -0
- data/lib/gov_codes/dafecd/publication.rb +252 -0
- data/lib/gov_codes/dafecd/record_splitter.rb +77 -0
- data/lib/gov_codes/dafecd/ri_sdi/change_date.rb +63 -0
- data/lib/gov_codes/dafecd/ri_sdi/config.rb +137 -0
- data/lib/gov_codes/dafecd/ri_sdi/index_builder.rb +265 -0
- data/lib/gov_codes/dafecd/ri_sdi/ri_list_parser.rb +125 -0
- data/lib/gov_codes/dafecd/ri_sdi/sdi_card_parser.rb +151 -0
- data/lib/gov_codes/dafecd/ri_sdi/sdi_section_splitter.rb +77 -0
- data/lib/gov_codes/dafecd/ri_sdi/section_slicer.rb +45 -0
- data/lib/gov_codes/dafecd/ri_sdi/title.rb +76 -0
- data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafecd.yml +124 -0
- data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafocd.yml +78 -0
- data/lib/gov_codes/dafecd/shredout_acronyms.rb +47 -0
- data/lib/gov_codes/dafecd/shredout_degluer.rb +73 -0
- data/lib/gov_codes/dafecd/shredout_overrides/dafecd.yml +27 -0
- data/lib/gov_codes/dafecd/shredout_overrides/dafocd.yml +27 -0
- data/lib/gov_codes/dafecd/shredout_parser.rb +75 -0
- data/lib/gov_codes/dafecd/specialty_parser.rb +200 -0
- data/lib/gov_codes/dafecd/text.rb +48 -0
- data/lib/gov_codes/dafecd/title_degluer.rb +67 -0
- data/lib/gov_codes/dafecd/title_overrides/dafocd.yml +141 -0
- data/lib/gov_codes/dafecd/title_overrides.yml +142 -0
- data/lib/gov_codes/data_loader.rb +19 -0
- data/lib/gov_codes/version.rb +1 -1
- metadata +34 -6
- data/lib/gov_codes/afsc/enlisted.yml +0 -532
- data/lib/gov_codes/afsc/officer.yml +0 -1072
- data/lib/gov_codes/afsc/ri.yml +0 -237
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "patterns"
|
|
4
|
+
require_relative "publication"
|
|
5
|
+
require_relative "record_splitter"
|
|
6
|
+
require_relative "specialty_parser"
|
|
7
|
+
require_relative "shredout_parser"
|
|
8
|
+
require_relative "shredout_acronyms"
|
|
9
|
+
require_relative "title_degluer"
|
|
10
|
+
require_relative "shredout_degluer"
|
|
11
|
+
|
|
12
|
+
module GovCodes
|
|
13
|
+
module Dafecd
|
|
14
|
+
# Assembles parsed DAFECD records into a specialty-keyed index.
|
|
15
|
+
#
|
|
16
|
+
# Verification gate (DEC-003) — read this before trusting "0 unverified".
|
|
17
|
+
# The gate confirms that every value emitted into the index appears verbatim
|
|
18
|
+
# in the source text. For the CURRENT purely-deterministic extraction every
|
|
19
|
+
# emitted code is a verbatim slice of the source, so the gate is guaranteed
|
|
20
|
+
# to pass — "0 unverified" is a tautology here, NOT evidence of correctness.
|
|
21
|
+
# The gate earns its keep as a REGRESSION GUARD for future value-transforming
|
|
22
|
+
# steps (notably the C.2 title de-gluer, whose invariant is "the de-glued
|
|
23
|
+
# title equals its source with only spaces changed"). Such a transform
|
|
24
|
+
# registers the values it emits via the #emitted_values_to_verify hook, and
|
|
25
|
+
# the gate flags any that are not grounded in the source.
|
|
26
|
+
#
|
|
27
|
+
# Drops are surfaced, never hidden: any record that carries a specialty
|
|
28
|
+
# signal (a CEM code) but yields zero parsed ladder codes is collected in
|
|
29
|
+
# #dropped_records with a reason. The invariant
|
|
30
|
+
# records_split == index.size + merged_count + dropped_records.size holds.
|
|
31
|
+
#
|
|
32
|
+
# Title de-gluing: a TitleDegluer supplies verified clean titles (see
|
|
33
|
+
# TitleDegluer). Each applied override is checked against the raw source
|
|
34
|
+
# title via the de-gluing invariant (spacing/case only); drift lands in
|
|
35
|
+
# #unverified_titles and must fail the build. Specialties without an override
|
|
36
|
+
# keep the auto-titlecased name and are listed in #specialties_needing_deglue.
|
|
37
|
+
class IndexBuilder
|
|
38
|
+
# A specialty acronym is a trailing parenthetical whose sole token is an
|
|
39
|
+
# uppercase abbreviation, e.g. "... (TACP)" -> TACP. Only trailing parens
|
|
40
|
+
# qualify; a mid-title paren ("... (SERE) Specialist") is prose, not the
|
|
41
|
+
# specialty's acronym.
|
|
42
|
+
ACRONYM_PATTERN = /\(([A-Z][A-Z0-9]{1,7})\)\s*\z/
|
|
43
|
+
|
|
44
|
+
def initialize(source_text, publication: Publication.dafecd, degluer: TitleDegluer.empty,
|
|
45
|
+
shredout_degluer: ShredoutDegluer.empty)
|
|
46
|
+
@source_text = source_text
|
|
47
|
+
@publication = publication
|
|
48
|
+
@degluer = degluer
|
|
49
|
+
@shredout_degluer = shredout_degluer
|
|
50
|
+
@index = nil
|
|
51
|
+
@unverified_codes = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Specialties whose trailing parenthetical is a phrase-abbreviation, not a
|
|
55
|
+
# specialty acronym (per-publication; see Publication#acronym_exclusions).
|
|
56
|
+
def acronym_exclusions
|
|
57
|
+
@publication.acronym_exclusions
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Hash{Symbol=>Hash}] X-form specialty => index entry
|
|
61
|
+
def build
|
|
62
|
+
@index = {}
|
|
63
|
+
@unverified_codes = []
|
|
64
|
+
@unverified_titles = []
|
|
65
|
+
@unverified_acronyms = []
|
|
66
|
+
@unverified_shredouts = []
|
|
67
|
+
@needs_deglue = []
|
|
68
|
+
@dropped_records = []
|
|
69
|
+
@merge_conflicts = []
|
|
70
|
+
@merged_count = 0
|
|
71
|
+
|
|
72
|
+
records = RecordSplitter.new(@source_text, publication: @publication).records
|
|
73
|
+
@records_split = records.size
|
|
74
|
+
|
|
75
|
+
records.each do |record|
|
|
76
|
+
header = SpecialtyParser.new(record, publication: @publication).parse
|
|
77
|
+
specialty = header[:specialty]
|
|
78
|
+
|
|
79
|
+
if specialty.nil?
|
|
80
|
+
@dropped_records << drop_descriptor(record)
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
shredouts = ShredoutParser.new(record, publication: @publication).parse
|
|
85
|
+
entry = build_entry(header, shredouts, record)
|
|
86
|
+
@merged_count += 1 if @index.key?(specialty)
|
|
87
|
+
merge_entry(specialty, entry)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@index.each do |specialty, entry|
|
|
91
|
+
apply_override(specialty, entry)
|
|
92
|
+
capture_acronym(specialty, entry)
|
|
93
|
+
verify(entry)
|
|
94
|
+
end
|
|
95
|
+
apply_shredout_overrides
|
|
96
|
+
@index
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Values emitted into the index that could NOT be found verbatim in the
|
|
100
|
+
# source. Empty by construction for the current deterministic extraction;
|
|
101
|
+
# non-empty means a transform (e.g. the de-gluer) emitted an ungrounded
|
|
102
|
+
# value. See the class docstring.
|
|
103
|
+
def unverified_codes
|
|
104
|
+
build if @unverified_codes.nil?
|
|
105
|
+
@unverified_codes
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Applied title overrides that violate the de-gluing invariant (their
|
|
109
|
+
# letters/digits/punctuation differ from the raw source title), each a
|
|
110
|
+
# Hash of {specialty:, applied:, raw_title:, reason:}. Non-empty means a
|
|
111
|
+
# stale/incorrect override; the build must fail. See #unverified?.
|
|
112
|
+
def unverified_titles
|
|
113
|
+
build if @unverified_titles.nil?
|
|
114
|
+
@unverified_titles
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Specialties that have an auto-titlecased name but no verified override
|
|
118
|
+
# (i.e. still need de-gluing). Should be empty once every specialty is
|
|
119
|
+
# covered; kept for robustness across future entity types.
|
|
120
|
+
def specialties_needing_deglue
|
|
121
|
+
build if @needs_deglue.nil?
|
|
122
|
+
@needs_deglue
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Emitted :acronym values that could NOT be found (whitespace/case
|
|
126
|
+
# tolerant) in their specialty's raw source title. Non-empty means a
|
|
127
|
+
# drifting/hallucinated acronym; the build must fail. See #unverified?.
|
|
128
|
+
def unverified_acronyms
|
|
129
|
+
build if @unverified_acronyms.nil?
|
|
130
|
+
@unverified_acronyms
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Applied shredout-value overrides that violate the de-gluing invariant
|
|
134
|
+
# (their letters/digits/punctuation differ from the raw extracted value) or
|
|
135
|
+
# target a shredout absent from the source, each a Hash of
|
|
136
|
+
# {specialty:, suffix:, applied:, raw:, reason:}. Non-empty means a
|
|
137
|
+
# stale/incorrect override; the build must fail. See #unverified?.
|
|
138
|
+
def unverified_shredouts
|
|
139
|
+
build if @unverified_shredouts.nil?
|
|
140
|
+
@unverified_shredouts
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# True if any emitted value (code, applied title, acronym, or shredout
|
|
144
|
+
# value) failed verification. The CLI must fail the build when this is true.
|
|
145
|
+
def unverified?
|
|
146
|
+
unverified_codes.any? || unverified_titles.any? || unverified_acronyms.any? ||
|
|
147
|
+
unverified_shredouts.any?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Records that carried a specialty signal but produced no ladder codes,
|
|
151
|
+
# each a Hash of {cem_code:, first_line:, reason:}. Surfaced so drops can
|
|
152
|
+
# never hide (C1).
|
|
153
|
+
def dropped_records
|
|
154
|
+
build if @dropped_records.nil?
|
|
155
|
+
@dropped_records
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Number of records the splitter produced.
|
|
159
|
+
def records_split
|
|
160
|
+
build if @records_split.nil?
|
|
161
|
+
@records_split
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Number of records that merged into an already-seen specialty.
|
|
165
|
+
def merged_count
|
|
166
|
+
build if @merged_count.nil?
|
|
167
|
+
@merged_count
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Merge collisions where an incoming record supplied a DIFFERENT title for
|
|
171
|
+
# a level digit already present, each a Hash of {specialty:, level:, kept:,
|
|
172
|
+
# discarded:, code:}. The existing (first-seen) title always wins; the
|
|
173
|
+
# discarded title is recorded here so the collision is visible in the CLI
|
|
174
|
+
# reconciliation rather than silently dropped (I1 layer b).
|
|
175
|
+
def merge_conflicts
|
|
176
|
+
build if @merge_conflicts.nil?
|
|
177
|
+
@merge_conflicts
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The verification-gate predicate: does +value+ appear verbatim in source?
|
|
181
|
+
def verified?(value)
|
|
182
|
+
@source_text.include?(value.to_s)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Specialties whose title could not be extracted (for the coverage report).
|
|
186
|
+
def specialties_missing_title
|
|
187
|
+
build if @index.nil?
|
|
188
|
+
@index.select { |_, e| e[:name].nil? || e[:name].empty? }.keys
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Specialties with no shredout table (for the coverage report). This is a
|
|
192
|
+
# normal condition for many specialties, not an error.
|
|
193
|
+
def specialties_without_shredouts
|
|
194
|
+
build if @index.nil?
|
|
195
|
+
@index.select { |_, e| e[:shredouts].empty? }.keys
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Specialties whose title is flagged as a probable pdf-reader glue artifact
|
|
199
|
+
# (heuristic), deferred to the C.2 despacer.
|
|
200
|
+
def glued_titles
|
|
201
|
+
build if @index.nil?
|
|
202
|
+
@index.select { |_, e| e[:glued_title] }.transform_values { |e| e[:name] }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Full title inventory for the de-gluing step: every specialty with its
|
|
206
|
+
# current (title-cased) name and the raw pre-titlecase source title.
|
|
207
|
+
# @return [Array<Hash>] {specialty:, name:, raw_title:, glued:}
|
|
208
|
+
def title_inventory
|
|
209
|
+
build if @index.nil?
|
|
210
|
+
@index.sort_by { |k, _| k.to_s }.map do |specialty, e|
|
|
211
|
+
{specialty: specialty, name: e[:name], raw_title: e[:raw_title], glued: e[:glued_title]}
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# Describe a dropped record for the accounting report.
|
|
218
|
+
def drop_descriptor(record)
|
|
219
|
+
cem = @publication.cem && record[@publication.cem, 1]
|
|
220
|
+
first_line = record.lines.map(&:strip).reject(&:empty?).first
|
|
221
|
+
reason =
|
|
222
|
+
if cem
|
|
223
|
+
"CEM code present but no ladder AFSC lines (career-field CEM manager)"
|
|
224
|
+
else
|
|
225
|
+
"no specialty code (no ladder, bare, or CEM AFSC lines)"
|
|
226
|
+
end
|
|
227
|
+
{cem_code: cem, first_line: first_line, reason: reason}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_entry(header, shredouts, record)
|
|
231
|
+
entry = {
|
|
232
|
+
name: header[:name],
|
|
233
|
+
raw_title: header[:raw_title],
|
|
234
|
+
career_field: header[:career_field],
|
|
235
|
+
cem_code: header[:cem_code],
|
|
236
|
+
bare_code: header[:bare_code],
|
|
237
|
+
changed_date: header[:changed_date],
|
|
238
|
+
shredouts: shredouts,
|
|
239
|
+
glued_title: header[:glued_title]
|
|
240
|
+
}.merge(@publication.levels_key => header[@publication.levels_key])
|
|
241
|
+
attach_shredout_acronyms(entry, header[:specialty], shredouts, record)
|
|
242
|
+
entry
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Attach (and verify) shredout-level acronyms for publications that capture
|
|
246
|
+
# them (officer). Sourced from the shredout table values and the in-record
|
|
247
|
+
# numbered enumeration; kept only when non-empty.
|
|
248
|
+
def attach_shredout_acronyms(entry, specialty, shredouts, record)
|
|
249
|
+
return unless @publication.captures_shredout_acronyms?
|
|
250
|
+
acronyms = ShredoutAcronyms.from_table(shredouts)
|
|
251
|
+
.merge(ShredoutAcronyms.from_enumeration(record, family_of(specialty)))
|
|
252
|
+
return if acronyms.empty?
|
|
253
|
+
verify_shredout_acronyms(acronyms, record)
|
|
254
|
+
entry[:shredout_acronyms] = acronyms
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The 3-char specialty family for enumeration scoping ("19ZX" -> "19Z").
|
|
258
|
+
def family_of(specialty)
|
|
259
|
+
str = specialty.to_s
|
|
260
|
+
str.end_with?("X") ? str.delete_suffix("X") : str
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Merge a re-encountered specialty (e.g. a record split by a page break)
|
|
264
|
+
# rather than silently overwriting it.
|
|
265
|
+
def merge_entry(specialty, entry)
|
|
266
|
+
existing = @index[specialty]
|
|
267
|
+
if existing.nil?
|
|
268
|
+
@index[specialty] = entry
|
|
269
|
+
return
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
levels_key = @publication.levels_key
|
|
273
|
+
existing[:name] ||= entry[:name]
|
|
274
|
+
existing[:raw_title] ||= entry[:raw_title]
|
|
275
|
+
existing[:career_field] ||= entry[:career_field]
|
|
276
|
+
existing[:cem_code] ||= entry[:cem_code]
|
|
277
|
+
existing[:bare_code] ||= entry[:bare_code]
|
|
278
|
+
existing[:changed_date] ||= entry[:changed_date]
|
|
279
|
+
record_level_conflicts(specialty, existing[levels_key], entry[levels_key])
|
|
280
|
+
existing[levels_key] = entry[levels_key].merge(existing[levels_key])
|
|
281
|
+
existing[:shredouts] = entry[:shredouts].merge(existing[:shredouts])
|
|
282
|
+
if entry[:shredout_acronyms]
|
|
283
|
+
existing[:shredout_acronyms] = entry[:shredout_acronyms].merge(existing[:shredout_acronyms] || {})
|
|
284
|
+
end
|
|
285
|
+
existing[:glued_title] ||= entry[:glued_title]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Record any level digit the incoming record re-titles differently from the
|
|
289
|
+
# already-present record. The existing title is kept (the merge below is
|
|
290
|
+
# existing-wins); the disagreement is surfaced so a re-titling can never
|
|
291
|
+
# pass silently (I1 layer b).
|
|
292
|
+
def record_level_conflicts(specialty, existing_levels, incoming_levels)
|
|
293
|
+
incoming_levels.each do |digit, incoming|
|
|
294
|
+
current = existing_levels[digit]
|
|
295
|
+
next if current.nil? || current[:title] == incoming[:title]
|
|
296
|
+
@merge_conflicts << {
|
|
297
|
+
specialty: specialty,
|
|
298
|
+
level: digit,
|
|
299
|
+
kept: current[:title],
|
|
300
|
+
discarded: incoming[:title],
|
|
301
|
+
code: incoming[:code]
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Apply a verified de-glued title override, enforcing the invariant that an
|
|
307
|
+
# override differs from the raw source title only in spacing/case. Drift is
|
|
308
|
+
# recorded (never silently applied); a missing override keeps the auto
|
|
309
|
+
# title and is flagged for de-gluing.
|
|
310
|
+
def apply_override(specialty, entry)
|
|
311
|
+
override = @degluer.override_for(specialty)
|
|
312
|
+
|
|
313
|
+
if override.nil?
|
|
314
|
+
@needs_deglue << specialty if entry[:name]
|
|
315
|
+
return
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
if TitleDegluer.matches_source?(override, entry[:raw_title])
|
|
319
|
+
entry[:name] = override
|
|
320
|
+
else
|
|
321
|
+
@unverified_titles << {
|
|
322
|
+
specialty: specialty,
|
|
323
|
+
applied: override,
|
|
324
|
+
raw_title: entry[:raw_title],
|
|
325
|
+
reason: "override differs from source title by more than spacing/case"
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Apply verified de-glued shredout VALUES, enforcing the same invariant as
|
|
331
|
+
# titles: an override may differ from the raw extracted value only in
|
|
332
|
+
# spacing/case. Every declared override is checked (not just the applied
|
|
333
|
+
# ones), so an override targeting a specialty or suffix absent from the
|
|
334
|
+
# source is surfaced as unverified rather than silently ignored. Shredout
|
|
335
|
+
# acronyms were already lifted from the raw values (during build_entry), so
|
|
336
|
+
# de-gluing the value here leaves them untouched.
|
|
337
|
+
def apply_shredout_overrides
|
|
338
|
+
@shredout_degluer.each_override do |specialty, suffix, clean|
|
|
339
|
+
entry = @index[specialty]
|
|
340
|
+
raw = entry && entry[:shredouts][suffix]
|
|
341
|
+
|
|
342
|
+
if raw.nil?
|
|
343
|
+
@unverified_shredouts << {
|
|
344
|
+
specialty: specialty, suffix: suffix, applied: clean, raw: raw,
|
|
345
|
+
reason: entry.nil? ?
|
|
346
|
+
"override targets a specialty absent from the index" :
|
|
347
|
+
"override targets a shredout suffix absent from the source"
|
|
348
|
+
}
|
|
349
|
+
elsif TitleDegluer.matches_source?(clean, raw)
|
|
350
|
+
entry[:shredouts][suffix] = clean
|
|
351
|
+
else
|
|
352
|
+
@unverified_shredouts << {
|
|
353
|
+
specialty: specialty, suffix: suffix, applied: clean, raw: raw,
|
|
354
|
+
reason: "override differs from source shredout by more than spacing/case"
|
|
355
|
+
}
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Capture the specialty acronym from the (de-glued) name: a trailing
|
|
361
|
+
# parenthetical uppercase token. Excluded specialties (phrase
|
|
362
|
+
# abbreviations, not specialty acronyms) never receive an :acronym. The
|
|
363
|
+
# name is left unchanged; it retains the parenthetical.
|
|
364
|
+
def capture_acronym(specialty, entry)
|
|
365
|
+
return if acronym_exclusions.include?(specialty)
|
|
366
|
+
name = entry[:name]
|
|
367
|
+
return if name.nil?
|
|
368
|
+
|
|
369
|
+
match = name.match(ACRONYM_PATTERN)
|
|
370
|
+
entry[:acronym] = match[1] if match
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def verify(entry)
|
|
374
|
+
entry[@publication.levels_key].each_value do |level|
|
|
375
|
+
@unverified_codes << level[:code] unless verified?(level[:code])
|
|
376
|
+
end
|
|
377
|
+
cem = entry[:cem_code]
|
|
378
|
+
@unverified_codes << cem if cem && !verified?(cem)
|
|
379
|
+
bare = entry[:bare_code]
|
|
380
|
+
@unverified_codes << bare if bare && !verified?(bare)
|
|
381
|
+
emitted_values_to_verify(entry).each do |value|
|
|
382
|
+
@unverified_codes << value unless verified?(value)
|
|
383
|
+
end
|
|
384
|
+
verify_acronym(entry)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# The acronym gate (DEC-003): an emitted :acronym must appear as a
|
|
388
|
+
# parenthetical `(ACRONYM)` in the specialty's raw source title -- not
|
|
389
|
+
# merely as some substring of it -- under the same whitespace/case
|
|
390
|
+
# tolerance the de-glue gate uses. A drifting/absent acronym is unverified.
|
|
391
|
+
def verify_acronym(entry)
|
|
392
|
+
acronym = entry[:acronym]
|
|
393
|
+
return if acronym.nil?
|
|
394
|
+
|
|
395
|
+
source = TitleDegluer.norm(entry[:raw_title])
|
|
396
|
+
@unverified_acronyms << acronym unless source.include?(TitleDegluer.norm("(#{acronym})"))
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Each emitted shredout acronym must appear as a parenthetical `(ACRONYM)`
|
|
400
|
+
# verbatim (whitespace/case tolerant) in the record's own source text.
|
|
401
|
+
def verify_shredout_acronyms(acronyms, record)
|
|
402
|
+
source = TitleDegluer.norm(record)
|
|
403
|
+
acronyms.each_value do |acronym|
|
|
404
|
+
@unverified_acronyms << acronym unless source.include?(TitleDegluer.norm("(#{acronym})"))
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Hook for future value-transforming steps (e.g. the C.2 title de-gluer):
|
|
409
|
+
# return the additional values the transform emits into this entry so the
|
|
410
|
+
# gate can confirm each is grounded in the source. Default: none.
|
|
411
|
+
def emitted_values_to_verify(entry)
|
|
412
|
+
[]
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GovCodes
|
|
4
|
+
module Dafecd
|
|
5
|
+
# Shared line-anchor patterns used by both RecordSplitter (to detect record
|
|
6
|
+
# boundaries) and SpecialtyParser (to extract the ladder). Kept in one place
|
|
7
|
+
# so the two never drift apart.
|
|
8
|
+
module Patterns
|
|
9
|
+
# Skill-ladder line. Captures the concrete 5-char AFSC (group 1) and the
|
|
10
|
+
# skill-level word (group 2). Tolerant of the DAFECD's formatting quirks:
|
|
11
|
+
# * an OPTIONAL leading "AFSC" prefix — pdf-reader sometimes shifts the
|
|
12
|
+
# "AFSC" token to the end of the previous line, leaving a bare code at
|
|
13
|
+
# line start (e.g. "1A194, SuperintendentAFSC"),
|
|
14
|
+
# * an optional TRAILING "AFSC" (that shifted token),
|
|
15
|
+
# * an optional "*" restriction marker on the code,
|
|
16
|
+
# * an optional alternate code, e.g. "3E471 or 3E471A",
|
|
17
|
+
# * an optional comma,
|
|
18
|
+
# * an optional specialty-specific qualifier before the level word,
|
|
19
|
+
# e.g. "Cryptologic Intelligence Superintendent",
|
|
20
|
+
# * a wrapped "Senior Enlisted" (Leader on the following line),
|
|
21
|
+
# * an optional trailing acronym, e.g. "Senior Enlisted Leader (SEL)".
|
|
22
|
+
# The code must be at line start and the level word must END the line;
|
|
23
|
+
# together these reject prose mentions such as "possession ofAFSC 1Z331,
|
|
24
|
+
# which ...". (Prefix-glue like "LeaderAFSC 1A178" is split onto its own
|
|
25
|
+
# line by Text.split_glued_afsc before this pattern is applied.)
|
|
26
|
+
LADDER = /
|
|
27
|
+
^\s*(?:AFSC\s+)?(\d[A-Z]\d\d\d)\*?
|
|
28
|
+
(?:\s+or\s+\d[A-Z0-9]+)?
|
|
29
|
+
,?\s*
|
|
30
|
+
(?:[A-Za-z][A-Za-z\/]*\s+){0,3}
|
|
31
|
+
(Helper|Apprentice|Journeyman|Craftsman|
|
|
32
|
+
Superintendent|Senior\s+Enlisted(?:\s+Leader)?|Entry)
|
|
33
|
+
(?:\s*\([A-Z]{2,6}\))?
|
|
34
|
+
(?:\s*AFSC)?
|
|
35
|
+
\s*$
|
|
36
|
+
/x
|
|
37
|
+
|
|
38
|
+
# CEM (Chief Enlisted Manager) code line, e.g. "CEM Code 1A100*".
|
|
39
|
+
CEM = /^\s*CEM Code\s+(\d[A-Z]\d00)\*?/
|
|
40
|
+
|
|
41
|
+
# Decorative glyphs pdf-reader lifts from symbol fonts and scatters through
|
|
42
|
+
# the text: Private Use Area bullets, black/white stars, and common
|
|
43
|
+
# bullets. They are never part of the data and are stripped before parsing.
|
|
44
|
+
DECORATIVE = /[\u{E000}-\u{F8FF}★☆•●▪■⁃∙]/
|
|
45
|
+
|
|
46
|
+
# Unicode dashes the directory uses, normalized to ASCII "-".
|
|
47
|
+
UNICODE_DASHES = /[‐-―−]/
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|