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,265 @@
|
|
|
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 "config"
|
|
11
|
+
require_relative "section_slicer"
|
|
12
|
+
require_relative "sdi_section_splitter"
|
|
13
|
+
require_relative "sdi_card_parser"
|
|
14
|
+
require_relative "ri_list_parser"
|
|
15
|
+
|
|
16
|
+
module GovCodes
|
|
17
|
+
module Dafecd
|
|
18
|
+
module RiSdi
|
|
19
|
+
# Assembles the RI and SDI records of one publication into a single
|
|
20
|
+
# code-keyed index and surfaces the reconciliation data the CLI reports.
|
|
21
|
+
#
|
|
22
|
+
# Every section named by the Config is sliced out and parsed by the format
|
|
23
|
+
# it uses: SDI sections by the SdiSectionSplitter + SdiCardParser (plus the
|
|
24
|
+
# one embedded CEM ladder record, parsed by the AFSC SpecialtyParser and
|
|
25
|
+
# keyed by its CEM code), RI sections by the RiListParser.
|
|
26
|
+
#
|
|
27
|
+
# Verification gate: every emitted code must appear verbatim in the source,
|
|
28
|
+
# and every emitted acronym must appear as a "(ACRONYM)" parenthetical in
|
|
29
|
+
# its record's raw title (whitespace/dash/case tolerant). Titles are
|
|
30
|
+
# extracted verbatim (only sanitized) and then de-glued via human-verified
|
|
31
|
+
# overrides supplied by an injected TitleDegluer: each applied override is
|
|
32
|
+
# gated against the raw source title (spacing/case only), so drift lands in
|
|
33
|
+
# #unverified_titles and fails the build; a code with no override keeps its
|
|
34
|
+
# verbatim title and is reported in #codes_needing_deglue (not an error).
|
|
35
|
+
# #unverified? must be false before anything is written.
|
|
36
|
+
#
|
|
37
|
+
# Acronyms are captured from a title's trailing parenthetical, EXCEPT for
|
|
38
|
+
# the per-publication Config#acronym_exclusions (organization/sub-phrase
|
|
39
|
+
# abbreviations). Every candidate — shipped or excluded — is reported via
|
|
40
|
+
# #acronym_candidates for review.
|
|
41
|
+
class IndexBuilder
|
|
42
|
+
# A trailing parenthetical whose sole token is an uppercase abbreviation.
|
|
43
|
+
# Mirrors Dafecd::IndexBuilder::ACRONYM_PATTERN.
|
|
44
|
+
ACRONYM_PATTERN = /\(([A-Z][A-Z0-9]{1,7})\)\s*\z/
|
|
45
|
+
|
|
46
|
+
UNICODE_DASHES = Patterns::UNICODE_DASHES
|
|
47
|
+
|
|
48
|
+
attr_reader :index, :dropped_records, :collisions, :acronym_candidates,
|
|
49
|
+
:unverified_codes, :unverified_titles, :unverified_acronyms,
|
|
50
|
+
:codes_needing_deglue, :section_counts, :section_codes, :sequence_numbers
|
|
51
|
+
|
|
52
|
+
def initialize(full_text, config: Config.dafecd, degluer: TitleDegluer.empty)
|
|
53
|
+
@full_text = full_text
|
|
54
|
+
@config = config
|
|
55
|
+
@degluer = degluer
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build
|
|
59
|
+
@index = {}
|
|
60
|
+
@dropped_records = []
|
|
61
|
+
@collisions = []
|
|
62
|
+
@acronym_candidates = []
|
|
63
|
+
@unverified_codes = []
|
|
64
|
+
@unverified_titles = []
|
|
65
|
+
@unverified_acronyms = []
|
|
66
|
+
@codes_needing_deglue = []
|
|
67
|
+
@section_counts = Hash.new(0)
|
|
68
|
+
@section_codes = Hash.new { |h, k| h[k] = [] }
|
|
69
|
+
@sequence_numbers = Hash.new { |h, k| h[k] = [] }
|
|
70
|
+
|
|
71
|
+
SectionSlicer.new(@full_text, config: @config).sections.each do |section|
|
|
72
|
+
case section.kind
|
|
73
|
+
when :sdi then build_sdi(section)
|
|
74
|
+
when :ri then build_ri(section)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@index.each { |code, entry| apply_override(code, entry) }
|
|
79
|
+
@index.each { |code, entry| capture_acronym(code, entry) }
|
|
80
|
+
@index.each { |code, entry| verify(code, entry) }
|
|
81
|
+
@index
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True if any emitted value failed verification; the CLI must not write.
|
|
85
|
+
def unverified?
|
|
86
|
+
@unverified_codes.any? || @unverified_titles.any? || @unverified_acronyms.any?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Hash{Symbol=>Hash}] force => {present:, missing:, duplicates:}
|
|
90
|
+
def sequence_report
|
|
91
|
+
@sequence_numbers.transform_values do |numbers|
|
|
92
|
+
present = numbers.uniq.sort
|
|
93
|
+
duplicates = numbers.group_by(&:itself).select { |_, v| v.size > 1 }.keys.sort
|
|
94
|
+
missing = present.empty? ? [] : ((present.first..present.last).to_a - present)
|
|
95
|
+
{present: present, missing: missing, duplicates: duplicates}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Array<Hash>] {code:, raw_title:} in code order, for the
|
|
100
|
+
# governed de-glue pass (titles are NOT cleaned here).
|
|
101
|
+
def title_inventory
|
|
102
|
+
@index.sort_by { |code, _| code.to_s }.map do |code, entry|
|
|
103
|
+
{code: code, name: entry[:name], raw_title: entry[:raw_title], glued: entry[:glued_title]}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# --- SDI sections --------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def build_sdi(section)
|
|
112
|
+
SdiSectionSplitter.new(section.text, config: @config).records.each do |record|
|
|
113
|
+
entries = SdiCardParser.new(record, config: @config).entries
|
|
114
|
+
|
|
115
|
+
if entries.empty?
|
|
116
|
+
if record.match?(@config.cem)
|
|
117
|
+
entries = [cem_entry(record)]
|
|
118
|
+
else
|
|
119
|
+
note_sdi_drop(record)
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
entries.each { |entry| add(section, entry) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The single embedded CEM ladder record (Premier Honor Guard, 8G000),
|
|
129
|
+
# parsed by the AFSC pipeline and keyed by its CEM code.
|
|
130
|
+
def cem_entry(record)
|
|
131
|
+
header = SpecialtyParser.new(record, publication: Publication.dafecd).parse
|
|
132
|
+
shredouts = ShredoutParser.new(record, publication: Publication.dafecd).parse
|
|
133
|
+
{
|
|
134
|
+
code: header[:cem_code],
|
|
135
|
+
name: header[:name],
|
|
136
|
+
raw_title: header[:raw_title],
|
|
137
|
+
changed_date: header[:changed_date],
|
|
138
|
+
glued_title: header[:glued_title],
|
|
139
|
+
shredouts: shredouts
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def note_sdi_drop(record)
|
|
144
|
+
# Detect the anchor with the SAME structural regexes the splitter/parser
|
|
145
|
+
# use — a standalone "SDI <code>" line OR an inline "<code>,<title>" line
|
|
146
|
+
# (the latter now tolerates a missing "SDI " prefix), so a future dropped
|
|
147
|
+
# bare-format card is logged rather than silently skipped.
|
|
148
|
+
anchor = record.lines.find do |line|
|
|
149
|
+
line.match?(@config.sdi_anchor) || line.match?(@config.sdi_inline_anchor)
|
|
150
|
+
end
|
|
151
|
+
return unless anchor
|
|
152
|
+
@dropped_records << {
|
|
153
|
+
section: :sdi,
|
|
154
|
+
first_line: anchor.strip,
|
|
155
|
+
reason: "SDI anchor produced no valid entry"
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- RI sections ---------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def build_ri(section)
|
|
162
|
+
RiListParser.new(section.text, config: @config).entries.each do |entry|
|
|
163
|
+
@sequence_numbers[section.force] << entry[:number]
|
|
164
|
+
add(section, entry)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Index assembly ------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def add(section, entry)
|
|
171
|
+
code = entry[:code].to_sym
|
|
172
|
+
key = [section.force, section.kind]
|
|
173
|
+
|
|
174
|
+
if @index.key?(code)
|
|
175
|
+
@collisions << {code: code, section: key, kept: @index[code][:name], discarded: entry[:name]}
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
@index[code] = build_entry(section, entry)
|
|
180
|
+
@section_counts[key] += 1
|
|
181
|
+
@section_codes[key] << code
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_entry(section, entry)
|
|
185
|
+
shredouts = entry[:shredouts] || {}
|
|
186
|
+
{
|
|
187
|
+
force: section.force,
|
|
188
|
+
kind: section.kind,
|
|
189
|
+
name: entry[:name],
|
|
190
|
+
raw_title: entry[:raw_title],
|
|
191
|
+
changed_date: entry[:changed_date],
|
|
192
|
+
glued_title: entry[:glued_title],
|
|
193
|
+
shredouts: shredouts,
|
|
194
|
+
shredout_acronyms: ShredoutAcronyms.from_table(shredouts)
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- Title de-gluing (verified overrides) --------------------------------
|
|
199
|
+
|
|
200
|
+
# Apply a verified de-glued title override to +entry[:name]+, enforcing
|
|
201
|
+
# the same invariant as the AFSC pipeline: an override may differ from the
|
|
202
|
+
# raw source title only in spacing/case (TitleDegluer.matches_source?).
|
|
203
|
+
# Drift (a changed letter/digit/punctuation, or a stale rename) is
|
|
204
|
+
# recorded in #unverified_titles and never silently applied. A code with
|
|
205
|
+
# no override keeps its verbatim (glued) title and is merely reported in
|
|
206
|
+
# #codes_needing_deglue -- unlike the AFSC titles, full override coverage
|
|
207
|
+
# is not required for the build to pass.
|
|
208
|
+
def apply_override(code, entry)
|
|
209
|
+
override = @degluer.override_for(code)
|
|
210
|
+
|
|
211
|
+
if override.nil?
|
|
212
|
+
@codes_needing_deglue << code if entry[:name]
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if TitleDegluer.matches_source?(override, entry[:raw_title])
|
|
217
|
+
entry[:name] = override
|
|
218
|
+
else
|
|
219
|
+
@unverified_titles << {
|
|
220
|
+
code: code,
|
|
221
|
+
applied: override,
|
|
222
|
+
raw_title: entry[:raw_title],
|
|
223
|
+
reason: "override differs from source title by more than spacing/case"
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# --- Acronyms ------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def capture_acronym(code, entry)
|
|
231
|
+
name = entry[:name]
|
|
232
|
+
return if name.nil?
|
|
233
|
+
match = name.match(ACRONYM_PATTERN)
|
|
234
|
+
return unless match
|
|
235
|
+
|
|
236
|
+
acronym = match[1]
|
|
237
|
+
shipped = !@config.acronym_exclusions.include?(code)
|
|
238
|
+
@acronym_candidates << {code: code, acronym: acronym, name: name, shipped: shipped}
|
|
239
|
+
entry[:acronym] = acronym if shipped
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# --- Verification gate ---------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def verify(code, entry)
|
|
245
|
+
@unverified_codes << code.to_s unless @full_text.include?(code.to_s)
|
|
246
|
+
|
|
247
|
+
raw = entry[:raw_title]
|
|
248
|
+
if raw && !norm(@full_text).include?(norm(raw))
|
|
249
|
+
@unverified_titles << {code: code, raw_title: raw}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
acronym = entry[:acronym]
|
|
253
|
+
if acronym && !norm(raw).include?(norm("(#{acronym})"))
|
|
254
|
+
@unverified_acronyms << acronym
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Whitespace-, dash-, and case-insensitive normalization for the gate.
|
|
259
|
+
def norm(str)
|
|
260
|
+
str.to_s.gsub(UNICODE_DASHES, "-").gsub(/\s+/, "").downcase
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patterns"
|
|
4
|
+
require_relative "../shredout_parser"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "change_date"
|
|
7
|
+
require_relative "title"
|
|
8
|
+
|
|
9
|
+
module GovCodes
|
|
10
|
+
module Dafecd
|
|
11
|
+
module RiSdi
|
|
12
|
+
# Parses a flat, numbered Reporting Identifiers (RI) list into per-code
|
|
13
|
+
# entries.
|
|
14
|
+
#
|
|
15
|
+
# Each top-level list item ("<n>. <code>, <title>. <date>") is one record,
|
|
16
|
+
# delimited by the next top-level number. The anchor tolerates the source's
|
|
17
|
+
# pdf-reader glue and decoration: an optional run of spaces (or none) after
|
|
18
|
+
# "<n>.", an optional leading decorative star, a comma OR a bare space
|
|
19
|
+
# before the title, and a list number glued to the code ("34.9T000").
|
|
20
|
+
#
|
|
21
|
+
# The title runs from the code to the FIRST of a change-date parenthetical
|
|
22
|
+
# or a sentence-ending period, and may wrap across physical lines. The
|
|
23
|
+
# officer directory omits the comma and always ends the title at the first
|
|
24
|
+
# period ("90G0 General Officer. Use this identifier ...").
|
|
25
|
+
#
|
|
26
|
+
# Rich records carry sub-paragraphs, sometimes including a
|
|
27
|
+
# "<n>.x Specialty Shredouts:" table, which is parsed by the reused
|
|
28
|
+
# ShredoutParser. Records are returned in document order; #entries also
|
|
29
|
+
# carries each item's :number so callers can run the 1..N completeness check.
|
|
30
|
+
class RiListParser
|
|
31
|
+
DECORATIVE = Patterns::DECORATIVE
|
|
32
|
+
|
|
33
|
+
# A sub-paragraph line ("24.1.", "56.2.1.") — where a record's body begins.
|
|
34
|
+
# Distinct from the anchor line, whose "<n>." is immediately followed by a
|
|
35
|
+
# code (letter in the second position), not another number-and-dot.
|
|
36
|
+
SUBPARAGRAPH = /\A\s*\d+\.\d/
|
|
37
|
+
|
|
38
|
+
# The title's trailing change-date parenthetical.
|
|
39
|
+
DATE_PAREN = /\((?:Changed|Established|Effective|Change)\b/
|
|
40
|
+
|
|
41
|
+
# A sentence-ending period: a "." followed by whitespace or end of string.
|
|
42
|
+
SENTENCE_PERIOD = /\.(?=\s|\z)/
|
|
43
|
+
|
|
44
|
+
def initialize(text, config: Config.dafecd)
|
|
45
|
+
@config = config
|
|
46
|
+
@lines = text.lines.reject { |line| line =~ config.header }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<Hash>] one entry per RI code, in document order
|
|
50
|
+
def entries
|
|
51
|
+
records.filter_map { |record| parse(record) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Split the section into records at each top-level anchor line.
|
|
57
|
+
def records
|
|
58
|
+
records = []
|
|
59
|
+
current = nil
|
|
60
|
+
@lines.each do |line|
|
|
61
|
+
if line.match?(@config.ri_anchor)
|
|
62
|
+
current = +""
|
|
63
|
+
records << current
|
|
64
|
+
end
|
|
65
|
+
current << line if current
|
|
66
|
+
end
|
|
67
|
+
records
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse(record)
|
|
71
|
+
lines = record.lines
|
|
72
|
+
m = lines.first.match(@config.ri_anchor)
|
|
73
|
+
return nil unless m
|
|
74
|
+
|
|
75
|
+
number = m[1].to_i
|
|
76
|
+
code = m[2]
|
|
77
|
+
region = title_region(m[3], lines.drop(1))
|
|
78
|
+
|
|
79
|
+
raw_title = extract_title(region)
|
|
80
|
+
name = raw_title && Title.titleize(raw_title)
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
number: number,
|
|
84
|
+
code: code,
|
|
85
|
+
name: name,
|
|
86
|
+
raw_title: raw_title,
|
|
87
|
+
changed_date: ChangeDate.extract(region),
|
|
88
|
+
glued_title: Title.glued?(name),
|
|
89
|
+
shredouts: shredouts(record)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The joined header region: the anchor line's title remainder plus every
|
|
94
|
+
# continuation line up to the first sub-paragraph, sanitized and with page
|
|
95
|
+
# numbers dropped. This holds the title and its trailing date.
|
|
96
|
+
def title_region(anchor_rest, rest_lines)
|
|
97
|
+
parts = [Title.sanitize(anchor_rest)]
|
|
98
|
+
rest_lines.each do |line|
|
|
99
|
+
break if line.match?(SUBPARAGRAPH)
|
|
100
|
+
stripped = Title.sanitize(line)
|
|
101
|
+
next if stripped.empty?
|
|
102
|
+
next if stripped.match?(/\A\d+\z/) # bare page number
|
|
103
|
+
parts << stripped
|
|
104
|
+
end
|
|
105
|
+
parts.reject(&:empty?).join(" ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The title: the region up to the first date parenthetical or sentence
|
|
109
|
+
# period, whichever comes first; the whole region when neither is present
|
|
110
|
+
# (e.g. "DAFWorld Class Athlete Program (WCAP)").
|
|
111
|
+
def extract_title(region)
|
|
112
|
+
cut = [region =~ DATE_PAREN, region =~ SENTENCE_PERIOD].compact.min
|
|
113
|
+
title = (cut ? region[0...cut] : region).sub(/[\s.,]+\z/, "").strip
|
|
114
|
+
title.empty? ? nil : title
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def shredouts(record)
|
|
118
|
+
ShredoutParser.new(
|
|
119
|
+
record, publication: @config, pair: ShredoutParser::RI_SDI_PAIR
|
|
120
|
+
).parse
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patterns"
|
|
4
|
+
require_relative "../shredout_parser"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "change_date"
|
|
7
|
+
require_relative "title"
|
|
8
|
+
|
|
9
|
+
module GovCodes
|
|
10
|
+
module Dafecd
|
|
11
|
+
module RiSdi
|
|
12
|
+
# Parses ONE "SDI card" record block into per-code entries.
|
|
13
|
+
#
|
|
14
|
+
# A card block is a run of one or more "SDI <code>" anchor lines followed by
|
|
15
|
+
# a shared body: a title (on its own line, or inline after the code), a
|
|
16
|
+
# change-date annotation, numbered sections, and (occasionally) a
|
|
17
|
+
# "Specialty Shredouts" table. Enlisted codes are 5 chars ("8A200"); officer
|
|
18
|
+
# codes are 4 ("80C0"); the shape is supplied by the injected Config.
|
|
19
|
+
#
|
|
20
|
+
# A multi-code block (e.g. the Air Advisor codes 8L100/8L200/8L300, each
|
|
21
|
+
# with its own inline title but one shared body) yields one entry per code.
|
|
22
|
+
#
|
|
23
|
+
# A wrapped-prose false positive — an "SDI <code>," anchor whose inline
|
|
24
|
+
# "title" is actually the lowercase continuation of a sentence, e.g.
|
|
25
|
+
# "SDI 8P000, completion of a current T5 Investigation ..." — is rejected:
|
|
26
|
+
# its title fails the real-title test, leaving the block with no valid
|
|
27
|
+
# anchor, so #entries returns [].
|
|
28
|
+
class SdiCardParser
|
|
29
|
+
DECORATIVE = Patterns::DECORATIVE
|
|
30
|
+
|
|
31
|
+
Anchor = Struct.new(:code, :inline_title, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# A bare (keyword-less) date parenthetical, e.g. "(31 Oct 24)" — the date
|
|
34
|
+
# annotation on the officer 88X0 card carries no Changed/Established word.
|
|
35
|
+
BARE_DATE = /\(\s*(\d{1,2}\s*[A-Za-z]{3,9}\s*\d{2,4})\s*\)/
|
|
36
|
+
BARE_DATE_LINE = /\A#{BARE_DATE}/
|
|
37
|
+
|
|
38
|
+
# A keyword date annotation ("(Changed ...)"), open paren optional.
|
|
39
|
+
KEYWORD_DATE_LINE = /\A\(?\s*(?:Changed|Established|Effective|Change)\b/
|
|
40
|
+
|
|
41
|
+
def initialize(record, config: Config.dafecd)
|
|
42
|
+
@config = config
|
|
43
|
+
@lines = record.lines.reject { |line| line =~ config.header }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<Hash>] one entry hash per code in the block
|
|
47
|
+
def entries
|
|
48
|
+
anchors = self.anchors
|
|
49
|
+
return [] if anchors.empty?
|
|
50
|
+
|
|
51
|
+
shared = shared_title
|
|
52
|
+
date = changed_date
|
|
53
|
+
shreds = shredouts
|
|
54
|
+
|
|
55
|
+
anchors.map do |anchor|
|
|
56
|
+
raw = anchor.inline_title || shared
|
|
57
|
+
name = raw && Title.titleize(raw)
|
|
58
|
+
{
|
|
59
|
+
code: anchor.code,
|
|
60
|
+
name: name,
|
|
61
|
+
raw_title: raw,
|
|
62
|
+
changed_date: date,
|
|
63
|
+
glued_title: Title.glued?(name),
|
|
64
|
+
shredouts: shreds
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The valid SDI anchors in document order. An inline-title anchor whose
|
|
72
|
+
# title is not a real title is treated as a non-anchor (the false-positive
|
|
73
|
+
# rejection); a standalone "SDI <code>" line is always a valid anchor.
|
|
74
|
+
def anchors
|
|
75
|
+
@lines.filter_map do |line|
|
|
76
|
+
if (m = line.match(@config.sdi_anchor))
|
|
77
|
+
Anchor.new(code: m[1], inline_title: nil)
|
|
78
|
+
elsif (m = line.match(@config.sdi_inline_anchor))
|
|
79
|
+
title = Title.sanitize(m[2])
|
|
80
|
+
Title.real?(title) ? Anchor.new(code: m[1], inline_title: title) : nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Index of the last VALID anchor line, or nil. A false-positive inline
|
|
86
|
+
# anchor in the body ("SDI 8P000, completion of ...") must not be treated
|
|
87
|
+
# as an anchor here, or the standalone card's title lookup would start
|
|
88
|
+
# after it and find nothing.
|
|
89
|
+
def last_anchor_index
|
|
90
|
+
@lines.each_index.reverse_each.find { |i| valid_anchor?(@lines[i]) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def valid_anchor?(line)
|
|
94
|
+
return true if line.match?(@config.sdi_anchor)
|
|
95
|
+
m = line.match(@config.sdi_inline_anchor)
|
|
96
|
+
m && Title.real?(Title.sanitize(m[2]))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The standalone title: title lines between the last anchor and the date /
|
|
100
|
+
# first numbered section, joined and sanitized. nil when the block carries
|
|
101
|
+
# only inline titles (or no title line).
|
|
102
|
+
def shared_title
|
|
103
|
+
last = last_anchor_index
|
|
104
|
+
return nil if last.nil?
|
|
105
|
+
|
|
106
|
+
collected = []
|
|
107
|
+
@lines[(last + 1)..].each do |line|
|
|
108
|
+
stripped = Title.sanitize(line)
|
|
109
|
+
next if stripped.empty?
|
|
110
|
+
next if stripped.match?(/\A\d+\z/) # bare page number
|
|
111
|
+
break if date_line?(stripped)
|
|
112
|
+
break if stripped.match?(/\A\d+\./) # numbered section
|
|
113
|
+
break unless Title.title_line?(stripped)
|
|
114
|
+
collected << stripped
|
|
115
|
+
end
|
|
116
|
+
collected.empty? ? nil : collected.join(" ")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Everything up to the first numbered section, where the change date lives.
|
|
120
|
+
def head_region
|
|
121
|
+
cut = @lines.index { |line| line.match?(/\A\s*\d+\.\s/) }
|
|
122
|
+
(cut ? @lines[0...cut] : @lines).join
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The change date: a keyword annotation, or (officer 88X0) a keyword-less
|
|
126
|
+
# bare date parenthetical in the card's header region.
|
|
127
|
+
def changed_date
|
|
128
|
+
region = head_region
|
|
129
|
+
ChangeDate.extract(region) || bare_date(region)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def bare_date(region)
|
|
133
|
+
m = region.match(BARE_DATE)
|
|
134
|
+
m && ChangeDate.normalize(m[1])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def shredouts
|
|
138
|
+
ShredoutParser.new(
|
|
139
|
+
@lines.join, publication: @config, pair: ShredoutParser::RI_SDI_PAIR
|
|
140
|
+
).parse
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A line that ends title collection: a keyword date annotation OR a bare
|
|
144
|
+
# date parenthetical ("(31 Oct 24)").
|
|
145
|
+
def date_line?(stripped)
|
|
146
|
+
stripped.match?(KEYWORD_DATE_LINE) || stripped.match?(BARE_DATE_LINE)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "title"
|
|
5
|
+
|
|
6
|
+
module GovCodes
|
|
7
|
+
module Dafecd
|
|
8
|
+
module RiSdi
|
|
9
|
+
# Splits a Special Duty Identifiers (SDI) section into one string per record.
|
|
10
|
+
#
|
|
11
|
+
# A record begins at either:
|
|
12
|
+
# * a run of "SDI <code>" card anchors (the first anchor of the run), or
|
|
13
|
+
# * a "CEM Code <code>" line — the single ladder record (Premier Honor
|
|
14
|
+
# Guard, 8G000) embedded in the enlisted AF SDI section. It is split out
|
|
15
|
+
# as its own record so its body (notably its shredout table) can never
|
|
16
|
+
# bleed into the preceding card; the AFSC pipeline parses it downstream.
|
|
17
|
+
#
|
|
18
|
+
# Running page headers are stripped first. Consecutive card anchors stay in
|
|
19
|
+
# one record (the multi-code Air Advisor blocks). A wrapped-prose false
|
|
20
|
+
# positive ("SDI 8P000, completion of a current T5 ...") does NOT start a
|
|
21
|
+
# record — its inline "title" fails the real-title test, so it is left as
|
|
22
|
+
# body of the surrounding record and later rejected by SdiCardParser.
|
|
23
|
+
class SdiSectionSplitter
|
|
24
|
+
def initialize(text, config: Config.dafecd)
|
|
25
|
+
@config = config
|
|
26
|
+
@lines = text.lines.reject { |line| line =~ config.header }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] one string per record
|
|
30
|
+
def records
|
|
31
|
+
records = []
|
|
32
|
+
current = nil
|
|
33
|
+
prev_meaningful_was_anchor = false
|
|
34
|
+
|
|
35
|
+
@lines.each do |line|
|
|
36
|
+
is_card = card_anchor?(line)
|
|
37
|
+
is_cem = line.match?(@config.cem)
|
|
38
|
+
stripped = line.strip
|
|
39
|
+
neutral = stripped.empty? || stripped.match?(/\A\d+\z/)
|
|
40
|
+
|
|
41
|
+
starts_record = is_cem || (is_card && !prev_meaningful_was_anchor)
|
|
42
|
+
|
|
43
|
+
if starts_record
|
|
44
|
+
current = +""
|
|
45
|
+
records << current
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
current << line if current
|
|
49
|
+
|
|
50
|
+
unless neutral
|
|
51
|
+
# A CEM line's ladder ("AFSC 8G091 ...") keeps the run open so the
|
|
52
|
+
# card detector does not re-trigger inside the ladder record.
|
|
53
|
+
prev_meaningful_was_anchor = is_card || is_cem || ladder_line?(line)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
records
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# A card anchor: a standalone "SDI <code>" line, or an inline-title anchor
|
|
63
|
+
# whose title is a real title (rejecting the wrapped-prose false positive).
|
|
64
|
+
def card_anchor?(line)
|
|
65
|
+
return true if line.match?(@config.sdi_anchor)
|
|
66
|
+
m = line.match(@config.sdi_inline_anchor)
|
|
67
|
+
m && Title.real?(Title.sanitize(m[2]))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A ladder line inside the embedded CEM record, e.g. "AFSC 8G091 ...".
|
|
71
|
+
def ladder_line?(line)
|
|
72
|
+
line.match?(/^\s*AFSC\s+\d[A-Z]\d\d\d/)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module GovCodes
|
|
6
|
+
module Dafecd
|
|
7
|
+
module RiSdi
|
|
8
|
+
# Slices the full directory text into the RI/SDI sections named in the
|
|
9
|
+
# injected Config.
|
|
10
|
+
#
|
|
11
|
+
# Each section is located by its header line (e.g.
|
|
12
|
+
# "AIR FORCE REPORTING IDENTIFIERS (RI)"). The header regexes require the
|
|
13
|
+
# "(SDI)"/"(RI)"/"(SFSC)" parenthetical, which the table-of-contents entries
|
|
14
|
+
# lack, so the TOC is never mistaken for a section. A section runs from its
|
|
15
|
+
# header to the next located header. The out-of-scope Space Force Specialty
|
|
16
|
+
# Codes (SFSC) section is used only as a boundary (it terminates the AF RI
|
|
17
|
+
# section) and is dropped from the result.
|
|
18
|
+
class SectionSlicer
|
|
19
|
+
Section = Struct.new(:kind, :force, :text, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
def initialize(text, config: Config.dafecd)
|
|
22
|
+
@text = text
|
|
23
|
+
@config = config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<Section>] located, in-scope sections in document order
|
|
27
|
+
def sections
|
|
28
|
+
located = @config.sections
|
|
29
|
+
.map { |spec| {spec: spec, at: (@text =~ spec[:header])} }
|
|
30
|
+
.reject { |m| m[:at].nil? }
|
|
31
|
+
.sort_by { |m| m[:at] }
|
|
32
|
+
|
|
33
|
+
located.each_with_index.map do |mark, i|
|
|
34
|
+
finish = (i + 1 < located.size) ? located[i + 1][:at] : @text.length
|
|
35
|
+
Section.new(
|
|
36
|
+
kind: mark[:spec][:kind],
|
|
37
|
+
force: mark[:spec][:force],
|
|
38
|
+
text: @text[mark[:at]...finish]
|
|
39
|
+
)
|
|
40
|
+
end.reject { |section| section.kind == :skip }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|