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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Exporters
|
|
5
|
+
module Neo4j
|
|
6
|
+
# Builds Neo4j Cypher relationships between nodes
|
|
7
|
+
#
|
|
8
|
+
# Handles:
|
|
9
|
+
# - Edition relationships (monograph belongs to edition)
|
|
10
|
+
# - Cross-publisher links (sameSubstanceAs, hasEquivalentIn, similarTo)
|
|
11
|
+
# - Test specification relationships
|
|
12
|
+
class RelationshipBuilder
|
|
13
|
+
attr_reader :registry
|
|
14
|
+
|
|
15
|
+
def initialize(registry = nil)
|
|
16
|
+
@registry = registry || ModelRegistry.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Builds a Cypher relationship for edition membership
|
|
20
|
+
#
|
|
21
|
+
# @param monograph_id [String] The monograph's @id
|
|
22
|
+
# @param edition_id [String] The edition's @id
|
|
23
|
+
# @return [String] Cypher MATCH-MERGE statement
|
|
24
|
+
def build_edition_relationship(monograph_id, edition_id)
|
|
25
|
+
<<~CYPHER
|
|
26
|
+
MATCH (m:Monograph {id: #{escape(monograph_id)}})
|
|
27
|
+
MATCH (e:Edition {id: #{escape(edition_id)}})
|
|
28
|
+
MERGE (m)-[:BELONGS_TO_EDITION]->(e);
|
|
29
|
+
CYPHER
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builds a Cypher relationship for cross-publisher links
|
|
33
|
+
#
|
|
34
|
+
# @param source_id [String] Source monograph @id
|
|
35
|
+
# @param target_id [String] Target monograph @id
|
|
36
|
+
# @param rel_type [String] Relationship type
|
|
37
|
+
# @return [String] Cypher MATCH-MERGE statement
|
|
38
|
+
def build_cross_publisher_relationship(source_id, target_id, rel_type)
|
|
39
|
+
<<~CYPHER
|
|
40
|
+
MATCH (a:Monograph {id: #{escape(source_id)}})
|
|
41
|
+
MATCH (b:Monograph {id: #{escape(target_id)}})
|
|
42
|
+
MERGE (a)-[:#{rel_type}]->(b);
|
|
43
|
+
CYPHER
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Maps a JSON-LD linkType to a Neo4j relationship type
|
|
47
|
+
#
|
|
48
|
+
# @param link_type [String] The link type from JSON-LD
|
|
49
|
+
# @return [String] Neo4j relationship type
|
|
50
|
+
def map_link_type(link_type)
|
|
51
|
+
case link_type
|
|
52
|
+
when 'sameSubstanceAs' then 'SAME_SUBSTANCE_AS'
|
|
53
|
+
when 'hasEquivalentIn' then 'HAS_EQUIVALENT_IN'
|
|
54
|
+
when 'similarTo' then 'SIMILAR_TO'
|
|
55
|
+
else 'RELATED_TO'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Builds a test specification relationship
|
|
60
|
+
#
|
|
61
|
+
# @param monograph_id [String] The monograph's @id
|
|
62
|
+
# @param test_spec_id [String] The test specification's @id
|
|
63
|
+
# @return [String] Cypher MATCH-MERGE statement
|
|
64
|
+
def build_test_spec_relationship(monograph_id, test_spec_id)
|
|
65
|
+
<<~CYPHER
|
|
66
|
+
MATCH (m:Monograph {id: #{escape(monograph_id)}})
|
|
67
|
+
MATCH (t:TestSpec {id: #{escape(test_spec_id)}})
|
|
68
|
+
MERGE (m)-[:HAS_TEST_SPEC]->(t);
|
|
69
|
+
CYPHER
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Escapes a string for Neo4j Cypher
|
|
75
|
+
def escape(str)
|
|
76
|
+
return 'null' if str.nil?
|
|
77
|
+
|
|
78
|
+
escaped = str.to_s
|
|
79
|
+
.gsub('\\', '\\\\')
|
|
80
|
+
.gsub("'", "\\'")
|
|
81
|
+
|
|
82
|
+
"'#{escaped}'"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Exporters
|
|
5
|
+
module Neo4j
|
|
6
|
+
# Neo4j-specific exporters and utilities.
|
|
7
|
+
#
|
|
8
|
+
# Handles model-to-Neo4j mapping, property conversion, and Cypher generation.
|
|
9
|
+
|
|
10
|
+
autoload :ModelRegistry, 'openphar/exporters/neo4j/model_registry'
|
|
11
|
+
autoload :PropertyMapper, 'openphar/exporters/neo4j/property_mapper'
|
|
12
|
+
autoload :NodeBuilder, 'openphar/exporters/neo4j/node_builder'
|
|
13
|
+
autoload :RelationshipBuilder, 'openphar/exporters/neo4j/relationship_builder'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Openphar
|
|
7
|
+
module Exporters
|
|
8
|
+
# Main Neo4j exporter - orchestrates the export process
|
|
9
|
+
#
|
|
10
|
+
# Uses model-driven architecture with separate concerns:
|
|
11
|
+
# - Neo4j::NodeBuilder: Converts models to Cypher nodes
|
|
12
|
+
# - Neo4j::RelationshipBuilder: Builds relationships
|
|
13
|
+
# - Neo4j::PropertyMapper: Maps model attributes to Neo4j properties
|
|
14
|
+
# - Neo4j::ModelRegistry: Registry of exportable models
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# exporter = Openphar::Exporters::Neo4jExporter.new
|
|
18
|
+
# exporter.export_monographs("data/jp/crude-drugs/", "output.cypher")
|
|
19
|
+
# exporter.export_relationships("output.cypher")
|
|
20
|
+
class Neo4jExporter
|
|
21
|
+
attr_reader :output_path, :stats, :node_builder, :relationship_builder
|
|
22
|
+
|
|
23
|
+
def initialize(output_path: nil)
|
|
24
|
+
@output_path = output_path
|
|
25
|
+
@stats = { nodes: 0, relationships: 0, properties: 0 }
|
|
26
|
+
|
|
27
|
+
# Initialize model-driven components (using Neo4j sub-module)
|
|
28
|
+
registry = Neo4j::ModelRegistry.new
|
|
29
|
+
mapper = Neo4j::PropertyMapper.new(registry)
|
|
30
|
+
@node_builder = Neo4j::NodeBuilder.new(mapper)
|
|
31
|
+
@relationship_builder = Neo4j::RelationshipBuilder.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Export all monograph files in a directory
|
|
35
|
+
#
|
|
36
|
+
# @param directory [String] Path to directory containing JSON-LD files
|
|
37
|
+
# @param output_path [String] Path for output Cypher file
|
|
38
|
+
# @param use_merge [Boolean] Use MERGE instead of CREATE (default: false)
|
|
39
|
+
def export_monographs(directory, output_path = nil, use_merge: false)
|
|
40
|
+
@output_path = output_path || @output_path
|
|
41
|
+
@use_merge = use_merge
|
|
42
|
+
reset_stats
|
|
43
|
+
|
|
44
|
+
File.open(@output_path, "w") do |f|
|
|
45
|
+
f.puts "// Neo4j Cypher Export - Open Pharmacopoeia"
|
|
46
|
+
f.puts "// Generated: #{Time.now.iso8601}"
|
|
47
|
+
f.puts "// Import method: #{use_merge ? 'MERGE (idempotent)' : 'CREATE'}"
|
|
48
|
+
f.puts ""
|
|
49
|
+
f.puts "// BEGIN TRANSACTION"
|
|
50
|
+
f.puts "BEGIN;"
|
|
51
|
+
|
|
52
|
+
Dir.glob(File.join(directory, "**", "*.jsonld")).sort.each do |file|
|
|
53
|
+
puts "Processing: #{file}"
|
|
54
|
+
process_file(file, f)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
f.puts "COMMIT;"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "\nExport complete:"
|
|
61
|
+
puts " Nodes: #{@stats[:nodes]}"
|
|
62
|
+
puts " Relationships: #{@stats[:relationships]}"
|
|
63
|
+
puts " Properties: #{@stats[:properties]}"
|
|
64
|
+
|
|
65
|
+
@output_path
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Export relationships between entities
|
|
69
|
+
#
|
|
70
|
+
# @param output_path [String] Path for relationship Cypher
|
|
71
|
+
def export_relationships(output_path = nil)
|
|
72
|
+
output_path ||= @output_path.sub(".cypher", "_rels.cypher")
|
|
73
|
+
|
|
74
|
+
File.open(output_path, "w") do |f|
|
|
75
|
+
f.puts "// Neo4j Relationships - Open Pharmacopoeia"
|
|
76
|
+
f.puts "// Generated: #{Time.now.iso8601}"
|
|
77
|
+
f.puts ""
|
|
78
|
+
f.puts "// BEGIN TRANSACTION"
|
|
79
|
+
f.puts "BEGIN;"
|
|
80
|
+
|
|
81
|
+
# Belongs to Edition
|
|
82
|
+
f.puts "\n// Edition relationships"
|
|
83
|
+
Dir.glob("data/jp/**/*.jsonld").sort.each do |file|
|
|
84
|
+
data = JSON.parse(File.read(file))
|
|
85
|
+
edition_id = data["belongsToEdition"]
|
|
86
|
+
next unless edition_id
|
|
87
|
+
|
|
88
|
+
monograph_id = data["@id"] || data["id"]
|
|
89
|
+
next unless monograph_id
|
|
90
|
+
|
|
91
|
+
f.puts @relationship_builder.build_edition_relationship(monograph_id, edition_id)
|
|
92
|
+
@stats[:relationships] += 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Cross-publisher links
|
|
96
|
+
f.puts "\n// Cross-publisher relationships"
|
|
97
|
+
Dir.glob("data/cross-publisher/*.jsonld").sort.each do |file|
|
|
98
|
+
data = JSON.parse(File.read(file))
|
|
99
|
+
(data["@graph"] || [data]).each do |link|
|
|
100
|
+
next unless link["linkType"]
|
|
101
|
+
|
|
102
|
+
source = link.dig("sourceMonograph", "@id")
|
|
103
|
+
target = link.dig("targetMonograph", "@id")
|
|
104
|
+
next unless source && target
|
|
105
|
+
|
|
106
|
+
rel_type = @relationship_builder.map_link_type(link["linkType"])
|
|
107
|
+
f.puts @relationship_builder.build_cross_publisher_relationship(source, target, rel_type)
|
|
108
|
+
@stats[:relationships] += 1
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
f.puts "COMMIT;"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
puts "\nRelationships export complete:"
|
|
116
|
+
puts " Relationships: #{@stats[:relationships]}"
|
|
117
|
+
puts " Output: #{output_path}"
|
|
118
|
+
|
|
119
|
+
output_path
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Export to CSV for Neo4j Admin Import
|
|
123
|
+
#
|
|
124
|
+
# @param directory [String] Directory with JSON-LD files
|
|
125
|
+
# @param nodes_path [String] Output path for nodes CSV
|
|
126
|
+
# @param rels_path [String] Output path for relationships CSV
|
|
127
|
+
def export_csv(directory, nodes_path, rels_path)
|
|
128
|
+
nodes_csv = CSV.open(nodes_path, "w")
|
|
129
|
+
rels_csv = CSV.open(rels_path, "w")
|
|
130
|
+
|
|
131
|
+
# Node header
|
|
132
|
+
nodes_csv << [:id, :type, :monograph_id, :publisher, :status, :label_en, :label_ja]
|
|
133
|
+
|
|
134
|
+
# Relationship header
|
|
135
|
+
rels_csv << [:start_id, :end_id, :type, :properties]
|
|
136
|
+
|
|
137
|
+
Dir.glob(File.join(directory, "**", "*.jsonld")).sort.each do |file|
|
|
138
|
+
data = JSON.parse(File.read(file))
|
|
139
|
+
next unless data["@id"]
|
|
140
|
+
|
|
141
|
+
id = data["@id"]
|
|
142
|
+
type = data["@type"]
|
|
143
|
+
node_type = node_builder.registry.node_type_for(type)
|
|
144
|
+
|
|
145
|
+
pref_label = data["prefLabel"] || {}
|
|
146
|
+
label_en = pref_label["en"] || ""
|
|
147
|
+
label_ja = pref_label["ja"] || ""
|
|
148
|
+
|
|
149
|
+
nodes_csv << [
|
|
150
|
+
id,
|
|
151
|
+
node_type,
|
|
152
|
+
data["monographId"] || "",
|
|
153
|
+
data["publisher"] || "",
|
|
154
|
+
data["status"] || "",
|
|
155
|
+
label_en,
|
|
156
|
+
label_ja
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
@stats[:nodes] += 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
nodes_csv.close
|
|
163
|
+
rels_csv.close
|
|
164
|
+
|
|
165
|
+
puts "\nCSV Export complete:"
|
|
166
|
+
puts " Nodes: #{@stats[:nodes]}"
|
|
167
|
+
puts " Nodes CSV: #{nodes_path}"
|
|
168
|
+
puts " Rels CSV: #{rels_path}"
|
|
169
|
+
|
|
170
|
+
{ nodes: nodes_path, rels: rels_path }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def reset_stats
|
|
176
|
+
@stats = { nodes: 0, relationships: 0, properties: 0 }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def process_file(file, output)
|
|
180
|
+
data = JSON.parse(File.read(file))
|
|
181
|
+
return unless data["@id"]
|
|
182
|
+
|
|
183
|
+
# Handle @graph format
|
|
184
|
+
items = data["@graph"] || [data]
|
|
185
|
+
|
|
186
|
+
items.each do |item|
|
|
187
|
+
next unless item["@id"]
|
|
188
|
+
|
|
189
|
+
# Use model-driven node builder
|
|
190
|
+
cypher = node_builder.build(item, use_merge: @use_merge)
|
|
191
|
+
output.puts cypher
|
|
192
|
+
|
|
193
|
+
@stats[:nodes] += 1
|
|
194
|
+
@stats[:properties] += node_builder.property_count(item)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Exporters
|
|
5
|
+
# Exporters for outputting data in various formats.
|
|
6
|
+
#
|
|
7
|
+
# Handles export to Neo4j, CSV, and other formats.
|
|
8
|
+
|
|
9
|
+
autoload :Neo4jExporter, 'openphar/exporters/neo4j_exporter'
|
|
10
|
+
autoload :CoverageValidator, 'openphar/exporters/coverage_validator'
|
|
11
|
+
|
|
12
|
+
# Neo4j sub-module (autoloads its own children)
|
|
13
|
+
autoload :Neo4j, 'openphar/exporters/neo4j'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Openphar
|
|
6
|
+
module Linkers
|
|
7
|
+
module Chp
|
|
8
|
+
# Links the same monograph across two ChP editions via `sameSubstanceAs`.
|
|
9
|
+
#
|
|
10
|
+
# The matcher uses `(book_id, normalized pinyin_title)` as the match
|
|
11
|
+
# key — pinyin is publisher-assigned, stable across editions, and
|
|
12
|
+
# disambiguates within volume. When two editions have a monograph with
|
|
13
|
+
# the same key, both IRIs are emitted as `sameSubstanceAs` targets.
|
|
14
|
+
#
|
|
15
|
+
# Input: two enumerables of monographs (anything that responds to
|
|
16
|
+
# `pinyin_title`, `book_id`, `id`). Output: a hash of
|
|
17
|
+
# `{source_iri => [target_iris]}` representing the bidirectional links.
|
|
18
|
+
#
|
|
19
|
+
class CrossEditionLinker
|
|
20
|
+
attr_reader :matches, :stats
|
|
21
|
+
|
|
22
|
+
# @param edition_a [Enumerable<Openphar::Models::Chp::Monograph>]
|
|
23
|
+
# @param edition_b [Enumerable<Openphar::Models::Chp::Monograph>]
|
|
24
|
+
def initialize(edition_a:, edition_b:)
|
|
25
|
+
@edition_a = edition_a
|
|
26
|
+
@edition_b = edition_b
|
|
27
|
+
@matches = {}
|
|
28
|
+
@stats = { matched: 0, unmatched_a: 0, unmatched_b: 0, multi_match: 0 }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def link
|
|
32
|
+
index_b = build_index(@edition_b)
|
|
33
|
+
index_a = build_index(@edition_a)
|
|
34
|
+
|
|
35
|
+
# Match A -> B
|
|
36
|
+
index_a.each do |key, iris_a|
|
|
37
|
+
iris_b = index_b[key]
|
|
38
|
+
next unless iris_b
|
|
39
|
+
|
|
40
|
+
if iris_b.size > 1
|
|
41
|
+
@stats[:multi_match] += 1
|
|
42
|
+
next # ambiguous: don't link
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
iri_a = iris_a.first
|
|
46
|
+
iri_b = iris_b.first
|
|
47
|
+
@matches[iri_a] = [iri_b]
|
|
48
|
+
@matches[iri_b] = [iri_a]
|
|
49
|
+
@stats[:matched] += 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@stats[:unmatched_a] = index_a.size - @stats[:matched]
|
|
53
|
+
@stats[:unmatched_b] = index_b.size - @stats[:matched]
|
|
54
|
+
@matches
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Serialize links as a JSON-LD graph document.
|
|
58
|
+
# @return [Hash] JSON-serializable graph.
|
|
59
|
+
def to_jsonld
|
|
60
|
+
graph = @matches.map do |source_iri, targets|
|
|
61
|
+
{
|
|
62
|
+
"@id" => source_iri,
|
|
63
|
+
"sameSubstanceAs" => targets,
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
{
|
|
67
|
+
"@context" => "https://www.openphar.org/ontology/context/chp.jsonld",
|
|
68
|
+
"@type" => "LinkSet",
|
|
69
|
+
"linkType" => "sameSubstanceAs",
|
|
70
|
+
"matchCount" => @stats[:matched],
|
|
71
|
+
"@graph" => graph,
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build_index(edition)
|
|
78
|
+
index = Hash.new { |h, k| h[k] = [] }
|
|
79
|
+
edition.each do |monograph|
|
|
80
|
+
key = match_key(monograph)
|
|
81
|
+
next unless key
|
|
82
|
+
|
|
83
|
+
iri = monograph.respond_to?(:iri) ? monograph.iri : monograph.id
|
|
84
|
+
index[key] << iri if iri
|
|
85
|
+
end
|
|
86
|
+
index
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def match_key(monograph)
|
|
90
|
+
pinyin = monograph.pinyin_title
|
|
91
|
+
return nil unless pinyin && !pinyin.empty?
|
|
92
|
+
|
|
93
|
+
normalized = pinyin.downcase.gsub(/[^a-z0-9]/, "")
|
|
94
|
+
return nil if normalized.empty?
|
|
95
|
+
|
|
96
|
+
"#{monograph.book_id}:#{normalized}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|