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,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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Openphar
4
+ module Linkers
5
+ # Chinese Pharmacopoeia linkers.
6
+ #
7
+ module Chp
8
+ autoload :CrossEditionLinker, "openphar/linkers/chp/cross_edition_linker"
9
+ end
10
+ end
11
+ end