openphar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +221 -0
  3. data/lib/openphar/cli/main.rb +137 -0
  4. data/lib/openphar/cli.rb +11 -0
  5. data/lib/openphar/core/slug_generator.rb +107 -0
  6. data/lib/openphar/core.rb +11 -0
  7. data/lib/openphar/errors/configuration_error.rb +8 -0
  8. data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
  9. data/lib/openphar/errors/parse_error.rb +18 -0
  10. data/lib/openphar/errors/unknown_format_error.rb +8 -0
  11. data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
  12. data/lib/openphar/errors/unknown_type_error.rb +8 -0
  13. data/lib/openphar/errors/validation_error.rb +15 -0
  14. data/lib/openphar/errors.rb +17 -0
  15. data/lib/openphar/exporters/coverage_validator.rb +265 -0
  16. data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
  17. data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
  18. data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
  19. data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
  20. data/lib/openphar/exporters/neo4j.rb +16 -0
  21. data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
  22. data/lib/openphar/exporters.rb +15 -0
  23. data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
  24. data/lib/openphar/linkers/chp.rb +11 -0
  25. data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
  26. data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
  27. data/lib/openphar/linkers.rb +13 -0
  28. data/lib/openphar/migrators/chp/classifier.rb +86 -0
  29. data/lib/openphar/migrators/chp.rb +15 -0
  30. data/lib/openphar/migrators/chp_migrator.rb +130 -0
  31. data/lib/openphar/migrators/jp_migrator.rb +345 -0
  32. data/lib/openphar/migrators/phint_migrator.rb +426 -0
  33. data/lib/openphar/migrators.rb +15 -0
  34. data/lib/openphar/models/ahp.rb +25 -0
  35. data/lib/openphar/models/amino_acid_monograph.rb +28 -0
  36. data/lib/openphar/models/api.rb +29 -0
  37. data/lib/openphar/models/assay_specification.rb +19 -0
  38. data/lib/openphar/models/base_entity.rb +221 -0
  39. data/lib/openphar/models/biological_substance_monograph.rb +31 -0
  40. data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
  41. data/lib/openphar/models/chp/biologic.rb +28 -0
  42. data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
  43. data/lib/openphar/models/chp/chemical_substance.rb +28 -0
  44. data/lib/openphar/models/chp/general_chapter.rb +30 -0
  45. data/lib/openphar/models/chp/monograph.rb +116 -0
  46. data/lib/openphar/models/chp/section.rb +37 -0
  47. data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
  48. data/lib/openphar/models/chp/tcm_extract.rb +29 -0
  49. data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
  50. data/lib/openphar/models/chp.rb +33 -0
  51. data/lib/openphar/models/crude_drug_monograph.rb +31 -0
  52. data/lib/openphar/models/edition.rb +41 -0
  53. data/lib/openphar/models/formulation_monograph.rb +31 -0
  54. data/lib/openphar/models/hkcmms.rb +26 -0
  55. data/lib/openphar/models/identification_specification.rb +19 -0
  56. data/lib/openphar/models/jp/kampo_formula.rb +94 -0
  57. data/lib/openphar/models/jp.rb +21 -0
  58. data/lib/openphar/models/limit.rb +54 -0
  59. data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
  60. data/lib/openphar/models/monograph.rb +58 -0
  61. data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
  62. data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
  63. data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
  64. data/lib/openphar/models/ph_int/reagent.rb +50 -0
  65. data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
  66. data/lib/openphar/models/ph_int/test_method.rb +50 -0
  67. data/lib/openphar/models/ph_int/test_solution.rb +39 -0
  68. data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
  69. data/lib/openphar/models/ph_int.rb +37 -0
  70. data/lib/openphar/models/physical_specification.rb +17 -0
  71. data/lib/openphar/models/publisher.rb +98 -0
  72. data/lib/openphar/models/purity_specification.rb +16 -0
  73. data/lib/openphar/models/supplement.rb +31 -0
  74. data/lib/openphar/models/test_specification.rb +29 -0
  75. data/lib/openphar/models/thp.rb +24 -0
  76. data/lib/openphar/models/vitamin_monograph.rb +28 -0
  77. data/lib/openphar/models.rb +58 -0
  78. data/lib/openphar/monograph_merger.rb +374 -0
  79. data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
  80. data/lib/openphar/parsers/jp_html_parser.rb +92 -0
  81. data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
  82. data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
  83. data/lib/openphar/parsers/phint_json_parser.rb +332 -0
  84. data/lib/openphar/parsers.rb +21 -0
  85. data/lib/openphar/registry/publisher_registry.rb +46 -0
  86. data/lib/openphar/registry/type_registry.rb +147 -0
  87. data/lib/openphar/registry.rb +24 -0
  88. data/lib/openphar/repositories/monograph_repository.rb +350 -0
  89. data/lib/openphar/repositories.rb +11 -0
  90. data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
  91. data/lib/openphar/transformers.rb +11 -0
  92. data/lib/openphar/version.rb +5 -0
  93. data/lib/openphar.rb +119 -0
  94. data/ontology/bibliographic/edition.ttl +267 -0
  95. data/ontology/context/chp.jsonld +69 -0
  96. data/ontology/context/crude-drugs.jsonld +56 -0
  97. data/ontology/context/pharmacopoeia.jsonld +631 -0
  98. data/ontology/core/identification.ttl +456 -0
  99. data/ontology/core/pharmacopoeia.ttl +3427 -0
  100. data/ontology/core/substance-form.ttl +302 -0
  101. data/ontology/core/unit.ttl +493 -0
  102. data/ontology/publisher/chp/extensions.ttl +186 -0
  103. data/ontology/publisher/phint/extensions.ttl +327 -0
  104. data/ontology/quality/test-method.ttl +610 -0
  105. data/shapes/chp/monograph.ttl +153 -0
  106. data/shapes/edition-shapes.ttl +230 -0
  107. data/shapes/monograph-shapes.ttl +1324 -0
  108. metadata +319 -0
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Openphar
7
+ module Linkers
8
+ # Links pharmacopoeia monographs across different publishers
9
+ #
10
+ # This class identifies matching substances across pharmacopoeias (e.g., JP and Ph.Int.)
11
+ # by comparing CAS numbers, INN names, and chemical structures.
12
+ class CrossPublisherLinker
13
+ attr_reader :jp_data, :phint_data, :output_path
14
+
15
+ # Match confidence levels
16
+ CONFIDENCE_HIGH = 0.95 # CAS + INN match
17
+ CONFIDENCE_MEDIUM = 0.80 # CAS or INN match
18
+ CONFIDENCE_LOW = 0.60 # Name similarity only
19
+
20
+ # @param jp_data_path [String] Path to JP monographs JSON-LD file
21
+ # @param phint_data_path [String] Path to Ph.Int. monographs JSON-LD file
22
+ # @param output_path [String] Output path for cross-publisher links
23
+ def initialize(jp_data_path:, phint_data_path:, output_path:)
24
+ @jp_data_path = jp_data_path
25
+ @phint_data_path = phint_data_path
26
+ @output_path = output_path
27
+ @jp_monographs = []
28
+ @phint_monographs = []
29
+ @links = []
30
+ @stats = { total: 0, matches: 0, by_method: Hash.new(0) }
31
+ end
32
+
33
+ # Run the linking process
34
+ # @return [Hash] Statistics about the linking
35
+ def run
36
+ puts "Starting cross-publisher linking..."
37
+ puts " JP data: #{@jp_data_path}"
38
+ puts " Ph.Int. data: #{@phint_data_path}"
39
+
40
+ # Load data
41
+ load_data
42
+
43
+ # Build indices for efficient lookup
44
+ build_indices
45
+
46
+ # Find matches
47
+ find_matches
48
+
49
+ # Generate output
50
+ generate_links_file
51
+
52
+ # Print statistics
53
+ print_statistics
54
+
55
+ @stats
56
+ end
57
+
58
+ private
59
+
60
+ def load_data
61
+ puts "\nLoading data..."
62
+
63
+ # Load JP data
64
+ jp_content = File.read(@jp_data_path, encoding: "UTF-8")
65
+ jp_json = JSON.parse(jp_content)
66
+ @jp_monographs = extract_monographs(jp_json)
67
+ puts " JP monographs: #{@jp_monographs.count}"
68
+
69
+ # Load Ph.Int. data
70
+ phint_content = File.read(@phint_data_path, encoding: "UTF-8")
71
+ phint_json = JSON.parse(phint_content)
72
+ @phint_monographs = extract_monographs(phint_json)
73
+ puts " Ph.Int. monographs: #{@phint_monographs.count}"
74
+ end
75
+
76
+ def extract_monographs(data)
77
+ if data["@graph"]
78
+ data["@graph"]
79
+ elsif data["@id"]
80
+ [data]
81
+ else
82
+ []
83
+ end
84
+ end
85
+
86
+ def build_indices
87
+ puts "\nBuilding indices..."
88
+
89
+ # Index JP monographs by CAS number
90
+ @jp_by_cas = {}
91
+ @jp_monographs.each do |m|
92
+ cas = normalize_cas(m["casNumber"])
93
+ @jp_by_cas[cas] = m if cas
94
+ end
95
+ puts " JP by CAS: #{@jp_by_cas.count}"
96
+
97
+ # Index JP monographs by name (INN)
98
+ @jp_by_name = {}
99
+ @jp_monographs.each do |m|
100
+ name = normalize_name(extract_label(m))
101
+ @jp_by_name[name] = m if name
102
+ end
103
+ puts " JP by name: #{@jp_by_name.count}"
104
+
105
+ # Index Ph.Int. monographs by CAS number
106
+ @phint_by_cas = {}
107
+ @phint_monographs.each do |m|
108
+ cas = normalize_cas(m["casNumber"])
109
+ @phint_by_cas[cas] = m if cas
110
+ end
111
+ puts " Ph.Int. by CAS: #{@phint_by_cas.count}"
112
+
113
+ # Index Ph.Int. monographs by name (INN)
114
+ @phint_by_name = {}
115
+ @phint_monographs.each do |m|
116
+ name = normalize_name(extract_label(m))
117
+ @phint_by_name[name] = m if name
118
+ end
119
+ puts " Ph.Int. by name: #{@phint_by_name.count}"
120
+ end
121
+
122
+ def find_matches
123
+ puts "\nFinding matches..."
124
+
125
+ @jp_monographs.each do |jp_m|
126
+ @stats[:total] += 1
127
+ jp_id = jp_m["@id"]
128
+ jp_cas = normalize_cas(jp_m["casNumber"])
129
+ jp_name = normalize_name(extract_label(jp_m))
130
+
131
+ # Try CAS match first (highest confidence)
132
+ if jp_cas && @phint_by_cas[jp_cas]
133
+ phint_m = @phint_by_cas[jp_cas]
134
+ add_link(jp_m, phint_m, "sameSubstanceAs", CONFIDENCE_HIGH, "CAS")
135
+ next
136
+ end
137
+
138
+ # Try name match
139
+ if jp_name && @phint_by_name[jp_name]
140
+ phint_m = @phint_by_name[jp_name]
141
+ add_link(jp_m, phint_m, "sameSubstanceAs", CONFIDENCE_MEDIUM, "INN")
142
+ next
143
+ end
144
+
145
+ # Try fuzzy name matching for remaining
146
+ phint_m = find_fuzzy_match(jp_name)
147
+ if phint_m
148
+ add_link(jp_m, phint_m, "similarTo", CONFIDENCE_LOW, "FuzzyName")
149
+ end
150
+ end
151
+ end
152
+
153
+ def add_link(jp_m, phint_m, relation, confidence, method)
154
+ @links << {
155
+ "jp_id" => jp_m["@id"],
156
+ "phint_id" => phint_m["@id"],
157
+ "jp_name" => extract_label(jp_m),
158
+ "phint_name" => extract_label(phint_m),
159
+ "relation" => relation,
160
+ "confidence" => confidence,
161
+ "method" => method
162
+ }
163
+ @stats[:matches] += 1
164
+ @stats[:by_method][method] += 1
165
+ end
166
+
167
+ def find_fuzzy_match(jp_name)
168
+ return nil unless jp_name
169
+
170
+ # Simple fuzzy matching - find names with high similarity
171
+ @phint_by_name.each do |phint_name, phint_m|
172
+ next unless phint_name
173
+
174
+ # Calculate simple similarity
175
+ similarity = calculate_similarity(jp_name, phint_name)
176
+ return phint_m if similarity > 0.85
177
+ end
178
+
179
+ nil
180
+ end
181
+
182
+ def calculate_similarity(str1, str2)
183
+ return 1.0 if str1 == str2
184
+ return 0.0 if str1.nil? || str2.nil?
185
+
186
+ # Levenshtein distance-based similarity
187
+ distance = levenshtein_distance(str1, str2)
188
+ max_len = [str1.length, str2.length].max
189
+ 1.0 - (distance.to_f / max_len)
190
+ end
191
+
192
+ def levenshtein_distance(str1, str2)
193
+ matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
194
+
195
+ (0..str1.length).each { |i| matrix[i][0] = i }
196
+ (0..str2.length).each { |j| matrix[0][j] = j }
197
+
198
+ (1..str1.length).each do |i|
199
+ (1..str2.length).each do |j|
200
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
201
+ matrix[i][j] = [
202
+ matrix[i - 1][j] + 1, # deletion
203
+ matrix[i][j - 1] + 1, # insertion
204
+ matrix[i - 1][j - 1] + cost # substitution
205
+ ].min
206
+ end
207
+ end
208
+
209
+ matrix[str1.length][str2.length]
210
+ end
211
+
212
+ def normalize_cas(cas)
213
+ return nil unless cas
214
+
215
+ # Normalize CAS format: XXXXX-XX-X
216
+ cas = cas.to_s.strip.upcase
217
+ # Remove any non-standard characters
218
+ cas = cas.gsub(/[^0-9\-]/, "")
219
+ cas.empty? ? nil : cas
220
+ end
221
+
222
+ def normalize_name(name)
223
+ return nil unless name
224
+
225
+ # Normalize drug name for comparison
226
+ name = name.to_s.strip.downcase
227
+ # Remove common suffixes that differ between pharmacopoeias
228
+ name = name.gsub(/\s+(hydrate|hydrochloride|sulfate|sodium|potassium)$/, "")
229
+ # Remove extra spaces
230
+ name = name.gsub(/\s+/, " ")
231
+ name.empty? ? nil : name
232
+ end
233
+
234
+ def extract_label(monograph)
235
+ label = monograph["prefLabel"]
236
+ return nil unless label
237
+
238
+ label.is_a?(Hash) ? (label["en"] || label.values.first) : label
239
+ end
240
+
241
+ def generate_links_file
242
+ puts "\nGenerating links file..."
243
+
244
+ # Ensure output directory exists
245
+ FileUtils.mkdir_p(File.dirname(@output_path))
246
+
247
+ # Build JSON-LD structure
248
+ jsonld = {
249
+ "@context" => "https://www.openphar.org/ontology/context/pharmacopoeia.jsonld",
250
+ "@id" => "https://www.openphar.org/data/cross-publisher/links",
251
+ "@type" => "Collection",
252
+ "prefLabel" => { "en" => "Cross-Publisher Links - JP and Ph.Int." },
253
+ "linkCount" => @links.count,
254
+ "@graph" => @links.map do |link|
255
+ {
256
+ "@id" => "https://www.openphar.org/data/cross-publisher/link/#{generate_link_id(link)}",
257
+ "@type" => "CrossPublisherLink",
258
+ "source" => link["jp_id"],
259
+ "target" => link["phint_id"],
260
+ "relation" => link["relation"],
261
+ "matchConfidence" => link["confidence"],
262
+ "matchMethod" => link["method"],
263
+ "sourceName" => link["jp_name"],
264
+ "targetName" => link["phint_name"]
265
+ }
266
+ end
267
+ }
268
+
269
+ File.write(@output_path, JSON.pretty_generate(jsonld))
270
+ puts " Created: #{@output_path}"
271
+ end
272
+
273
+ def generate_link_id(link)
274
+ # Generate a unique ID for the link
275
+ jp_slug = link["jp_id"].split("/").last
276
+ phint_slug = link["phint_id"].split("/").last
277
+ "#{jp_slug}-#{phint_slug}"
278
+ end
279
+
280
+ def print_statistics
281
+ puts "\n" + "=" * 50
282
+ puts "Cross-Publisher Linking Statistics"
283
+ puts "=" * 50
284
+ puts "Total JP monographs processed: #{@stats[:total]}"
285
+ puts "Matches found: #{@stats[:matches]}"
286
+ puts "Match rate: #{(@stats[:matches].to_f / @stats[:total] * 100).round(1)}%"
287
+ puts "\nBy method:"
288
+ @stats[:by_method].each do |method, count|
289
+ puts " #{method}: #{count}"
290
+ end
291
+ puts "=" * 50
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Linkers
5
+ # Links pharmacopoeia monographs to data-herbapedia entities
6
+ class HerbapediaLinker
7
+ attr_reader :preparations_index, :tcm_profiles_index, :ayurveda_profiles_index
8
+
9
+ def initialize(herbapedia_data_path: nil)
10
+ @herbapedia_data_path = herbapedia_data_path
11
+ @preparations_index = {}
12
+ @tcm_profiles_index = {}
13
+ @ayurveda_profiles_index = {}
14
+ load_indexes if herbapedia_data_path
15
+ end
16
+
17
+ # Find a HerbalPreparation in data-herbapedia
18
+ # @param latin_name [String] The Latin pharmaceutical name
19
+ # @param chinese_name [String, nil] Optional Chinese name
20
+ # @return [String, nil] The IRI of the matching preparation
21
+ def find_preparation(latin_name:, chinese_name: nil)
22
+ return nil if preparations_index.empty?
23
+
24
+ # Try exact Latin name match first
25
+ normalized = normalize_name(latin_name)
26
+ match = preparations_index[normalized]
27
+
28
+ # Try Chinese name if no match
29
+ if !match && chinese_name
30
+ match = preparations_index.values.find do |prep|
31
+ prep[:names]&.any? { |n| n[:zh] == chinese_name }
32
+ end
33
+ end
34
+
35
+ match&.dig(:iri)
36
+ end
37
+
38
+ # Find a TCM profile in data-herbapedia
39
+ # @param pinyin [String] The pinyin name
40
+ # @return [String, nil] The IRI of the matching TCM profile
41
+ def find_tcm_profile(pinyin:)
42
+ return nil if tcm_profiles_index.empty?
43
+
44
+ normalized = normalize_name(pinyin)
45
+ tcm_profiles_index[normalized]&.dig(:iri)
46
+ end
47
+
48
+ # Find an Ayurveda profile in data-herbapedia
49
+ # @param sanskrit [String] The Sanskrit name
50
+ # @return [String, nil] The IRI of the matching Ayurveda profile
51
+ def find_ayurveda_profile(sanskrit:)
52
+ return nil if ayurveda_profiles_index.empty?
53
+
54
+ normalized = normalize_name(sanskrit)
55
+ ayurveda_profiles_index[normalized]&.dig(:iri)
56
+ end
57
+
58
+ # Link a monograph to herbapedia entities
59
+ # @param monograph_data [Hash] The monograph data
60
+ # @return [Hash] Updated monograph data with herbapedia links
61
+ def link_monograph(monograph_data)
62
+ result = monograph_data.dup
63
+
64
+ # Try to find preparation by Latin name
65
+ latin_name = monograph_data[:name] || monograph_data.dig(:pref_label, "en")
66
+ chinese_name = monograph_data.dig(:pref_label, "zh") ||
67
+ monograph_data.dig(:pref_label, "zh-Hant")
68
+
69
+ if latin_name
70
+ prep_iri = find_preparation(latin_name: latin_name, chinese_name: chinese_name)
71
+ result[:references_preparation] = prep_iri if prep_iri
72
+ end
73
+
74
+ result
75
+ end
76
+
77
+ private
78
+
79
+ def load_indexes
80
+ # Load preparations index
81
+ prep_file = File.join(@herbapedia_data_path, "preparations_index.json")
82
+ if File.exist?(prep_file)
83
+ data = JSON.parse(File.read(prep_file))
84
+ data.each do |prep|
85
+ normalized = normalize_name(prep["latin_name"])
86
+ @preparations_index[normalized] = {
87
+ iri: prep["iri"],
88
+ names: prep["names"]
89
+ }
90
+ end
91
+ end
92
+
93
+ # Load TCM profiles index
94
+ tcm_file = File.join(@herbapedia_data_path, "tcm_profiles_index.json")
95
+ if File.exist?(tcm_file)
96
+ data = JSON.parse(File.read(tcm_file))
97
+ data.each do |profile|
98
+ normalized = normalize_name(profile["pinyin"])
99
+ @tcm_profiles_index[normalized] = {
100
+ iri: profile["iri"]
101
+ }
102
+ end
103
+ end
104
+
105
+ # Load Ayurveda profiles index
106
+ ayu_file = File.join(@herbapedia_data_path, "ayurveda_profiles_index.json")
107
+ if File.exist?(ayu_file)
108
+ data = JSON.parse(File.read(ayu_file))
109
+ data.each do |profile|
110
+ normalized = normalize_name(profile["sanskrit"])
111
+ @ayurveda_profiles_index[normalized] = {
112
+ iri: profile["iri"]
113
+ }
114
+ end
115
+ end
116
+ end
117
+
118
+ def normalize_name(name)
119
+ return nil unless name
120
+
121
+ name.to_s.downcase.strip.gsub(/\s+/, " ")
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Linkers
5
+ # Linkers for connecting monographs to external resources.
6
+ #
7
+ # Handles linking to Herbapedia entities and cross-publisher matching.
8
+
9
+ autoload :HerbapediaLinker, 'openphar/linkers/herbapedia_linker'
10
+ autoload :CrossPublisherLinker, 'openphar/linkers/cross_publisher_linker'
11
+ autoload :Chp, 'openphar/linkers/chp'
12
+ end
13
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Migrators
5
+ module Chp
6
+ # Single dispatch point mapping a ChP API record's (book_id,
7
+ # directory_title) pair to the correct Openphar::Models::Chp::*
8
+ # subclass.
9
+ #
10
+ # Routing is data-driven: SPECIFIC_RULES is an ordered list of
11
+ # `{book_id, substring, klass_name}` triples. Adding a new directory
12
+ # type = adding one line, not editing dispatch logic. Open/closed.
13
+ #
14
+ # MECE invariant: every (book_id, directory_title) pair observed in
15
+ # the ChP 2025/2020 manifests classifies to exactly one subclass.
16
+ # `spec/openphar/migrators/chp/classifier_spec.rb` enforces this
17
+ # against every directory_title seen in the manifests.
18
+ #
19
+ class Classifier
20
+ SPECIFIC_RULES = [
21
+ { book_id: 1, substring: '药材和饮片', klass: 'TcmCrudeDrug' },
22
+ { book_id: 1, substring: '植物油脂和提取物', klass: 'TcmExtract' },
23
+ { book_id: 1, substring: '成方制剂', klass: 'TcmFormulation' },
24
+ { book_id: 2, substring: '第二部分', klass: 'ChemicalPreparation' },
25
+ ].freeze
26
+
27
+ VOLUME_DEFAULTS = {
28
+ 1 => 'TcmCrudeDrug',
29
+ 2 => 'ChemicalSubstance',
30
+ 3 => 'Biologic',
31
+ 4 => 'GeneralChapter',
32
+ }.freeze
33
+
34
+ FINAL_FALLBACK = 'Monograph'.freeze
35
+
36
+ class << self
37
+ # Returns the subclass appropriate for the given record.
38
+ # Never raises — degrades gracefully to Chp::Monograph.
39
+ #
40
+ # @param book_id [Integer] 1..4
41
+ # @param directory_title [String, nil] e.g., "药材和饮片"
42
+ # @return [Class] a subclass of Openphar::Models::Chp::Monograph
43
+ def classify(book_id:, directory_title: nil)
44
+ klass_name = resolve(book_id: book_id, directory_title: directory_title)
45
+ Openphar::Models::Chp.const_get(klass_name)
46
+ end
47
+
48
+ # Returns the wire_key string for a record. Useful for routing
49
+ # without loading the class (e.g., for stats before instantiation).
50
+ #
51
+ # @return [String]
52
+ def wire_key_for(book_id:, directory_title: nil)
53
+ classify(book_id: book_id, directory_title: directory_title).wire_key
54
+ end
55
+
56
+ # Enumerate every ChP type class. Used by specs to assert
57
+ # classification coverage.
58
+ #
59
+ # @return [Array<Class>]
60
+ def all_types
61
+ %i[
62
+ TcmCrudeDrug TcmExtract TcmFormulation
63
+ ChemicalSubstance ChemicalPreparation
64
+ Biologic GeneralChapter
65
+ ].map { |k| Openphar::Models::Chp.const_get(k) }
66
+ end
67
+
68
+ private
69
+
70
+ def resolve(book_id:, directory_title:)
71
+ return FINAL_FALLBACK unless VOLUME_DEFAULTS.key?(book_id)
72
+
73
+ SPECIFIC_RULES.each do |rule|
74
+ next unless rule[:book_id] == book_id
75
+ next if directory_title.nil? || directory_title.empty?
76
+
77
+ return rule[:klass] if directory_title.include?(rule[:substring])
78
+ end
79
+
80
+ VOLUME_DEFAULTS[book_id]
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Migrators
5
+ # Chinese Pharmacopoeia migrator namespace.
6
+ #
7
+ # Houses the classifier (single dispatch point) and the migrator that
8
+ # walks `data/<edition>/entries/*.json` and produces typed
9
+ # `Openphar::Models::Chp::*` instances + JSON-LD output.
10
+ #
11
+ module Chp
12
+ autoload :Classifier, 'openphar/migrators/chp/classifier'
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Openphar
7
+ module Migrators
8
+ # Orchestrates the ChP migration end-to-end from the openphar gem side.
9
+ #
10
+ # Reads raw API JSON from a ChP edition's data directory, classifies +
11
+ # instantiates each entry to the correct Openphar::Models::Chp::* subclass,
12
+ # and writes JSON-LD output. Used by any consumer of the openphar gem that
13
+ # wants to publish ChP data without depending on the data-china-pharmacopoeia
14
+ # repo's CLI directly.
15
+ #
16
+ # The actual parsing (Nokogiri htmlContent → sections) is delegated to
17
+ # ChP::Parser when running inside the data repo. When used from another
18
+ # consumer, the caller supplies a parser lambda via the `parser:` keyword.
19
+ #
20
+ class ChpMigrator
21
+ attr_reader :edition_dir, :output_dir, :edition_label, :stats
22
+
23
+ # @param edition_dir [String] Path to the edition's data directory
24
+ # (must contain `entries/*.json`).
25
+ # @param output_dir [String] Where to write JSON-LD output.
26
+ # @param edition_label [String] Harmonized label, e.g., "ChP-2025".
27
+ # @param parser [#call] Lambda/proc that takes (record_json, edition_label)
28
+ # and returns an Openphar::Models::Chp::* instance. Defaults to
29
+ # ChP::Parser method object when running inside the data repo.
30
+ def initialize(edition_dir:, output_dir:, edition_label:, parser: default_parser)
31
+ @edition_dir = edition_dir
32
+ @output_dir = output_dir
33
+ @edition_label = edition_label
34
+ @parser = parser
35
+ @stats = {
36
+ total: 0, classified: 0, skipped: 0, exported: 0, by_type: Hash.new(0),
37
+ }
38
+ end
39
+
40
+ def run
41
+ ensure_output_dirs
42
+ walk_entries { |monograph| export_monograph(monograph) }
43
+ write_aggregates
44
+ @stats
45
+ end
46
+
47
+ private
48
+
49
+ def ensure_output_dirs
50
+ FileUtils.mkdir_p(File.join(@output_dir, "nodes"))
51
+ end
52
+
53
+ def walk_entries
54
+ Dir.glob(File.join(@edition_dir, "entries", "*.json")).sort.each do |path|
55
+ @stats[:total] += 1
56
+ record = JSON.parse(File.read(path))
57
+ data = record["data"]
58
+
59
+ if data.nil?
60
+ @stats[:skipped] += 1
61
+ next
62
+ end
63
+
64
+ begin
65
+ monograph = @parser.call(record, @edition_label)
66
+ yield monograph
67
+ @stats[:classified] += 1
68
+ @stats[:by_type][monograph.class.wire_key] += 1
69
+ rescue StandardError => e
70
+ warn "[#{@edition_label}] #{path}: #{e.message}"
71
+ @stats[:skipped] += 1
72
+ end
73
+ end
74
+ end
75
+
76
+ def export_monograph(monograph)
77
+ dir = File.join(@output_dir, "nodes", monograph.class.wire_key)
78
+ FileUtils.mkdir_p(dir)
79
+ path = File.join(dir, monograph.jsonld_filename)
80
+ doc = { "@context" => context_url }.merge(monograph_to_jsonld(monograph))
81
+ File.write(path, JSON.pretty_generate(doc))
82
+ @stats[:exported] += 1
83
+ end
84
+
85
+ def write_aggregates
86
+ # Placeholder for index/full graph generation — the data repo's exporter
87
+ # is more complete; this gem-side migrator focuses on per-node output.
88
+ File.write(File.join(@output_dir, "migrator-stats.json"),
89
+ JSON.pretty_generate(@stats))
90
+ end
91
+
92
+ def monograph_to_jsonld(m)
93
+ {
94
+ "@id" => m.id || m.iri,
95
+ "@type" => m.class.jsonld_types,
96
+ "monographId" => m.monograph_id,
97
+ "belongsToEdition" => m.belongs_to_edition,
98
+ "prefLabel" => m.pref_label,
99
+ "publisher" => m.publisher,
100
+ "status" => m.status,
101
+ "definition" => m.definition,
102
+ "entryId" => m.entry_id,
103
+ "bookId" => m.book_id,
104
+ "directoryTitle" => m.directory_title,
105
+ "title" => m.title,
106
+ "pinyinTitle" => m.pinyin_title,
107
+ "eTitle" => m.e_title,
108
+ "pageNum" => m.page_num,
109
+ }.compact
110
+ end
111
+
112
+ def context_url
113
+ "https://www.openphar.org/ontology/context/chp.jsonld"
114
+ end
115
+
116
+ def default_parser
117
+ lambda do |record, edition_label|
118
+ edition_key = edition_label.to_s.downcase.gsub(/\Achp-/, "")
119
+ ChP::Parser.parse_record(record, edition: edition_key)
120
+ end
121
+ rescue NameError
122
+ # ChP::Parser not loadable (running outside the data repo) — the caller
123
+ # must supply an explicit parser lambda.
124
+ lambda { |*, |
125
+ raise "no default parser available; supply `parser:` keyword to ChpMigrator.new"
126
+ }
127
+ end
128
+ end
129
+ end
130
+ end