metanorma-mko 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d552e397dd8ed669ba8766736e4fd97621ef7b3203598b162ff989c84691a59
4
- data.tar.gz: 0d1babd1f3cfcecf0aa61055a4798bb91edcd7847c9936a624cbbcfe9dec45a1
3
+ metadata.gz: f884af7c676c4b65e7f157fc580605171ed9e35b857ea6ab6407cc158a6cfd5f
4
+ data.tar.gz: c01a9a9cf36a2ba2c933007605e550e3354193516798f213ebc584522ff9e4f0
5
5
  SHA512:
6
- metadata.gz: bbe6e4797d6290ca828ed59c124d3b452d2b9a10f7ca59533e18c23be2f42316da55edbd14d2dbd88a8c9636659b6c15b7bde605c2cf1205a0f8a0909f9d034c
7
- data.tar.gz: 6fa2e380044eecad731b5258b7c424651a815a3a0b6522ae47699d97e037ae121a49e770d8982bf5af89645ae5f2d0559f11a28d6ee31e5ed21074903406af07
6
+ metadata.gz: 45c3f2a3c7226226a761c0684f8df548981c12ee2229f3f8ab8c37da42b1dc5c82613bda4c8ada865dceac397f550ba40d86fcc6330176c2f259bb1fd9ba1e81
7
+ data.tar.gz: 3b9e6249e7c78fd40010ea691ff92a152d1923eb07267f8346871cfd25fa808d29b87fdc8c9995552a86f7ed246018e5e6c0d30502204af783feb22c307072de
data/README.adoc CHANGED
@@ -17,6 +17,14 @@ The FORMAT contract:
17
17
  * the generated JSON Schemas — `Mko.json_schemas` (the schema classes
18
18
  are the single source of truth; TS/Python consumers validate against
19
19
  these, never hand-rolled copies);
20
+ * language resolution (`Mko::Language`) — W3C XML §2.12 nearest
21
+ ancestor-or-self `xml:lang`, then an honest stopword-ratio langid
22
+ (en/fr/es/de) over the element's own text, then the declared
23
+ document language, then an explicit `en` fallback. Every resolution
24
+ reports its provenance (`markup` / `heuristic` / `default` /
25
+ `fallback`); units carry it as `lang_source`. Consumer-side of
26
+ metanorma-standoc#1243: element-level `xml:lang` wins automatically
27
+ the day the converter emits it;
20
28
  * the reference MCP server over any conforming bundle —
21
29
  `Mko::Mcp::Server` (search_units / get_unit / walk_edges /
22
30
  edition_diff; JSON-RPC 2.0 over stdio).
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mko
5
+ # Language resolution for knowledge objects (#53 item 3),
6
+ # consumer-side of metanorma/metanorma-standoc#1243: standoc's
7
+ # converter does not emit element-level xml:lang and the models do
8
+ # not map it, so producer markup today cannot tell a translated
9
+ # annex from the body. Resolution follows W3C XML §2.12 — xml:lang
10
+ # is inheritable, an element's effective language is its nearest
11
+ # ancestor-or-self xml:lang — extended with an honest fallback
12
+ # chain for corpora whose markup carries no language at all:
13
+ #
14
+ # 1. "markup" — nearest ancestor-or-self xml:lang. Element-
15
+ # level attributes win automatically the day
16
+ # standoc#1243 lands; a root xml:lang covers
17
+ # every descendant until then.
18
+ # 2. "heuristic" — stopword-ratio langid over the element's own
19
+ # text (first SAMPLE_WORDS words; en/fr/es/de —
20
+ # the same heuristic the RAG consumer adopts).
21
+ # Fires only when NO xml:lang is in scope; an
22
+ # inconclusive sample scores nil, never an en
23
+ # bias. This is what tags a French terminology
24
+ # annex inside an EN edition as fr.
25
+ # 3. "default" — the document's declared language (the bibdata
26
+ # language, or the caller's document default).
27
+ # 4. "fallback" — DEFAULT_LANG with an explicit "fallback"
28
+ # source: nothing declared, nothing detected.
29
+ #
30
+ # Every Resolution carries its provenance in source — consumers
31
+ # can always tell markup-tagged language from a detected one.
32
+ module Language
33
+ # lang: a BCP 47 tag as declared ("en", "fr", ...); source:
34
+ # "markup" | "heuristic" | "default" | "fallback".
35
+ Resolution = Struct.new(:lang, :source, keyword_init: true)
36
+
37
+ # Assumed only when nothing is declared and detection fails —
38
+ # always reported with source "fallback", never silently.
39
+ DEFAULT_LANG = "en"
40
+
41
+ # The langid sample: the consumer contract is the first ~200
42
+ # words of the element's own text.
43
+ SAMPLE_WORDS = 200
44
+
45
+ # Honesty guards: below MIN_WORDS sampled words or MIN_HITS
46
+ # stopword hits there is no evidence — detect returns nil and
47
+ # the precedence chain falls through instead of guessing.
48
+ MIN_WORDS = 10
49
+ MIN_HITS = 3
50
+
51
+ # High-frequency function words per language (en/fr/es/de — the
52
+ # consumer's coverage need). Flat frequency lists, no weighting:
53
+ # every language scores over the same sample, so raw hit counts
54
+ # are the ratios. Collisions ("la" is fr+es) wash out over a
55
+ # real sample; the French single letters come from elision
56
+ # splits (l'annexe -> "l"), which EN/DE/ES prose never yields.
57
+ STOPWORDS = {
58
+ "en" => %w[
59
+ the of and to in is are that for with as this be on by at from
60
+ or an not it its shall should must may will can which
61
+ ],
62
+ "fr" => %w[
63
+ le la les des de du un une et est sont dans pour avec sur au
64
+ aux par pas plus qui que ce cet cette ces être avoir selon
65
+ doit doivent entre comme tout toute tous l d qu
66
+ ],
67
+ "es" => %w[
68
+ el la los las de del un una es son en para con por que no más
69
+ como se su sus al lo este esta estos estas y debe deben según
70
+ entre también sobre ser está están
71
+ ],
72
+ "de" => %w[
73
+ der die das den dem des und ist in im zu mit für auf von ein
74
+ eine einer einem einen nicht sind oder bei nach über unter
75
+ soll sollen müssen kann können wird werden auch als durch
76
+ ],
77
+ }.freeze
78
+
79
+ XML_NS = "http://www.w3.org/XML/1998/namespace"
80
+
81
+ class << self
82
+ # The element-level machine. own: the element's own xml:lang
83
+ # (nil where models don't map it). inherited: the enclosing
84
+ # scope's Resolution (the nearest ancestor, already resolved).
85
+ # default: the document-level Resolution (or a bare tag).
86
+ def resolve(own: nil, text: nil, inherited: nil, default: nil)
87
+ own = normalize(own)
88
+ return Resolution.new(lang: own, source: "markup") if own
89
+ # xml:lang in scope: the nearest ancestor's markup stands and
90
+ # the heuristic stays off (W3C §2.12).
91
+ return inherited if inherited&.source == "markup"
92
+
93
+ detected = detect(text)
94
+ return Resolution.new(lang: detected, source: "heuristic") if detected
95
+
96
+ inherited || coerce(default) || fallback_resolution
97
+ end
98
+
99
+ # The document-level machine: the root xml:lang when carried,
100
+ # else the declared document language (bibdata), else
101
+ # detection over the document's own text, else the fallback.
102
+ def document(markup: nil, declared: nil, text: nil)
103
+ markup = normalize(markup)
104
+ return Resolution.new(lang: markup, source: "markup") if markup
105
+
106
+ declared = normalize(declared)
107
+ return Resolution.new(lang: declared, source: "default") if declared
108
+
109
+ detected = detect(text)
110
+ return Resolution.new(lang: detected, source: "heuristic") if detected
111
+
112
+ fallback_resolution
113
+ end
114
+
115
+ # XML side (Nokogiri node): full §2.12 nearest
116
+ # ancestor-or-self over the live tree, then the element's own
117
+ # text, then the document default, then the fallback.
118
+ def for_node(node, default: nil)
119
+ markup = [node, *node.ancestors].filter_map do |n|
120
+ xml_lang_of(n)
121
+ end.first
122
+ return Resolution.new(lang: markup, source: "markup") if markup
123
+
124
+ detected = detect(node.text)
125
+ return Resolution.new(lang: detected, source: "heuristic") if detected
126
+
127
+ coerce(default) || fallback_resolution
128
+ end
129
+
130
+ # XML side, document level: the root element's xml:lang, the
131
+ # declared language, the document's own text, the fallback.
132
+ def for_document(root, declared: nil)
133
+ document(markup: xml_lang_of(root), declared: declared,
134
+ text: root.text)
135
+ end
136
+
137
+ # Stopword-ratio langid over the first SAMPLE_WORDS words of
138
+ # text. Honest scoring: nil when the sample is too small, has
139
+ # too few hits, or ties — never a default-to-en guess.
140
+ def detect(text)
141
+ words = text.to_s.downcase.gsub(/['’]/, " ")
142
+ .scan(/[[:alpha:]]+/).first(SAMPLE_WORDS)
143
+ return nil if words.size < MIN_WORDS
144
+
145
+ scores = STOPWORDS.transform_values do |list|
146
+ words.count { |word| list.include?(word) }
147
+ end
148
+ lang, hits = scores.max_by { |_, n| n }
149
+ return nil if hits < MIN_HITS
150
+ return nil if scores.any? { |l, n| l != lang && n == hits }
151
+
152
+ lang
153
+ end
154
+
155
+ # A tag worth believing: stripped, downcased (BCP 47 tags are
156
+ # case-insensitive); "" and "und" carry no information (XML
157
+ # §2.12), so inheritance keeps walking.
158
+ def normalize(tag)
159
+ tag = tag.to_s.strip.downcase
160
+ tag.empty? || tag == "und" ? nil : tag
161
+ end
162
+
163
+ private
164
+
165
+ def xml_lang_of(node)
166
+ attr = node.attribute_with_ns("lang", XML_NS)
167
+ return normalize(attr.value) if attr
168
+
169
+ # namespace-stripped trees keep a literal "xml:lang" name
170
+ normalize(node["xml:lang"])
171
+ rescue NoMethodError
172
+ nil
173
+ end
174
+
175
+ def coerce(value)
176
+ return value if value.is_a?(Resolution)
177
+
178
+ tag = normalize(value)
179
+ tag && Resolution.new(lang: tag, source: "default")
180
+ end
181
+
182
+ # The explicit last resort: undeclared and undetectable.
183
+ # source "fallback" says so — never a silent en.
184
+ def fallback_resolution
185
+ Resolution.new(lang: DEFAULT_LANG, source: "fallback")
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -190,6 +190,12 @@ module Metanorma
190
190
  attribute :breadcrumb, :string, collection: true, default: -> { [] }
191
191
  attribute :obligation, :string
192
192
  attribute :lang, :string
193
+ # provenance of lang (#53 item 3, Mko::Language): "markup"
194
+ # (xml:lang in scope), "heuristic" (detected from the unit's
195
+ # own text), "default" (the declared document language),
196
+ # "fallback" (undeclared and undetectable). Optional so
197
+ # pre-resolution bundles stay schema-valid.
198
+ attribute :lang_source, :string
193
199
  # authoring-time situating note (#53 item 7): AI-assisted,
194
200
  # editor-approved, versioned with the document. Wire-ready —
195
201
  # emitted the day the authoring system ships it.
@@ -210,6 +216,7 @@ module Metanorma
210
216
  map "breadcrumb", to: :breadcrumb
211
217
  map "obligation", to: :obligation
212
218
  map "lang", to: :lang
219
+ map "lang_source", to: :lang_source, render_nil: false
213
220
  map "ai_note", to: :ai_note, render_nil: false
214
221
  map "text", to: :text
215
222
  map "payload", to: :payload
@@ -5,6 +5,6 @@ module Metanorma
5
5
  # Gem version — kept dependency-free so gemspecs can read it
6
6
  # without activating the model stack. Tracks the MN 116 schema
7
7
  # version: the wire contract and the gem release together.
8
- VERSION = "1.0.0"
8
+ VERSION = "1.1.0"
9
9
  end
10
10
  end
data/lib/metanorma/mko.rb CHANGED
@@ -29,6 +29,7 @@ module Metanorma
29
29
  autoload :Diff, "metanorma/mko/diff"
30
30
  autoload :Mcp, "metanorma/mko/mcp"
31
31
  autoload :Units, "metanorma/mko/units"
32
+ autoload :Language, "metanorma/mko/language"
32
33
 
33
34
  autoload :VERSION, "metanorma/mko/version"
34
35
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma-mko
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -111,6 +111,7 @@ files:
111
111
  - lib/metanorma/mko/bundle.rb
112
112
  - lib/metanorma/mko/diff.rb
113
113
  - lib/metanorma/mko/export.rb
114
+ - lib/metanorma/mko/language.rb
114
115
  - lib/metanorma/mko/mcp.rb
115
116
  - lib/metanorma/mko/result.rb
116
117
  - lib/metanorma/mko/schema.rb