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,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patterns"
|
|
4
|
+
|
|
5
|
+
module GovCodes
|
|
6
|
+
module Dafecd
|
|
7
|
+
module RiSdi
|
|
8
|
+
# Conservative title normalization for RI/SDI records.
|
|
9
|
+
#
|
|
10
|
+
# These helpers mirror the AFSC SpecialtyParser's title handling exactly
|
|
11
|
+
# (sanitize -> title-case verbatim, flag probable pdf glue) but live in
|
|
12
|
+
# their own module so the RI/SDI extraction shares NONE of the AFSC ladder
|
|
13
|
+
# pipeline's mutable state — the already-shipped enlisted/officer artifacts
|
|
14
|
+
# cannot be perturbed by anything done here.
|
|
15
|
+
#
|
|
16
|
+
# As in the AFSC pipeline, no de-gluing is attempted: a run-together title
|
|
17
|
+
# like "ENLISTEDAIDE" becomes "Enlistedaide" and is flagged via #glued? for
|
|
18
|
+
# the deferred, governed de-glue pass. Clean titles are authored out of band.
|
|
19
|
+
module Title
|
|
20
|
+
DECORATIVE = Patterns::DECORATIVE
|
|
21
|
+
UNICODE_DASHES = Patterns::UNICODE_DASHES
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Remove decorative symbol glyphs, normalize unicode dashes, and collapse
|
|
26
|
+
# whitespace runs so a title line reduces to its plain-text content.
|
|
27
|
+
def sanitize(line)
|
|
28
|
+
line.gsub(DECORATIVE, "").gsub(UNICODE_DASHES, "-").gsub(/\s+/, " ").strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A title line is short, starts with a capital / paren / digit, and is not
|
|
32
|
+
# a prose sentence. Accepts ALL-CAPS and Title-Case forms.
|
|
33
|
+
def title_line?(stripped)
|
|
34
|
+
return false if stripped.length > 90
|
|
35
|
+
return false unless stripped.match?(/\A[A-Z0-9(]/)
|
|
36
|
+
return false if stripped.split(/\s+/).size > 12
|
|
37
|
+
return false if stripped.match?(/[a-z]\.\s+[A-Z]/) # mid-line sentence break = prose
|
|
38
|
+
stripped.match?(/[A-Za-z]/)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Conservative title-case: capitalize each alphabetic word, preserving
|
|
42
|
+
# parenthesized acronyms and all-caps-with-digit tokens.
|
|
43
|
+
def titleize(raw)
|
|
44
|
+
raw.split(/\s+/).map { |word| titleize_word(word) }.join(" ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def titleize_word(word)
|
|
48
|
+
return word if word.match?(/\A\([A-Z0-9&\/.-]{2,}\)\z/)
|
|
49
|
+
return word if word.match?(/\A[A-Z]*\d[A-Z0-9]*\z/) && word.match?(/[A-Z0-9]/)
|
|
50
|
+
|
|
51
|
+
word.split(/([\/-])/).map { |seg|
|
|
52
|
+
seg.match?(/[A-Za-z]/) ? seg.capitalize : seg
|
|
53
|
+
}.join
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A real title starts with a capital letter, a digit, or an opening paren
|
|
57
|
+
# — never the lowercase word that begins a wrapped prose sentence (the
|
|
58
|
+
# "SDI 8P000, completion of ..." false positive).
|
|
59
|
+
def real?(title)
|
|
60
|
+
t = title.to_s.strip
|
|
61
|
+
!t.empty? && t.match?(/\A[A-Z0-9(]/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Heuristic proxy for pdf-reader glue: an unusually long single token
|
|
65
|
+
# (excluding preserved acronyms). Reported, not relied upon.
|
|
66
|
+
def glued?(name)
|
|
67
|
+
return false if name.nil?
|
|
68
|
+
name.split(/\s+/).any? do |token|
|
|
69
|
+
bare = token.delete("()/-")
|
|
70
|
+
bare.length >= 12 && bare.match?(/\A[A-Za-z]+\z/)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# De-glued RI/SDI titles for DAFECD, effective 2025-10-31.
|
|
2
|
+
# Each value is the source PDF title with ONLY spacing/case adjusted --
|
|
3
|
+
# no letters, digits, or punctuation changed. Enforced at build time: the
|
|
4
|
+
# IndexBuilder verifies each override against the raw source title
|
|
5
|
+
# (whitespace/case-insensitive equality) and fails the build on drift.
|
|
6
|
+
:5I000: Intelligence, Surveillance & Reconnaissance Superintendent
|
|
7
|
+
:5Z700: Space Operations Senior Enlisted Leader (USSF Only)
|
|
8
|
+
:8A200: Enlisted Aide
|
|
9
|
+
:8A300: Protocol
|
|
10
|
+
:8A400: Talent Management Consultant
|
|
11
|
+
:8B000: Miltary Training Instructor
|
|
12
|
+
:8B100: Military Training Leader
|
|
13
|
+
:8B200: Academy Military Training NCO
|
|
14
|
+
:8B300: Officer Accessions Intructor
|
|
15
|
+
:8B400: Military Training Instructor (Space Force)
|
|
16
|
+
:8B500: Guardian Training Leader
|
|
17
|
+
:8C000: Military and Family Readiness Non-Commissioned Officer (RNCO)
|
|
18
|
+
:8D100: Language & Culture Advisor
|
|
19
|
+
:8F000: First Sergeant
|
|
20
|
+
:8F100: Guardian First Sergeant
|
|
21
|
+
:8G000: Premier Honor Guard
|
|
22
|
+
:8G100: Base Honor Guard Program Manager
|
|
23
|
+
:8G200: Premier Honor Guard (Space Force)
|
|
24
|
+
:8H000: Airmen Dorm Leader
|
|
25
|
+
:8I000: Superintendent, Inspector General
|
|
26
|
+
:8I100: Inspections Coordinator
|
|
27
|
+
:8I200: Complaints & Resolution Coordinator
|
|
28
|
+
:8K000: Software Development Specialist
|
|
29
|
+
:8L100: Air Advisor - Basic
|
|
30
|
+
:8L200: Air Advisor Basic, Team Sergeant
|
|
31
|
+
:8L300: Air Advisor Basic, Team Leader
|
|
32
|
+
:8L400: Air Advisor Advanced
|
|
33
|
+
:8L500: Air Advisor Advanced, Team Sergeant
|
|
34
|
+
:8L600: Air Advisor Advanced, Team Leader
|
|
35
|
+
:8L700: Combat Aviation Advisor
|
|
36
|
+
:8L800: Combat Aviation Advisor Team Sergeant
|
|
37
|
+
:8L900: Combat Aviation Advisor Team Leader
|
|
38
|
+
:8P000: Courier
|
|
39
|
+
:8P100: Defense Attaché
|
|
40
|
+
:8R000: Enlisted Accessions Recruiter
|
|
41
|
+
:8R100: Guardian Talent Scout
|
|
42
|
+
:8R200: Second-Tier Recruiter
|
|
43
|
+
:8R300: Third-Tier Recruiter
|
|
44
|
+
:8S000: Missile Facility Manager
|
|
45
|
+
:8S200: Combat Crew Communications
|
|
46
|
+
:8T000: Professional Military Education Instructor
|
|
47
|
+
:8T100: Enlisted Professional Military Education Instructional System Designer
|
|
48
|
+
:8T200: Development Advisor
|
|
49
|
+
:8T300: Professional Military Education Instructor (Space Force)
|
|
50
|
+
:8U000: Unit Deployment Manager
|
|
51
|
+
:8U100: Weapons of Mass Destruction Civil Support Team (WMD-CST)
|
|
52
|
+
:8W000: Weapons Safety Manager - Wing Level
|
|
53
|
+
:8W100: Weapons Safety Manager
|
|
54
|
+
:8W200: Second-Tier Weapons Safety Manager
|
|
55
|
+
:8W300: Third-Tier Weapons Safety Manager
|
|
56
|
+
:8X100: Operational STEM Specialist
|
|
57
|
+
:8Y000: Pathfinder
|
|
58
|
+
:9A000: Enlisted Airman/Guardian - Disqualified for Reasons Beyond Control
|
|
59
|
+
:9A100: Enlisted Airman/Guardian - Disqualified for Reasons Within Control
|
|
60
|
+
:9A200: Enlisted Airman/Guardian Awaiting Discharge, Separation, or Retirement for
|
|
61
|
+
Reasons Within Their Control
|
|
62
|
+
:9A300: Enlisted Airman/Guardian Awaiting Discharge, Separation, or Retirement for
|
|
63
|
+
Reasons Beyond Their Control
|
|
64
|
+
:9A400: Disqualified Airman/Guardian, Return to Duty Program
|
|
65
|
+
:9A500: Enlisted Airman/Guardian Temporarily Ineligible for Retraining - Disqualified
|
|
66
|
+
for Reasons Beyond Control
|
|
67
|
+
:9B000: Senior Enlisted Advisor to the Chairman of the Joint Chiefs of Staff
|
|
68
|
+
:9B100: Senior Enlisted Advisor to the Chief of the National Guard Bureau (CNGB)
|
|
69
|
+
:9C000: Chief Master Sergeant of the Air Force
|
|
70
|
+
:9C100: Executive Assistant to the Chief Master Sergeant of the Air Force
|
|
71
|
+
:9D100: AF Developmental Senior Enlisted Positions
|
|
72
|
+
:9D200: Key Developmental Senior Enlisted Positions
|
|
73
|
+
:9D300: Wing (Wing level) or Headquarter (HQ level) Senior Enlisted Leader
|
|
74
|
+
:9E000: Command Chief Master Sergeant
|
|
75
|
+
:9E100: Command Chief Executive Assistant
|
|
76
|
+
:9E200: Individual Mobilization Augmentee to Command Chief Master Sergeant
|
|
77
|
+
:9F000: First Term Enlisted Course (FTEC) NCOIC
|
|
78
|
+
:9G100: Group Senior Enlisted Leader
|
|
79
|
+
:9H000: Academic Faculty Instructor
|
|
80
|
+
:9H100: White House Communications Agency Technician (WHCA)
|
|
81
|
+
:9H200: Academic Faculty Instructor (Space Force)
|
|
82
|
+
:9I000: Futures Airmen
|
|
83
|
+
:9J000: Prisoner
|
|
84
|
+
:9L000: Interpreter/Translator
|
|
85
|
+
:9L100: International Affairs Specialist
|
|
86
|
+
:9M000: Military Entrance Processing Command (MEPCOM) Senior Enlisted Advisor
|
|
87
|
+
:9M200: International Health Specialists (IHS)
|
|
88
|
+
:9M400: Chief, Medical Enlisted Force (CMEF), Senior Enlisted Leader and/or Senior
|
|
89
|
+
Enlisted Advisor (SEA)
|
|
90
|
+
:9N000: Secretary of the Air Force Enlisted Legislative Fellows
|
|
91
|
+
:9N100: Secretary of the Air Force Enlisted Legislative Fellows (Space Force)
|
|
92
|
+
:9N200: DAF World Class Athlete Program (WCAP)
|
|
93
|
+
:9P000: Patient
|
|
94
|
+
:9Q000: Reserve Force Generation and Oversight NCO
|
|
95
|
+
:9R000: Civil Air Patrol (CAP)-USAF Reserve Assistance NCO
|
|
96
|
+
:9S000: Chief Master Sergeant of the Space Force
|
|
97
|
+
:9S100: Scientific Applications Specialist
|
|
98
|
+
:9T000: Basic Enlisted Airman
|
|
99
|
+
:9T100: Officer Trainee
|
|
100
|
+
:9T200: Precadet Assignee
|
|
101
|
+
:9T400: AFIT/EWI Enlisted Students
|
|
102
|
+
:9T500: Basic Special Warfare Enlisted Airman
|
|
103
|
+
:9U000: Enlisted Airman/Guardian Ineligible for Local Utilization
|
|
104
|
+
:9U100: Unallotted Enlisted Authorization
|
|
105
|
+
:9V000: Key Developmental Joint Senior Enlisted Position
|
|
106
|
+
:9V100: Executive Assistant to the Senior Enlisted Advisor to the Chairman of the
|
|
107
|
+
Joint Chiefs of Staff
|
|
108
|
+
:9W000: Combat Wounded Warrior
|
|
109
|
+
:9W100: Reserved for Future Use
|
|
110
|
+
:9W200: Combat Wounded Warrior with Exemptions
|
|
111
|
+
:9W300: Non-Combat Wounded Warrior
|
|
112
|
+
:9W400: Wounded Warrior - Limited Assignment Status (LAS)
|
|
113
|
+
:9W500: Reserved for Future Use
|
|
114
|
+
:9W600: Reserved for Future Use
|
|
115
|
+
:9W700: Reserved for Future Use
|
|
116
|
+
:9W800: Wounded Warrior - Ambassador
|
|
117
|
+
:9W900: Wounded Warrior - Project Planner/Officer
|
|
118
|
+
:9Y000: Air Force Parachute Team (AFPT) Instructor
|
|
119
|
+
:9Z000: Special Warfare Mission Support (SWMS) Career Field Manager (CFM) on Headquarters
|
|
120
|
+
Air Force Staff, Air Force Special Warfare Division
|
|
121
|
+
:9Z100: Special Warfare Mission Support (SWMS) Senior Enlisted Leader, Air Force Special
|
|
122
|
+
Warfare (AFSPECWAR)
|
|
123
|
+
:9Z200: Special Warfare Mission Support (SWMS) Superintendent, Air Force Special Warfare
|
|
124
|
+
(AFSPECWAR)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# De-glued RI/SDI titles for DAFOCD, effective 2025-10-31.
|
|
2
|
+
# Each value is the source PDF title with ONLY spacing/case adjusted --
|
|
3
|
+
# no letters, digits, or punctuation changed. Enforced at build time: the
|
|
4
|
+
# IndexBuilder verifies each override against the raw source title
|
|
5
|
+
# (whitespace/case-insensitive equality) and fails the build on drift.
|
|
6
|
+
:80C0: Commander, Cadet Squadron, USAF Academy
|
|
7
|
+
:81C0: Instructor, Officer Training School
|
|
8
|
+
:81D0: Air Force Reserve Officer Training Corps Detachment Commander and Professor
|
|
9
|
+
of Aerospace Studies
|
|
10
|
+
:81L0: Education and Training Leader
|
|
11
|
+
:81T0: Instructor
|
|
12
|
+
:82A0: Academic Program Manager
|
|
13
|
+
:83R0: Recruiting Service
|
|
14
|
+
:84H0: Historian
|
|
15
|
+
:85G0: Honor Guard
|
|
16
|
+
:86M0: Operations Management
|
|
17
|
+
:86P0: Command and Control
|
|
18
|
+
:87G0: Wing Inspector General
|
|
19
|
+
:87I0: Director, Wing Inspections
|
|
20
|
+
:87Q0: Director, Complaints Resolution
|
|
21
|
+
:88A0: Aide-de-camp
|
|
22
|
+
:88B0: Protocol Officer
|
|
23
|
+
:88C0: Sexual Assault Response Coordinator (SARC)
|
|
24
|
+
:88I0: Innovation Officer
|
|
25
|
+
:88M0: United States Air Force Medical Pinnacle Position
|
|
26
|
+
:88X0: Operational STEM Officer
|
|
27
|
+
:88Y0: Pathfinder Officer
|
|
28
|
+
:89A0: Air Advisor (Basic)
|
|
29
|
+
:89B0: Air Advisor (Basic) Team Leader
|
|
30
|
+
:89C0: Air Advisor (Basic) Mission Commander
|
|
31
|
+
:89D0: Air Advisor (Advanced)
|
|
32
|
+
:89E0: Air Advisor (Advanced) Team Leader
|
|
33
|
+
:89F0: Air Advisor (Advanced) Mission Commander
|
|
34
|
+
:89G0: Combat Aviation Advisor
|
|
35
|
+
:89H0: Combat Aviation Advisor Team Leader
|
|
36
|
+
:89I0: Combat Aviation Advisor Mission Commander
|
|
37
|
+
:89W0: Weapons of Mass Destruction Civil Support Team (WMD-CST)
|
|
38
|
+
:90G0: General Officer
|
|
39
|
+
:91C0: Commander
|
|
40
|
+
:91E0: Wing Commander Equivalent
|
|
41
|
+
:91W0: Wing Commander
|
|
42
|
+
:92F0: Foreign Area Officer (FAO) Trainee
|
|
43
|
+
:92J0: Non-designated Lawyer
|
|
44
|
+
:92J1: AFROTC Educational Delay Law Student
|
|
45
|
+
:92J2: Funded Legal Education Program Law Student
|
|
46
|
+
:92J3: Excess Leave Law Student
|
|
47
|
+
:92M0: Health Professions Scholarship Program (HPSP) Medical Student
|
|
48
|
+
:92M1: Uniformed Services University of Health Sciences (USUHS) Student
|
|
49
|
+
:92M2: HPSP Biomedical Science Student
|
|
50
|
+
:92P0: Physician Assistant Student
|
|
51
|
+
:92R0: Chaplain Candidate
|
|
52
|
+
:92S0: Student Officer Authorization
|
|
53
|
+
:92T0: Pilot Trainee
|
|
54
|
+
:92T1: Combat Systems Officer Trainee
|
|
55
|
+
:92T2: Air Battle Manager Trainee
|
|
56
|
+
:92T3: Remotely Piloted Aircraft Pilot Trainee
|
|
57
|
+
:92W0: Combat Wounded Warrior
|
|
58
|
+
:92W1: Reserved for Future Use
|
|
59
|
+
:92W2: Combat Wounded Warrior with Exemptions
|
|
60
|
+
:92W3: Non-Combat Wounded Warrior
|
|
61
|
+
:92W4: Wounded Warrior - Limited Assignment Status (LAS)
|
|
62
|
+
:92W5: Wounded Warrior - Retired/Discharged
|
|
63
|
+
:92W6: Reserved for Future Use
|
|
64
|
+
:92W7: Reserved for Future Use
|
|
65
|
+
:92W8: Wounded Warrior - Ambassador
|
|
66
|
+
:92W9: Wounded Warrior - Project Planner/Officer
|
|
67
|
+
:93P0: Patient
|
|
68
|
+
:94N0: Nuclear Weapons Custodian
|
|
69
|
+
:95A0: Non-EAD USAFR Academy Liaison Officer (ALO) or Civil Air Patrol Reserve Assistance
|
|
70
|
+
Program (CAPRAP) Officer
|
|
71
|
+
:96A0: Disqualified Officer, Reasons Beyond Their Control
|
|
72
|
+
:96B0: Disqualified Officer, Reasons Within Their Control
|
|
73
|
+
:96D0: Officer Not Available for Use in Awarded AFSC for Cause
|
|
74
|
+
:96U0: Unclassified Officer
|
|
75
|
+
:96V0: Un-allotted
|
|
76
|
+
:97E0: Executive Officer
|
|
77
|
+
:99A0: Unspecified AFSC
|
|
78
|
+
:99G0: Gold Bar Recruiter
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GovCodes
|
|
4
|
+
module Dafecd
|
|
5
|
+
# Extracts shredout-level acronyms for the officer directory from two
|
|
6
|
+
# deterministic sources (both grounded verbatim in the record source):
|
|
7
|
+
#
|
|
8
|
+
# a. Shredout TABLE values whose meaning ends in a trailing "(ACR)", e.g.
|
|
9
|
+
# "Air Liaison Officer (ALO)" -> {U: "ALO"}. The :shredouts value is kept
|
|
10
|
+
# verbatim; only the acronym is lifted out.
|
|
11
|
+
#
|
|
12
|
+
# b. A numbered in-record enumeration of the form
|
|
13
|
+
# "<family>X<shred> (<Title> (<ACR>))", e.g. in the 19Z record:
|
|
14
|
+
# "19ZXB (Tactical Air Control Party Officer (TACPO))" -> {B: "TACPO"}.
|
|
15
|
+
# Only codes belonging to the record's own specialty family are accepted.
|
|
16
|
+
module ShredoutAcronyms
|
|
17
|
+
# A shredout value's trailing parenthetical acronym.
|
|
18
|
+
TABLE_ACRONYM = /\(([A-Z][A-Z0-9]{1,7})\)\s*\z/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param shredouts [Hash{Symbol=>String}] parsed suffix => meaning
|
|
23
|
+
# @return [Hash{Symbol=>String}] suffix => trailing acronym
|
|
24
|
+
def from_table(shredouts)
|
|
25
|
+
result = {}
|
|
26
|
+
shredouts.each do |suffix, meaning|
|
|
27
|
+
match = meaning.match(TABLE_ACRONYM)
|
|
28
|
+
result[suffix] = match[1] if match
|
|
29
|
+
end
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param record [String] the specialty record's source text
|
|
34
|
+
# @param family [String] the record's 3-char specialty family (e.g. "19Z")
|
|
35
|
+
# @return [Hash{Symbol=>String}] shred letter => acronym
|
|
36
|
+
def from_enumeration(record, family)
|
|
37
|
+
return {} if family.nil? || family.empty?
|
|
38
|
+
result = {}
|
|
39
|
+
pattern = /#{Regexp.escape(family)}X([A-Z])\s*\([^()]*\(([A-Z][A-Z0-9]{1,7})\)\)/
|
|
40
|
+
record.scan(pattern) do |shred, acronym|
|
|
41
|
+
result[shred.to_sym] = acronym
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module GovCodes
|
|
6
|
+
module Dafecd
|
|
7
|
+
# Applies verified de-glued shredout values.
|
|
8
|
+
#
|
|
9
|
+
# pdf-reader drops spaces inside shredout meanings exactly as it does inside
|
|
10
|
+
# specialty titles ("ImageryAnalyst", "TacticalAir Control Party Officer").
|
|
11
|
+
# The deterministic extractor keeps those verbatim and cannot safely re-insert
|
|
12
|
+
# the missing spaces. The clean values are produced out of band and shipped in
|
|
13
|
+
# per-publication files under shredout_overrides/ (mirroring title_overrides/).
|
|
14
|
+
#
|
|
15
|
+
# The data is nested one level deeper than the title overrides: a specialty
|
|
16
|
+
# maps to a suffix => clean-value map, e.g.
|
|
17
|
+
#
|
|
18
|
+
# :1A1X8:
|
|
19
|
+
# :A: C-32/C-40B/C Flight Attendant
|
|
20
|
+
# :C: C-37A/B Flight Attendant
|
|
21
|
+
#
|
|
22
|
+
# The de-gluing invariant — enforced at build time by IndexBuilder — is that
|
|
23
|
+
# an override may only change SPACING and CASE relative to the raw extracted
|
|
24
|
+
# value; no letter, digit, or punctuation may change. Any drift (or an
|
|
25
|
+
# override targeting a shredout absent from the source) fails the build loudly
|
|
26
|
+
# rather than silently applying a stale value.
|
|
27
|
+
class ShredoutDegluer
|
|
28
|
+
# @return [ShredoutDegluer] loaded from +path+
|
|
29
|
+
def self.load(path)
|
|
30
|
+
overrides = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
|
|
31
|
+
new(overrides)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [ShredoutDegluer] loaded from +publication+'s overrides file, or
|
|
35
|
+
# an empty de-gluer when that file does not exist.
|
|
36
|
+
def self.for(publication)
|
|
37
|
+
path = publication.shredout_overrides_path
|
|
38
|
+
File.exist?(path) ? load(path) : empty
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# An empty de-gluer applies no overrides (keeps the verbatim values).
|
|
42
|
+
def self.empty
|
|
43
|
+
new({})
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param overrides [Hash{Symbol=>Hash{Symbol=>String}}] specialty =>
|
|
47
|
+
# (suffix => clean value)
|
|
48
|
+
def initialize(overrides = {})
|
|
49
|
+
@overrides = overrides
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Hash{Symbol=>String}, nil] suffix => clean value for +specialty+
|
|
53
|
+
def overrides_for(specialty)
|
|
54
|
+
@overrides[specialty]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Yields every declared override so the build can verify each targets a
|
|
58
|
+
# shredout that actually exists (completeness gate).
|
|
59
|
+
def each_override
|
|
60
|
+
return enum_for(:each_override) unless block_given?
|
|
61
|
+
@overrides.each do |specialty, suffixes|
|
|
62
|
+
suffixes.each do |suffix, value|
|
|
63
|
+
yield specialty, suffix, value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def any?
|
|
69
|
+
!@overrides.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# De-glued shredout values for DAFECD, effective 2025-10-31.
|
|
2
|
+
# Each value is the source PDF shredout meaning with ONLY spacing/case
|
|
3
|
+
# adjusted -- no letters, digits, or punctuation changed. Enforced at build
|
|
4
|
+
# time: the IndexBuilder verifies each override against the raw extracted
|
|
5
|
+
# shredout value (whitespace-and-case-insensitive equality) and fails the
|
|
6
|
+
# build on drift or on an override targeting a shredout absent from the source.
|
|
7
|
+
# Nested one level deeper than title_overrides: specialty => (suffix => value).
|
|
8
|
+
:1A1X4:
|
|
9
|
+
:Z: Multi-domain Operations Aviator Data Mask
|
|
10
|
+
:1A1X8:
|
|
11
|
+
:A: C-32/C-40B/C Flight Attendant
|
|
12
|
+
:C: C-37A/B Flight Attendant
|
|
13
|
+
:H: E-4B Flight Attendant
|
|
14
|
+
:1D7X1:
|
|
15
|
+
:B: Systems Administration
|
|
16
|
+
:1N1X1:
|
|
17
|
+
:A: Imagery Analyst
|
|
18
|
+
:1N2X1:
|
|
19
|
+
:A: Electronic Non-Communications Analyst
|
|
20
|
+
:C: Signals Analyst
|
|
21
|
+
:1P0X1:
|
|
22
|
+
:A: Ejection Seat Aircraft
|
|
23
|
+
:B: Non-Ejection Seat Aircraft
|
|
24
|
+
:3F3X1:
|
|
25
|
+
:M: Management Engineering/Data Analytics
|
|
26
|
+
:3P0X1:
|
|
27
|
+
:B: Combat Arms
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# De-glued shredout values for DAFOCD, effective 2025-10-31.
|
|
2
|
+
# Each value is the source PDF shredout meaning with ONLY spacing/case
|
|
3
|
+
# adjusted -- no letters, digits, or punctuation changed. Enforced at build
|
|
4
|
+
# time: the IndexBuilder verifies each override against the raw extracted
|
|
5
|
+
# shredout value (whitespace-and-case-insensitive equality) and fails the
|
|
6
|
+
# build on drift or on an override targeting a shredout absent from the source.
|
|
7
|
+
# Nested one level deeper than title_overrides: specialty => (suffix => value).
|
|
8
|
+
:11UX:
|
|
9
|
+
:K: Instructor for weapon system designated by AFSC
|
|
10
|
+
:V: Automated Functional Applications Analyst
|
|
11
|
+
:13BX:
|
|
12
|
+
:D: Mobile Air Control
|
|
13
|
+
:13SX:
|
|
14
|
+
:E: Space Access and Sustainment
|
|
15
|
+
:15WX:
|
|
16
|
+
:A: Advanced Weather Activities
|
|
17
|
+
:17YX:
|
|
18
|
+
:A: Cyber Warfare Analyst
|
|
19
|
+
:D: Cyber Threat Defense Analyst
|
|
20
|
+
:O: Cyber Attack Operator
|
|
21
|
+
:19ZX:
|
|
22
|
+
:B: Tactical Air Control Party Officer
|
|
23
|
+
:44KX:
|
|
24
|
+
:N: Child Abuse
|
|
25
|
+
:45BX:
|
|
26
|
+
:G: Replacement Arthroplasty
|
|
27
|
+
:J: Foot and Ankle Surgery
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "patterns"
|
|
4
|
+
require_relative "publication"
|
|
5
|
+
|
|
6
|
+
module GovCodes
|
|
7
|
+
module Dafecd
|
|
8
|
+
# Parses a "Specialty Shredouts" table into a suffix => name map.
|
|
9
|
+
#
|
|
10
|
+
# The table is laid out in two side-by-side columns, e.g. (enlisted):
|
|
11
|
+
#
|
|
12
|
+
# Suffix Primary Aircraft Suffix Primary Aircraft
|
|
13
|
+
# A C-5 Flight Engineer L C-130H Flight Engineer
|
|
14
|
+
# B C-5 Loadmaster N C-130H Loadmaster
|
|
15
|
+
#
|
|
16
|
+
# The officer directory uses the same two-column interleave under a different
|
|
17
|
+
# heading ("Suffix ... Portion of AFS to Which Related"). Both columns are
|
|
18
|
+
# captured. Values that wrap onto a continuation line are captured only up to
|
|
19
|
+
# the wrap (a documented, minor limitation). The table header is
|
|
20
|
+
# publication-specific (injected Publication); the default is enlisted.
|
|
21
|
+
class ShredoutParser
|
|
22
|
+
# A single suffix/name cell. The suffix is one capital letter followed by
|
|
23
|
+
# 2+ spaces; the name runs until 2+ spaces precede the next column's suffix
|
|
24
|
+
# or the line ends.
|
|
25
|
+
PAIR = /\b([A-Z])\s{2,}([A-Z0-9][A-Za-z0-9 \/().-]{2,45}?)(?=\s{2,}[A-Z]\s{2,}|\s*$)/
|
|
26
|
+
|
|
27
|
+
# A more permissive variant used by the RI/SDI extractor, whose values carry
|
|
28
|
+
# punctuation the AFSC tables never do: a colon ("8W1XX WSMs w/PAFSC: 2W0X1"
|
|
29
|
+
# in the Space Force 8W tables) and a comma ("Graduated, Flight Chief" in
|
|
30
|
+
# the 8R300 recruiter table). The AFSC ladder pipeline keeps the stricter
|
|
31
|
+
# PAIR (its default), so the shipped enlisted/officer artifacts are
|
|
32
|
+
# unaffected.
|
|
33
|
+
RI_SDI_PAIR = /\b([A-Z])\s{2,}([A-Z0-9][A-Za-z0-9 \/().:,-]{2,45}?)(?=\s{2,}[A-Z]\s{2,}|\s*$)/
|
|
34
|
+
|
|
35
|
+
def initialize(text, publication: Publication.dafecd, pair: PAIR)
|
|
36
|
+
@text = text
|
|
37
|
+
@header = publication.shredout_header
|
|
38
|
+
@pair = pair
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash{Symbol=>String}] suffix letter => shredout name
|
|
42
|
+
def parse
|
|
43
|
+
result = {}
|
|
44
|
+
table_lines.each do |line|
|
|
45
|
+
# Strip stray decorative glyphs that would otherwise break the column
|
|
46
|
+
# lookahead (e.g. a U+F0EA bullet sitting in a between-column gap).
|
|
47
|
+
line.gsub(Patterns::DECORATIVE, " ").scan(@pair) do |suffix, name|
|
|
48
|
+
result[suffix.to_sym] = name.strip
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Lines belonging to the shredout table body: everything after the
|
|
57
|
+
# "Suffix ..." header up to a terminating boundary (a *NOTE, a numbered
|
|
58
|
+
# section, or the end of the given text).
|
|
59
|
+
def table_lines
|
|
60
|
+
lines = @text.lines
|
|
61
|
+
start = lines.index { |line| line.match?(@header) }
|
|
62
|
+
return [] if start.nil?
|
|
63
|
+
|
|
64
|
+
body = []
|
|
65
|
+
lines[(start + 1)..].each do |line|
|
|
66
|
+
stripped = line.strip
|
|
67
|
+
break if stripped.start_with?("*NOTE", "NOTE:")
|
|
68
|
+
break if stripped.match?(/\A\d+(?:\.\d+)*\.\s/)
|
|
69
|
+
body << line
|
|
70
|
+
end
|
|
71
|
+
body
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|