gov_codes 0.1.0 → 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 +15 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +57 -2
- data/README.md +223 -22
- data/Rakefile +7 -1
- data/checksums/gov_codes-0.1.0.gem.sha512 +1 -0
- data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
- data/lib/gov_codes/afsc/enlisted.rb +134 -24
- data/lib/gov_codes/afsc/officer.rb +141 -25
- 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 +207 -0
- data/lib/gov_codes/afsc.rb +35 -3
- 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 +49 -36
- data/lib/gov_codes/version.rb +1 -1
- metadata +36 -4
- data/lib/gov_codes/afsc/enlisted.yml +0 -725
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GovCodes
|
|
4
|
+
module Dafecd
|
|
5
|
+
# Per-publication configuration for the classification-directory extractor.
|
|
6
|
+
#
|
|
7
|
+
# The DAFECD (enlisted) and DAFOCD (officer) directories share the same
|
|
8
|
+
# extraction pipeline (RecordSplitter -> SpecialtyParser -> ShredoutParser ->
|
|
9
|
+
# IndexBuilder) but differ in a handful of concrete details: the running
|
|
10
|
+
# header, the shape of a skill/qualification ladder line, whether standalone
|
|
11
|
+
# single-code records and CEM lines exist, how the X-form specialty key is
|
|
12
|
+
# derived, and where the release artifact is written.
|
|
13
|
+
#
|
|
14
|
+
# A Publication is an immutable value object capturing exactly those
|
|
15
|
+
# differences. It is injected into every pipeline stage. The default
|
|
16
|
+
# everywhere is +Publication.dafecd+, so the enlisted extraction is
|
|
17
|
+
# byte-for-byte unchanged by the parameterization.
|
|
18
|
+
class Publication
|
|
19
|
+
# --- Enlisted (DAFECD) patterns ------------------------------------------
|
|
20
|
+
# Running page header, e.g. "DAFECD, 31 Oct 25".
|
|
21
|
+
DAFECD_HEADER = /^\s*DAFECD,\s+\d/
|
|
22
|
+
|
|
23
|
+
# Skill-ladder line (group 1 = concrete 5-char AFSC, group 2 = skill-level
|
|
24
|
+
# word). Tolerates the DAFECD's pdf-reader quirks (see Patterns::LADDER).
|
|
25
|
+
DAFECD_LADDER = /
|
|
26
|
+
^\s*(?:AFSC\s+)?(\d[A-Z]\d\d\d)\*?
|
|
27
|
+
(?:\s+or\s+\d[A-Z0-9]+)?
|
|
28
|
+
,?\s*
|
|
29
|
+
(?:[A-Za-z][A-Za-z\/]*\s+){0,3}
|
|
30
|
+
(Helper|Apprentice|Journeyman|Craftsman|
|
|
31
|
+
Superintendent|Senior\s+Enlisted(?:\s+Leader)?|Entry)
|
|
32
|
+
(?:\s*\([A-Z]{2,6}\))?
|
|
33
|
+
(?:\s*AFSC)?
|
|
34
|
+
\s*$
|
|
35
|
+
/x
|
|
36
|
+
|
|
37
|
+
# CEM (Chief Enlisted Manager) code line, e.g. "CEM Code 1A100*".
|
|
38
|
+
DAFECD_CEM = /^\s*CEM Code\s+(\d[A-Z]\d00)\*?/
|
|
39
|
+
|
|
40
|
+
# Inserts a boundary before an "AFSC <enlisted-code>" glued to a word.
|
|
41
|
+
DAFECD_GLUED_AFSC = /(?<=[A-Za-z])(?=AFSC \d[A-Z]\d\d\d)/
|
|
42
|
+
|
|
43
|
+
# The DAFECD's per-record change-date annotation.
|
|
44
|
+
DAFECD_CHANGE_DATE = /\((?:Changed|Established|Effective)\s+(\d{1,2}\s+\w{3,9}\s+\d{2,4})\)/
|
|
45
|
+
|
|
46
|
+
# The DAFECD shredout table header ("Suffix ... Primary Aircraft").
|
|
47
|
+
DAFECD_SHREDOUT_HEADER = /Suffix\s+\w/
|
|
48
|
+
|
|
49
|
+
# --- Officer (DAFOCD) patterns -------------------------------------------
|
|
50
|
+
# Running page header, e.g. "DAFOCD, 31 Oct 25".
|
|
51
|
+
DAFOCD_HEADER = /^\s*DAFOCD,\s+\d/
|
|
52
|
+
|
|
53
|
+
# Qualification-ladder line (group 1 = concrete 4-char AFSC, group 2 =
|
|
54
|
+
# free-form qualification title). The officer title vocabulary is open, so
|
|
55
|
+
# the anchor relies on the CODE SHAPE (exactly 4 chars: two digits, a
|
|
56
|
+
# letter, and a level digit 1-4) plus a SHORT title that ENDS the line AND
|
|
57
|
+
# STARTS WITH AN UPPERCASE LETTER. Every real DAFOCD qualification title is
|
|
58
|
+
# a proper label (Entry, Qualified, Aircraft Commander, Staff, ...), so the
|
|
59
|
+
# uppercase-initial requirement rejects wrapped source PROSE that happens to
|
|
60
|
+
# begin "AFSC <4-char-code>, <lowercase words>" (e.g. the real lines
|
|
61
|
+
# "AFSC 14F3, but applied to developing" and "AFSC 42G3, current and
|
|
62
|
+
# continuous"), which otherwise satisfied the code+short-title shape. It
|
|
63
|
+
# also still rejects the 5-char shred-code / long-sentence prose mentions.
|
|
64
|
+
# An optional leading/trailing "AFSC" absorbs the same pdf-reader glue the
|
|
65
|
+
# enlisted ladder handles (e.g. "11G4, StaffAFSC").
|
|
66
|
+
#
|
|
67
|
+
# The directory is INCONSISTENT about three things, all tolerated here:
|
|
68
|
+
# * COMMA (group A): most cards print "AFSC 11B4, Staff", but several live
|
|
69
|
+
# medical cards omit the comma and separate with a space ("AFSC 44B4
|
|
70
|
+
# Staff", "AFSC 45A4* Staff"). Hence the separator is comma-or-space,
|
|
71
|
+
# NOT a bare optional comma: requiring at least one of comma/whitespace
|
|
72
|
+
# is what still rejects glued-shred prose such as "AFSC 42B3Z requires
|
|
73
|
+
# successful completion of" and "AFSC 43E3Aand complete the education",
|
|
74
|
+
# where an uppercase shred letter is glued to the code with no
|
|
75
|
+
# separator before the lowercase prose. A bare ",?" would let those in.
|
|
76
|
+
# * SHRED LETTER (group B): a few cards print the qualification code with
|
|
77
|
+
# a glued shredout letter, e.g. "AFSC 17D4W*, Staff". The optional
|
|
78
|
+
# "[A-Z]" absorbs it so it is NOT baked into the captured concrete code
|
|
79
|
+
# (group 1 stays "17D4"); the letter is documented as a shredout via the
|
|
80
|
+
# card's shredout table instead (see ShredoutParser).
|
|
81
|
+
# * LEADING GLYPH: pdf-reader prepends a Private Use Area / bullet glyph
|
|
82
|
+
# to some ladder lines (e.g. U+F0EA on the 17D card). The leading class
|
|
83
|
+
# mirrors Patterns::DECORATIVE so the anchor tolerates it.
|
|
84
|
+
DAFOCD_LADDER = %r{
|
|
85
|
+
^[\s\u{E000}-\u{F8FF}★☆•●▪■⁃∙]*(?:AFSC\s+)?(\d\d[A-Z][1-4])[A-Z]?\*?
|
|
86
|
+
(?:,\s*|\s+)
|
|
87
|
+
([A-Z][A-Za-z/\ ]{0,44}?)
|
|
88
|
+
(?:\s*AFSC)?
|
|
89
|
+
\s*$
|
|
90
|
+
}x
|
|
91
|
+
|
|
92
|
+
# A standalone single-code officer record, e.g. "AFSC 10C0". These carry a
|
|
93
|
+
# title/date/sections but no ladder; each is a full record keyed by the
|
|
94
|
+
# literal code.
|
|
95
|
+
DAFOCD_BARE_CODE = /^\s*AFSC\s+(\d\d[A-Z][0-9X])\s*$/
|
|
96
|
+
|
|
97
|
+
# Inserts a boundary before an "AFSC <officer-code>" glued to a word.
|
|
98
|
+
DAFOCD_GLUED_AFSC = /(?<=[A-Za-z])(?=AFSC \d\d[A-Z][1-4])/
|
|
99
|
+
|
|
100
|
+
# The DAFOCD's per-record change-date annotation. Tolerant of the officer
|
|
101
|
+
# directory's glued day/month ("30Apr 23") and of a trailing second date
|
|
102
|
+
# ("30Apr 14, Effective 25 Oct 13"): the first annotation is captured.
|
|
103
|
+
DAFOCD_CHANGE_DATE = /\((?:Changed|Established|Effective)\s+(\d{1,2}\s*\w{3,9}\s+\d{2,4})/
|
|
104
|
+
|
|
105
|
+
# The DAFOCD shredout table header ("Suffix ... Portion of AFS to Which
|
|
106
|
+
# Related").
|
|
107
|
+
DAFOCD_SHREDOUT_HEADER = /Suffix\s+\w/
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
# @return [Publication] the enlisted directory configuration
|
|
111
|
+
def dafecd
|
|
112
|
+
@dafecd ||= new(
|
|
113
|
+
id: :dafecd,
|
|
114
|
+
directory_name: "Department of the Air Force Enlisted Classification Directory",
|
|
115
|
+
header: DAFECD_HEADER,
|
|
116
|
+
ladder: DAFECD_LADDER,
|
|
117
|
+
bare_code: nil,
|
|
118
|
+
cem: DAFECD_CEM,
|
|
119
|
+
glued_afsc: DAFECD_GLUED_AFSC,
|
|
120
|
+
change_date: DAFECD_CHANGE_DATE,
|
|
121
|
+
shredout_header: DAFECD_SHREDOUT_HEADER,
|
|
122
|
+
levels_key: :skill_levels,
|
|
123
|
+
code_has_specific: true,
|
|
124
|
+
captures_shredout_acronyms: false,
|
|
125
|
+
acronym_exclusions: %i[1D7X2 1A8X0],
|
|
126
|
+
release_dir: "dafecd",
|
|
127
|
+
index_filename: "enlisted.yml",
|
|
128
|
+
title_overrides_filename: "title_overrides.yml",
|
|
129
|
+
shredout_overrides_filename: "shredout_overrides/dafecd.yml"
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Publication] the officer directory configuration
|
|
134
|
+
def dafocd
|
|
135
|
+
@dafocd ||= new(
|
|
136
|
+
id: :dafocd,
|
|
137
|
+
directory_name: "Department of the Air Force Officer Classification Directory",
|
|
138
|
+
header: DAFOCD_HEADER,
|
|
139
|
+
ladder: DAFOCD_LADDER,
|
|
140
|
+
bare_code: DAFOCD_BARE_CODE,
|
|
141
|
+
cem: nil,
|
|
142
|
+
glued_afsc: DAFOCD_GLUED_AFSC,
|
|
143
|
+
change_date: DAFOCD_CHANGE_DATE,
|
|
144
|
+
shredout_header: DAFOCD_SHREDOUT_HEADER,
|
|
145
|
+
levels_key: :qual_levels,
|
|
146
|
+
code_has_specific: false,
|
|
147
|
+
captures_shredout_acronyms: true,
|
|
148
|
+
acronym_exclusions: [],
|
|
149
|
+
release_dir: "dafocd",
|
|
150
|
+
index_filename: "officer.yml",
|
|
151
|
+
title_overrides_filename: "title_overrides/dafocd.yml",
|
|
152
|
+
shredout_overrides_filename: "shredout_overrides/dafocd.yml"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Select the publication that matches +text+'s running header, defaulting
|
|
157
|
+
# to enlisted.
|
|
158
|
+
def detect(text)
|
|
159
|
+
[dafocd, dafecd].find { |pub| pub.header.match?(text) } || dafecd
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Look a publication up by its id symbol (:dafecd / :dafocd).
|
|
163
|
+
def for(id)
|
|
164
|
+
by_id = {dafecd: dafecd, dafocd: dafocd}
|
|
165
|
+
by_id.fetch(id.to_sym) do
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"unknown publication #{id.inspect}; valid options are #{by_id.keys.map(&:inspect).join(", ")}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
attr_reader :id, :directory_name, :header, :ladder, :bare_code, :cem,
|
|
173
|
+
:glued_afsc, :change_date, :shredout_header, :levels_key,
|
|
174
|
+
:acronym_exclusions, :release_dir, :index_filename,
|
|
175
|
+
:title_overrides_filename, :shredout_overrides_filename
|
|
176
|
+
|
|
177
|
+
def initialize(id:, directory_name:, header:, ladder:, bare_code:, cem:,
|
|
178
|
+
glued_afsc:, change_date:, shredout_header:, levels_key:,
|
|
179
|
+
code_has_specific:, captures_shredout_acronyms:, acronym_exclusions:,
|
|
180
|
+
release_dir:, index_filename:, title_overrides_filename:,
|
|
181
|
+
shredout_overrides_filename:)
|
|
182
|
+
@id = id
|
|
183
|
+
@directory_name = directory_name
|
|
184
|
+
@header = header
|
|
185
|
+
@ladder = ladder
|
|
186
|
+
@bare_code = bare_code
|
|
187
|
+
@cem = cem
|
|
188
|
+
@glued_afsc = glued_afsc
|
|
189
|
+
@change_date = change_date
|
|
190
|
+
@shredout_header = shredout_header
|
|
191
|
+
@levels_key = levels_key
|
|
192
|
+
@code_has_specific = code_has_specific
|
|
193
|
+
@captures_shredout_acronyms = captures_shredout_acronyms
|
|
194
|
+
@acronym_exclusions = acronym_exclusions
|
|
195
|
+
@release_dir = release_dir
|
|
196
|
+
@index_filename = index_filename
|
|
197
|
+
@title_overrides_filename = title_overrides_filename
|
|
198
|
+
@shredout_overrides_filename = shredout_overrides_filename
|
|
199
|
+
freeze
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Whether this publication extracts shredout-level acronyms (officer).
|
|
203
|
+
def captures_shredout_acronyms?
|
|
204
|
+
@captures_shredout_acronyms
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The X-form specialty key for a ladder group's concrete codes.
|
|
208
|
+
# enlisted: 1A172,1A152,... -> :1A1X2 (X at the level digit, specific kept)
|
|
209
|
+
# officer: 11B4,11B3,... -> :11BX (X at the level digit, no specific)
|
|
210
|
+
# Returns nil when no codes are given.
|
|
211
|
+
def specialty_key(codes)
|
|
212
|
+
return nil if codes.empty?
|
|
213
|
+
basis = specialty_basis(codes)
|
|
214
|
+
prefix = basis.first[0, 3]
|
|
215
|
+
@code_has_specific ? :"#{prefix}X#{most_common_specific(basis)}" : :"#{prefix}X"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The career-field key (first two chars of the basis code).
|
|
219
|
+
def career_field(codes)
|
|
220
|
+
return nil if codes.empty?
|
|
221
|
+
:"#{specialty_basis(codes).first[0, 2]}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The absolute path to this publication's title-overrides file.
|
|
225
|
+
def title_overrides_path
|
|
226
|
+
File.expand_path(@title_overrides_filename, __dir__)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The absolute path to this publication's shredout-overrides file.
|
|
230
|
+
def shredout_overrides_path
|
|
231
|
+
File.expand_path(@shredout_overrides_filename, __dir__)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
# The ladder codes that define the specialty. Enlisted: a subdivision
|
|
237
|
+
# superintendent (skill digit 9) carries a specific digit of 0, so the
|
|
238
|
+
# specialty is defined by its non-superintendent levels. Officer: all ladder
|
|
239
|
+
# codes share the same three-char family, so every code is basis.
|
|
240
|
+
def specialty_basis(codes)
|
|
241
|
+
return codes unless @code_has_specific
|
|
242
|
+
non_super = codes.reject { |code| code[3] == "9" }
|
|
243
|
+
non_super.empty? ? codes : non_super
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The most frequent specific (5th) digit among the basis codes (enlisted).
|
|
247
|
+
def most_common_specific(basis)
|
|
248
|
+
basis.map { |code| code[4] }.group_by(&:itself).max_by { |_, v| v.size }.first
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "patterns"
|
|
4
|
+
require_relative "publication"
|
|
5
|
+
require_relative "text"
|
|
6
|
+
|
|
7
|
+
module GovCodes
|
|
8
|
+
# Dev-only tooling that parses an official Department of the Air Force
|
|
9
|
+
# classification directory (DAFECD enlisted / DAFOCD officer) PDF text into
|
|
10
|
+
# structured data.
|
|
11
|
+
#
|
|
12
|
+
# These classes are NEVER required by the gem runtime. They are used offline
|
|
13
|
+
# (via bin/extract_afsc_from_pdf.rb) to regenerate the versioned index when a
|
|
14
|
+
# new directory is released.
|
|
15
|
+
module Dafecd
|
|
16
|
+
# Splits the full directory text into one string per specialty record.
|
|
17
|
+
#
|
|
18
|
+
# A specialty record begins at either:
|
|
19
|
+
# * a "CEM Code <code>" line (enlisted only), or
|
|
20
|
+
# * a bare standalone "AFSC <code>" line (officer single-code records), or
|
|
21
|
+
# * the first ladder line ("AFSC <code>, <title>") of a ladder group that
|
|
22
|
+
# is NOT immediately preceded (ignoring blank lines) by another ladder
|
|
23
|
+
# line, CEM line, or bare-code line.
|
|
24
|
+
#
|
|
25
|
+
# Running page headers ("DAFECD, <date>" / "DAFOCD, <date>") are stripped
|
|
26
|
+
# before splitting. Behavior is publication-specific (injected Publication);
|
|
27
|
+
# the default is the enlisted directory.
|
|
28
|
+
class RecordSplitter
|
|
29
|
+
# A lone title-case word on its own line (e.g. "Leader" left behind when
|
|
30
|
+
# Text.split_glued_afsc splits "LeaderAFSC 1A178"). Such wrapped
|
|
31
|
+
# continuations must not break a ladder group.
|
|
32
|
+
CONTINUATION_WORD = /\A[A-Z][a-z]+\z/
|
|
33
|
+
|
|
34
|
+
def initialize(text, publication: Publication.dafecd)
|
|
35
|
+
@publication = publication
|
|
36
|
+
@text = Text.split_glued_afsc(text, publication.glued_afsc)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<String>] one string per specialty record
|
|
40
|
+
def records
|
|
41
|
+
lines = @text.lines.reject { |line| line =~ @publication.header }
|
|
42
|
+
|
|
43
|
+
records = []
|
|
44
|
+
current = nil
|
|
45
|
+
prev_meaningful_was_anchor = false
|
|
46
|
+
|
|
47
|
+
lines.each do |line|
|
|
48
|
+
is_ladder = line =~ @publication.ladder
|
|
49
|
+
is_cem = @publication.cem && line =~ @publication.cem
|
|
50
|
+
is_bare = @publication.bare_code && line =~ @publication.bare_code
|
|
51
|
+
stripped = line.strip
|
|
52
|
+
# Blank lines and lone continuation words are neutral: they neither
|
|
53
|
+
# start a record nor break a run of ladder/CEM/bare lines.
|
|
54
|
+
neutral = stripped.empty? || stripped.match?(CONTINUATION_WORD)
|
|
55
|
+
|
|
56
|
+
starts_record =
|
|
57
|
+
is_cem ||
|
|
58
|
+
is_bare ||
|
|
59
|
+
(is_ladder && !prev_meaningful_was_anchor)
|
|
60
|
+
|
|
61
|
+
if starts_record
|
|
62
|
+
current = +""
|
|
63
|
+
records << current
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
current << line if current
|
|
67
|
+
|
|
68
|
+
unless neutral
|
|
69
|
+
prev_meaningful_was_anchor = !!(is_ladder || is_cem || is_bare)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
records
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../text"
|
|
4
|
+
|
|
5
|
+
module GovCodes
|
|
6
|
+
module Dafecd
|
|
7
|
+
module RiSdi
|
|
8
|
+
# Extracts the per-record change/effective date from an RI or SDI record's
|
|
9
|
+
# header region.
|
|
10
|
+
#
|
|
11
|
+
# The RI/SDI sections of the DAFECD/DAFOCD use noticeably messier date
|
|
12
|
+
# annotations than the AFSC ladder records the existing
|
|
13
|
+
# Publication#change_date patterns handle. Rather than loosen those shared
|
|
14
|
+
# patterns (which would risk perturbing the already-shipped enlisted/officer
|
|
15
|
+
# artifacts), this module owns a deliberately tolerant capture regex used
|
|
16
|
+
# only on the new content:
|
|
17
|
+
#
|
|
18
|
+
# * a leading extra word before the date
|
|
19
|
+
# "(Change Effective 31 Oct 18)", "(Established Effective 31 Oct 22)"
|
|
20
|
+
# * a long leading clause ending in a lowercase "effective"
|
|
21
|
+
# "(Change to description only effective 11 May 15)"
|
|
22
|
+
# * a glued day/month "(Established 30Apr 25)"
|
|
23
|
+
# * a dual date; the FIRST is captured (matching the officer convention)
|
|
24
|
+
# "(Changed 31 Oct 16, Effective 8 Feb 16)" -> 2016-10-31
|
|
25
|
+
# * a missing/misplaced open paren (pdf glue)
|
|
26
|
+
# "Established 31 Oct 25 )"
|
|
27
|
+
#
|
|
28
|
+
# The captured substring is normalized to ISO 8601 by the shared
|
|
29
|
+
# Text.parse_date (which already tolerates the glued day/month). Returns nil
|
|
30
|
+
# when the given text carries no such annotation.
|
|
31
|
+
module ChangeDate
|
|
32
|
+
# A directory date: "31 Oct 25", "5 June 2013", the glued "30Apr 25", or
|
|
33
|
+
# the fully glued "30Apr20" (officer 81D0). The gap before BOTH the month
|
|
34
|
+
# and the year is optional to absorb pdf-reader's glue.
|
|
35
|
+
DATE = /\d{1,2}\s*[A-Za-z]{3,9}\s*\d{2,4}/
|
|
36
|
+
|
|
37
|
+
# A change-date annotation: a capitalized change keyword, then a lazy run
|
|
38
|
+
# of same-annotation filler (no newline, no close paren), then the first
|
|
39
|
+
# date. The lazy filler is what captures the FIRST date of a dual-date
|
|
40
|
+
# line and absorbs the "Effective"/"effective"/"to description only"
|
|
41
|
+
# bridging words.
|
|
42
|
+
ANNOTATION = /(?:Changed|Established|Effective|Change)\b[^)\n]*?(#{DATE})/
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @param text [String] the record's header region (or any text slice)
|
|
47
|
+
# @return [String, nil] the ISO 8601 change date, or nil when absent
|
|
48
|
+
def extract(text)
|
|
49
|
+
raw = text.to_s[ANNOTATION, 1]
|
|
50
|
+
raw && normalize(raw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Normalize an already-isolated raw date ("31 Oct 24", "30Apr20") to ISO
|
|
54
|
+
# 8601. Re-spaces any letter/digit glue so the shared date normalizer,
|
|
55
|
+
# which needs a gap before the year, can read it. Returns nil when the raw
|
|
56
|
+
# is not a date.
|
|
57
|
+
def normalize(raw)
|
|
58
|
+
Text.parse_date(raw.to_s.gsub(/([A-Za-z])(?=\d)/, '\1 ').gsub(/(\d)(?=[A-Za-z])/, '\1 '))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patterns"
|
|
4
|
+
|
|
5
|
+
module GovCodes
|
|
6
|
+
module Dafecd
|
|
7
|
+
module RiSdi
|
|
8
|
+
# Per-publication configuration for the RI/SDI extractor.
|
|
9
|
+
#
|
|
10
|
+
# The DAFECD (enlisted) and DAFOCD (officer) directories publish their
|
|
11
|
+
# Reporting Identifiers (RI) and Special Duty Identifiers (SDI) in the same
|
|
12
|
+
# two record formats (the SDI "card" and the flat numbered RI list). They
|
|
13
|
+
# differ only in the code SHAPE (enlisted codes are 5 chars, "8A200";
|
|
14
|
+
# officer codes are 4 chars, "80C0"), the running page header, and where the
|
|
15
|
+
# combined artifact is written. A Config captures exactly those differences
|
|
16
|
+
# and is injected into every stage.
|
|
17
|
+
class Config
|
|
18
|
+
# Enlisted RI/SDI code: a digit, a letter, three digits (8A200, 9Z200,
|
|
19
|
+
# 5I000, 5Z700). Space Force RI reuses the same shape.
|
|
20
|
+
ENLISTED_CODE = /\d[A-Z]\d{3}/
|
|
21
|
+
|
|
22
|
+
# Officer RI/SDI code: two digits, a letter, a level digit (80C0, 90G0).
|
|
23
|
+
# The trailing character is a DIGIT — never "X" — which is what rejects
|
|
24
|
+
# the academic CIP codes ("05XX", "14.10XX") that share the officer RI
|
|
25
|
+
# list's page real estate.
|
|
26
|
+
OFFICER_CODE = /\d\d[A-Z]\d/
|
|
27
|
+
|
|
28
|
+
DAFECD_HEADER = /^\s*DAFECD,\s+\d/
|
|
29
|
+
DAFOCD_HEADER = /^\s*DAFOCD,\s+\d/
|
|
30
|
+
|
|
31
|
+
# Both directories head the shredout table with "Suffix ... Portion of
|
|
32
|
+
# <AFS|RI> to Which Related"; the leading "Suffix <word>" is common to
|
|
33
|
+
# every variant, so the existing header regex matches them all.
|
|
34
|
+
SHREDOUT_HEADER = /Suffix\s+\w/
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def dafecd
|
|
38
|
+
@dafecd ||= new(
|
|
39
|
+
id: :dafecd,
|
|
40
|
+
directory_name: "Department of the Air Force Enlisted Classification Directory",
|
|
41
|
+
code: ENLISTED_CODE,
|
|
42
|
+
header: DAFECD_HEADER,
|
|
43
|
+
release_dir: "dafecd",
|
|
44
|
+
# Trailing parentheticals that abbreviate an embedded organization
|
|
45
|
+
# or a sub-phrase — NOT the identifier's own name — and so must not
|
|
46
|
+
# ship as the record's acronym. See the acronym classification in
|
|
47
|
+
# the extractor report. (SWMS/AFSPECWAR/MEPCOM/etc. are mid-title or
|
|
48
|
+
# length-excluded and never captured, so they need no listing here.)
|
|
49
|
+
acronym_exclusions: %i[9B100 9H100 9M400],
|
|
50
|
+
# AF SDI -> AF RI -> (skip SFSC) -> SF SDI -> SF RI
|
|
51
|
+
sections: [
|
|
52
|
+
{kind: :sdi, force: :af, header: /^\s*SPECIALDUTY IDENTIFIERS \(SDI\)/},
|
|
53
|
+
{kind: :ri, force: :af, header: /^\s*AIR FORCE REPORTING IDENTIFIERS \(RI\)/},
|
|
54
|
+
{kind: :skip, force: :sf, header: /^\s*SPACE FORCE SPECIALTY CODES \(SFSC\)/},
|
|
55
|
+
{kind: :sdi, force: :sf, header: /^\s*SPACE FORCE SPECIAL ?DUTY IDENTIFIERS \(SDI\)/},
|
|
56
|
+
{kind: :ri, force: :sf, header: /^\s*SPACE FORCE REPORTING IDENTIFIERS \(RI\)/}
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dafocd
|
|
62
|
+
@dafocd ||= new(
|
|
63
|
+
id: :dafocd,
|
|
64
|
+
directory_name: "Department of the Air Force Officer Classification Directory",
|
|
65
|
+
code: OFFICER_CODE,
|
|
66
|
+
header: DAFOCD_HEADER,
|
|
67
|
+
release_dir: "dafocd",
|
|
68
|
+
acronym_exclusions: [],
|
|
69
|
+
# Officer SDI -> Officer RI (no AF/SF split in the officer directory)
|
|
70
|
+
sections: [
|
|
71
|
+
{kind: :sdi, force: :officer, header: /^\s*SPECIAL ?DUTY IDENTIFIERS \(SDI\)/},
|
|
72
|
+
{kind: :ri, force: :officer, header: /^\s*REPORTING IDENTIFIERS \(RI\)/}
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def for(id)
|
|
78
|
+
by_id = {dafecd: dafecd, dafocd: dafocd}
|
|
79
|
+
by_id.fetch(id.to_sym) do
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"unknown publication #{id.inspect}; valid options are #{by_id.keys.map(&:inspect).join(", ")}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attr_reader :id, :directory_name, :code, :header, :shredout_header,
|
|
87
|
+
:release_dir, :sections, :index_filename, :acronym_exclusions,
|
|
88
|
+
:title_overrides_filename, :sdi_anchor, :sdi_inline_anchor, :ri_anchor, :cem
|
|
89
|
+
|
|
90
|
+
def initialize(id:, directory_name:, code:, header:, release_dir:, sections:, acronym_exclusions:)
|
|
91
|
+
@id = id
|
|
92
|
+
@directory_name = directory_name
|
|
93
|
+
@code = code
|
|
94
|
+
@header = header
|
|
95
|
+
@shredout_header = SHREDOUT_HEADER
|
|
96
|
+
@release_dir = release_dir
|
|
97
|
+
@sections = sections
|
|
98
|
+
@acronym_exclusions = acronym_exclusions
|
|
99
|
+
@index_filename = "ri.yml"
|
|
100
|
+
# Human-verified de-glued titles, one file per publication, gated at
|
|
101
|
+
# build time against the verbatim source (spacing/case only).
|
|
102
|
+
@title_overrides_filename = "title_overrides/#{id}.yml"
|
|
103
|
+
# A standalone SDI card anchor ("SDI 8A200") and an inline-title anchor
|
|
104
|
+
# ("SDI 8L100,AirAdvisor - Basic"); group 1 is the code, group 2 (inline
|
|
105
|
+
# only) the raw title text. The inline anchor's "SDI " keyword is
|
|
106
|
+
# OPTIONAL: the officer Air Advisor cards (89A0-89I0) print as a bare
|
|
107
|
+
# "CODE,Title" line with no prefix. False positives are still rejected
|
|
108
|
+
# downstream by Title.real? (the inline "title" must start with a
|
|
109
|
+
# capital/digit/paren, never a wrapped-prose lowercase continuation),
|
|
110
|
+
# and the "^" anchor keeps the code at line start so mid-line codes
|
|
111
|
+
# (e.g. "...10-4301V1, Air Advisor Training") never match.
|
|
112
|
+
@sdi_anchor = /^\s*SDI\s+(#{code})\*?\s*$/
|
|
113
|
+
@sdi_inline_anchor = /^\s*(?:SDI\s+)?(#{code})\*?,\s*(\S.*)$/
|
|
114
|
+
# A flat-list RI anchor: a top-level list number, an optional decorative
|
|
115
|
+
# star, then the code. Group 1 is the list number, group 2 the code,
|
|
116
|
+
# group 3 the remainder of the line (title, possibly with a trailing
|
|
117
|
+
# date). Tolerates a run of spaces or none after "<n>.", and a comma or
|
|
118
|
+
# a bare space before the title.
|
|
119
|
+
@ri_anchor =
|
|
120
|
+
/^[ \t]*(\d{1,2})\.[ \t]*(?:#{Patterns::DECORATIVE}[ \t]*)*(#{code})\s*,?\s*(.*)$/
|
|
121
|
+
# The one ladder record embedded in the enlisted AF SDI section
|
|
122
|
+
# ("CEM Code 8G000", Premier Honor Guard) is handled by the AFSC
|
|
123
|
+
# pipeline; the splitter uses this to isolate it from the cards.
|
|
124
|
+
@cem = Patterns::CEM
|
|
125
|
+
freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The absolute path to this publication's RI/SDI title-overrides file.
|
|
129
|
+
# Mirrors Publication#title_overrides_path so the shared TitleDegluer
|
|
130
|
+
# (its .for/#override_for/.matches_source? API) loads from a Config too.
|
|
131
|
+
def title_overrides_path
|
|
132
|
+
File.expand_path(@title_overrides_filename, __dir__)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|