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.
- checksums.yaml +7 -0
- data/README.adoc +221 -0
- data/lib/openphar/cli/main.rb +137 -0
- data/lib/openphar/cli.rb +11 -0
- data/lib/openphar/core/slug_generator.rb +107 -0
- data/lib/openphar/core.rb +11 -0
- data/lib/openphar/errors/configuration_error.rb +8 -0
- data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
- data/lib/openphar/errors/parse_error.rb +18 -0
- data/lib/openphar/errors/unknown_format_error.rb +8 -0
- data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
- data/lib/openphar/errors/unknown_type_error.rb +8 -0
- data/lib/openphar/errors/validation_error.rb +15 -0
- data/lib/openphar/errors.rb +17 -0
- data/lib/openphar/exporters/coverage_validator.rb +265 -0
- data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
- data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
- data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
- data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
- data/lib/openphar/exporters/neo4j.rb +16 -0
- data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
- data/lib/openphar/exporters.rb +15 -0
- data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
- data/lib/openphar/linkers/chp.rb +11 -0
- data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
- data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
- data/lib/openphar/linkers.rb +13 -0
- data/lib/openphar/migrators/chp/classifier.rb +86 -0
- data/lib/openphar/migrators/chp.rb +15 -0
- data/lib/openphar/migrators/chp_migrator.rb +130 -0
- data/lib/openphar/migrators/jp_migrator.rb +345 -0
- data/lib/openphar/migrators/phint_migrator.rb +426 -0
- data/lib/openphar/migrators.rb +15 -0
- data/lib/openphar/models/ahp.rb +25 -0
- data/lib/openphar/models/amino_acid_monograph.rb +28 -0
- data/lib/openphar/models/api.rb +29 -0
- data/lib/openphar/models/assay_specification.rb +19 -0
- data/lib/openphar/models/base_entity.rb +221 -0
- data/lib/openphar/models/biological_substance_monograph.rb +31 -0
- data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
- data/lib/openphar/models/chp/biologic.rb +28 -0
- data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
- data/lib/openphar/models/chp/chemical_substance.rb +28 -0
- data/lib/openphar/models/chp/general_chapter.rb +30 -0
- data/lib/openphar/models/chp/monograph.rb +116 -0
- data/lib/openphar/models/chp/section.rb +37 -0
- data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
- data/lib/openphar/models/chp/tcm_extract.rb +29 -0
- data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
- data/lib/openphar/models/chp.rb +33 -0
- data/lib/openphar/models/crude_drug_monograph.rb +31 -0
- data/lib/openphar/models/edition.rb +41 -0
- data/lib/openphar/models/formulation_monograph.rb +31 -0
- data/lib/openphar/models/hkcmms.rb +26 -0
- data/lib/openphar/models/identification_specification.rb +19 -0
- data/lib/openphar/models/jp/kampo_formula.rb +94 -0
- data/lib/openphar/models/jp.rb +21 -0
- data/lib/openphar/models/limit.rb +54 -0
- data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
- data/lib/openphar/models/monograph.rb +58 -0
- data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
- data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
- data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
- data/lib/openphar/models/ph_int/reagent.rb +50 -0
- data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
- data/lib/openphar/models/ph_int/test_method.rb +50 -0
- data/lib/openphar/models/ph_int/test_solution.rb +39 -0
- data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
- data/lib/openphar/models/ph_int.rb +37 -0
- data/lib/openphar/models/physical_specification.rb +17 -0
- data/lib/openphar/models/publisher.rb +98 -0
- data/lib/openphar/models/purity_specification.rb +16 -0
- data/lib/openphar/models/supplement.rb +31 -0
- data/lib/openphar/models/test_specification.rb +29 -0
- data/lib/openphar/models/thp.rb +24 -0
- data/lib/openphar/models/vitamin_monograph.rb +28 -0
- data/lib/openphar/models.rb +58 -0
- data/lib/openphar/monograph_merger.rb +374 -0
- data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
- data/lib/openphar/parsers/jp_html_parser.rb +92 -0
- data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
- data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
- data/lib/openphar/parsers/phint_json_parser.rb +332 -0
- data/lib/openphar/parsers.rb +21 -0
- data/lib/openphar/registry/publisher_registry.rb +46 -0
- data/lib/openphar/registry/type_registry.rb +147 -0
- data/lib/openphar/registry.rb +24 -0
- data/lib/openphar/repositories/monograph_repository.rb +350 -0
- data/lib/openphar/repositories.rb +11 -0
- data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
- data/lib/openphar/transformers.rb +11 -0
- data/lib/openphar/version.rb +5 -0
- data/lib/openphar.rb +119 -0
- data/ontology/bibliographic/edition.ttl +267 -0
- data/ontology/context/chp.jsonld +69 -0
- data/ontology/context/crude-drugs.jsonld +56 -0
- data/ontology/context/pharmacopoeia.jsonld +631 -0
- data/ontology/core/identification.ttl +456 -0
- data/ontology/core/pharmacopoeia.ttl +3427 -0
- data/ontology/core/substance-form.ttl +302 -0
- data/ontology/core/unit.ttl +493 -0
- data/ontology/publisher/chp/extensions.ttl +186 -0
- data/ontology/publisher/phint/extensions.ttl +327 -0
- data/ontology/quality/test-method.ttl +610 -0
- data/shapes/chp/monograph.ttl +153 -0
- data/shapes/edition-shapes.ttl +230 -0
- data/shapes/monograph-shapes.ttl +1324 -0
- 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
|