yanagi 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/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/data/combinatorial.yml +59 -0
- data/data/exceptions.yml +15 -0
- data/data/exonyms.yml +14 -0
- data/data/mora.yml +790 -0
- data/exe/yanagi +7 -0
- data/lib/yanagi/audit.rb +301 -0
- data/lib/yanagi/cli.rb +345 -0
- data/lib/yanagi/cyrillic.rb +310 -0
- data/lib/yanagi/doc_sync.rb +194 -0
- data/lib/yanagi/lexicon.rb +211 -0
- data/lib/yanagi/mora.rb +81 -0
- data/lib/yanagi/normalize.rb +26 -0
- data/lib/yanagi/romaji.rb +55 -0
- data/lib/yanagi/rules.rb +89 -0
- data/lib/yanagi/version.rb +5 -0
- data/lib/yanagi.rb +15 -0
- data/yanagi.gemspec +34 -0
- metadata +94 -0
data/exe/yanagi
ADDED
data/lib/yanagi/audit.rb
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "rules"
|
|
5
|
+
require_relative "normalize"
|
|
6
|
+
require_relative "lexicon"
|
|
7
|
+
require_relative "cyrillic"
|
|
8
|
+
|
|
9
|
+
module Yanagi
|
|
10
|
+
module Audit
|
|
11
|
+
JP_MARKERS = %w[
|
|
12
|
+
ші чі джі ґ
|
|
13
|
+
дза дзу дзе дзо
|
|
14
|
+
шя шю шьо
|
|
15
|
+
чя чю чьо
|
|
16
|
+
джя джю джьо
|
|
17
|
+
кя кю кьо
|
|
18
|
+
ря рю рьо
|
|
19
|
+
ня ню ньо
|
|
20
|
+
хя хю хьо
|
|
21
|
+
мя мю мьо
|
|
22
|
+
бя бю бьо
|
|
23
|
+
пя пю пьо
|
|
24
|
+
ґя ґю ґьо
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
POLIVANOV_MARKERS = %w[
|
|
28
|
+
сі ті дзі зі
|
|
29
|
+
ся сю сьо
|
|
30
|
+
тя тю тьо
|
|
31
|
+
дзя дзю дзьо зя зю зьо
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
Finding = Struct.new(
|
|
35
|
+
:file,
|
|
36
|
+
:line,
|
|
37
|
+
:col,
|
|
38
|
+
:token,
|
|
39
|
+
:tier,
|
|
40
|
+
:message,
|
|
41
|
+
:canonical,
|
|
42
|
+
:approved,
|
|
43
|
+
keyword_init: true
|
|
44
|
+
) do
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
"file" => file,
|
|
48
|
+
"line" => line,
|
|
49
|
+
"col" => col,
|
|
50
|
+
"token" => token,
|
|
51
|
+
"tier" => tier,
|
|
52
|
+
"message" => message,
|
|
53
|
+
"canonical" => canonical,
|
|
54
|
+
"approved" => approved || false
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.allowlist
|
|
60
|
+
@allowlist ||= begin
|
|
61
|
+
data = Rules.native_ua_allowlist
|
|
62
|
+
data.is_a?(Array) ? Set.new(data.map(&:to_s).map(&:downcase)) : Set.new
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.reload_allowlist!
|
|
67
|
+
@allowlist = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.scan_text(text, file_path: nil, tier2: true, tier3: false)
|
|
71
|
+
findings = []
|
|
72
|
+
allow = allowlist
|
|
73
|
+
lines = text.lines
|
|
74
|
+
|
|
75
|
+
lines.each_with_index do |line_text, line_idx|
|
|
76
|
+
line_text.scan(/(?<![\w\p{Cyrillic}])([\p{Cyrillic}'’\`-]+)(?![\w\p{Cyrillic}])/) do
|
|
77
|
+
match_data = Regexp.last_match
|
|
78
|
+
raw_tok = match_data[1]
|
|
79
|
+
col = match_data.begin(0) + 1
|
|
80
|
+
|
|
81
|
+
clean = raw_tok.downcase.gsub(/^[-'\`"]+|[-'\`"]+$/, "")
|
|
82
|
+
next if clean.length < 2
|
|
83
|
+
|
|
84
|
+
# 1. Skip if allowlisted native Ukrainian word
|
|
85
|
+
next if allow.include?(clean)
|
|
86
|
+
stem, _ = Lexicon.strip_inflection(clean)
|
|
87
|
+
next if stem.length >= 3 && allow.include?(stem)
|
|
88
|
+
|
|
89
|
+
# 2. Tier 1 Check (AUTOFIX-eligible)
|
|
90
|
+
# Does this un-allowlisted token match a lexicon entry via forbidden substitution?
|
|
91
|
+
t1 = check_tier1(raw_tok, clean, file_path, line_idx + 1, col)
|
|
92
|
+
if t1
|
|
93
|
+
findings << t1
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# 3. Skip if known in lexicon
|
|
98
|
+
lex_match = Lexicon.find_by_stem(clean)
|
|
99
|
+
next if lex_match
|
|
100
|
+
|
|
101
|
+
# 4. Tier 2 Check (JP markers present, absent from allowlist & lexicon)
|
|
102
|
+
if tier2 && has_jp_markers?(clean)
|
|
103
|
+
findings << Finding.new(
|
|
104
|
+
file: file_path,
|
|
105
|
+
line: line_idx + 1,
|
|
106
|
+
col: col,
|
|
107
|
+
token: raw_tok,
|
|
108
|
+
tier: 2,
|
|
109
|
+
message: "Contains Japanese transliteration markers; unanchored in lexicon",
|
|
110
|
+
canonical: nil,
|
|
111
|
+
approved: false
|
|
112
|
+
)
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 5. Tier 3 Check (Polivanov markers present, off by default)
|
|
117
|
+
if tier3 && has_polivanov_markers?(clean)
|
|
118
|
+
findings << Finding.new(
|
|
119
|
+
file: file_path,
|
|
120
|
+
line: line_idx + 1,
|
|
121
|
+
col: col,
|
|
122
|
+
token: raw_tok,
|
|
123
|
+
tier: 3,
|
|
124
|
+
message: "Contains Polivanov digraph marker; expected mostly noise",
|
|
125
|
+
canonical: nil,
|
|
126
|
+
approved: false
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
findings
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.scan(paths, tier2: true, tier3: false)
|
|
136
|
+
paths = [paths] unless paths.is_a?(Array)
|
|
137
|
+
all_findings = []
|
|
138
|
+
|
|
139
|
+
paths.each do |p|
|
|
140
|
+
if File.directory?(p)
|
|
141
|
+
Dir.glob(File.join(p, "**", "*.{org,md,txt}")).each do |f|
|
|
142
|
+
next if f.include?("glossary.org") || f.include?("transliteration.md")
|
|
143
|
+
content = File.read(f, encoding: "UTF-8")
|
|
144
|
+
all_findings.concat(scan_text(content, file_path: f, tier2: tier2, tier3: tier3))
|
|
145
|
+
end
|
|
146
|
+
elsif File.file?(p)
|
|
147
|
+
content = File.read(p, encoding: "UTF-8")
|
|
148
|
+
all_findings.concat(scan_text(content, file_path: p, tier2: tier2, tier3: tier3))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
all_findings
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.apply(findings_file)
|
|
156
|
+
data = YAML.safe_load_file(findings_file, permitted_classes: [Symbol, Date], symbolize_names: true) || []
|
|
157
|
+
data = data[:findings] if data.is_a?(Hash) && data.key?(:findings)
|
|
158
|
+
data = [data] unless data.is_a?(Array)
|
|
159
|
+
|
|
160
|
+
by_file = {}
|
|
161
|
+
data.each do |f|
|
|
162
|
+
next unless f[:approved] == true && f[:canonical] && !f[:canonical].empty? && f[:file]
|
|
163
|
+
by_file[f[:file]] ||= []
|
|
164
|
+
by_file[f[:file]] << f
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
applied_count = 0
|
|
168
|
+
|
|
169
|
+
by_file.each do |file_path, file_findings|
|
|
170
|
+
next unless File.exist?(file_path)
|
|
171
|
+
content = File.read(file_path, encoding: "UTF-8")
|
|
172
|
+
|
|
173
|
+
file_findings.sort_by { |f| [-f[:line].to_i, -f[:col].to_i] }.each do |f|
|
|
174
|
+
tok = f[:token]
|
|
175
|
+
canon = f[:canonical]
|
|
176
|
+
replacement = if tok == tok.upcase
|
|
177
|
+
canon.upcase
|
|
178
|
+
elsif tok == tok.capitalize
|
|
179
|
+
canon.capitalize
|
|
180
|
+
else
|
|
181
|
+
canon
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if content.include?(tok)
|
|
185
|
+
content = content.sub(tok, replacement)
|
|
186
|
+
applied_count += 1
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
File.write(file_path, content, encoding: "UTF-8")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
applied_count
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.check_tier1(raw_tok, clean_tok, file_path, line, col)
|
|
197
|
+
mora_map = Rules.mora_map
|
|
198
|
+
return nil unless mora_map
|
|
199
|
+
return nil if clean_tok.length < Lexicon::MIN_STEM_LENGTH
|
|
200
|
+
|
|
201
|
+
mora_map.each do |_kana, data|
|
|
202
|
+
forbidden_list = data[:forbidden] || []
|
|
203
|
+
canonical_cyr = data[:cyrillic]&.to_s
|
|
204
|
+
next unless canonical_cyr && !forbidden_list.empty?
|
|
205
|
+
|
|
206
|
+
forbidden_list.each do |forb|
|
|
207
|
+
next unless clean_tok.include?(forb)
|
|
208
|
+
|
|
209
|
+
candidate = clean_tok.gsub(forb, canonical_cyr)
|
|
210
|
+
lex_match = Lexicon.find_by_stem(candidate)
|
|
211
|
+
|
|
212
|
+
# Japanese mora rules apply only to Japanese-origin terms. Chinese works
|
|
213
|
+
# and place names in the glossary carry their own transliteration.
|
|
214
|
+
next if lex_match && lex_match[:entry] && lex_match[:entry][:origin].to_s == "zh"
|
|
215
|
+
|
|
216
|
+
if lex_match && lex_match[:stem].length >= Lexicon::MIN_STEM_LENGTH
|
|
217
|
+
canonical_word = if raw_tok == raw_tok.capitalize
|
|
218
|
+
candidate.capitalize
|
|
219
|
+
elsif raw_tok == raw_tok.upcase
|
|
220
|
+
candidate.upcase
|
|
221
|
+
else
|
|
222
|
+
candidate
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
return Finding.new(
|
|
226
|
+
file: file_path,
|
|
227
|
+
line: line,
|
|
228
|
+
col: col,
|
|
229
|
+
token: raw_tok,
|
|
230
|
+
tier: 1,
|
|
231
|
+
message: "Forbidden transliteration '#{forb}' for '#{canonical_cyr}' matching lexicon '#{lex_match[:key]}'",
|
|
232
|
+
canonical: canonical_word,
|
|
233
|
+
approved: true
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.has_jp_markers?(text)
|
|
243
|
+
JP_MARKERS.any? { |m| text.include?(m) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.has_polivanov_markers?(text)
|
|
247
|
+
POLIVANOV_MARKERS.any? { |m| text.include?(m) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.build_allowlist_from_corpus(corpus_dir, out_path: nil)
|
|
251
|
+
allow = Set.new
|
|
252
|
+
|
|
253
|
+
seed = %w[
|
|
254
|
+
інші наші ваші перші більші менші кращі довші вищі нижчі тиші чаші аркуші гроші душі пізніші давніші старші молодші зовнішні свіжіші
|
|
255
|
+
вночі очі плечі ключі речі ночі двічі тричі уночі поночі тисячі зустрічі чіпляти
|
|
256
|
+
бджіл бджілка джунглі джерело джерела джерел
|
|
257
|
+
ґанок ґрунт ґудзик ґава ґрати дзиґа ґвалт ґречний ґедзь
|
|
258
|
+
дзвонити дзвін дзвінок дзвіночок дзеркало дзеркальний дзьоб кукурудза дзенькіт дзвеніти задзвеніти
|
|
259
|
+
сірник сірники зовсім вісім досі сіль сільський сусід сусідка сусіди сіно січень сірий сідати сісти засідання весілля постійний постійно
|
|
260
|
+
партії кімнаті статті миті житті святі почутті тяглості тіло тінь тікати тітка тільки потім тієї тією
|
|
261
|
+
зір зірка зірки зібрати зіграти зійти поїздці нозі дорозі книзі підлозі зілля
|
|
262
|
+
сьогодні сьомий всього всьому третього цього якому того цього
|
|
263
|
+
]
|
|
264
|
+
seed.each { |w| allow << w.downcase }
|
|
265
|
+
|
|
266
|
+
if Dir.exist?(corpus_dir)
|
|
267
|
+
books_dir = File.join(corpus_dir, "books")
|
|
268
|
+
shared_dir = File.join(corpus_dir, "shared")
|
|
269
|
+
files = []
|
|
270
|
+
files.concat(Dir.glob(File.join(books_dir, "**", "*.{org,md,txt}"))) if Dir.exist?(books_dir)
|
|
271
|
+
files.concat(Dir.glob(File.join(shared_dir, "**", "*.{org,md,txt}"))) if Dir.exist?(shared_dir)
|
|
272
|
+
files.reject! { |f| f.include?("glossary.org") || f.include?("transliteration.md") }
|
|
273
|
+
|
|
274
|
+
files.each do |f|
|
|
275
|
+
content = File.read(f, encoding: "UTF-8")
|
|
276
|
+
content.scan(/[\p{Cyrillic}'’\`-]+/) do |tok|
|
|
277
|
+
clean = tok.downcase.gsub(/^[-'\`"]+|[-'\`"]+$/, "")
|
|
278
|
+
next if clean.length < 2
|
|
279
|
+
|
|
280
|
+
# Do not allowlist Tier-1 forbidden forms or explicit Polivanov markers
|
|
281
|
+
next if check_tier1(clean, clean, nil, 0, 0)
|
|
282
|
+
next if clean.include?("дзі") || clean.include?("тсу") || clean.include?("сьо")
|
|
283
|
+
next if Lexicon.find_by_stem(clean)
|
|
284
|
+
|
|
285
|
+
allow << clean
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
out_file = out_path || Rules.path_for("native_ua_allowlist.yml")
|
|
291
|
+
File.write(out_file, YAML.dump(allow.to_a.sort))
|
|
292
|
+
reload_allowlist!
|
|
293
|
+
Rules.reload!
|
|
294
|
+
allow
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def self.audit(paths, tier2: true, tier3: false)
|
|
299
|
+
Audit.scan(paths, tier2: tier2, tier3: tier3)
|
|
300
|
+
end
|
|
301
|
+
end
|
data/lib/yanagi/cli.rb
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require_relative "../yanagi"
|
|
7
|
+
|
|
8
|
+
module Yanagi
|
|
9
|
+
class CLI
|
|
10
|
+
def self.start(args = ARGV)
|
|
11
|
+
# Output is Ukrainian/Japanese text; emit UTF-8 regardless of the caller's locale.
|
|
12
|
+
$stdout.set_encoding(Encoding::UTF_8)
|
|
13
|
+
$stderr.set_encoding(Encoding::UTF_8)
|
|
14
|
+
new(args).run
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(args)
|
|
18
|
+
@args = args.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
cmd = @args.shift
|
|
23
|
+
case cmd
|
|
24
|
+
when "romaji"
|
|
25
|
+
cmd_romaji
|
|
26
|
+
when "cyrillic"
|
|
27
|
+
cmd_cyrillic
|
|
28
|
+
when "audit"
|
|
29
|
+
cmd_audit
|
|
30
|
+
when "apply"
|
|
31
|
+
cmd_apply
|
|
32
|
+
when "doc-sync", "doc_sync"
|
|
33
|
+
cmd_doc_sync
|
|
34
|
+
when "lexicon"
|
|
35
|
+
cmd_lexicon
|
|
36
|
+
when "verify-gold", "verify_gold"
|
|
37
|
+
cmd_verify_gold
|
|
38
|
+
when "-v", "--version", "version"
|
|
39
|
+
puts "yanagi #{Yanagi::VERSION}"
|
|
40
|
+
when "-h", "--help", "help", nil
|
|
41
|
+
print_help
|
|
42
|
+
else
|
|
43
|
+
warn "Unknown command: #{cmd}"
|
|
44
|
+
print_help
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def cmd_romaji
|
|
52
|
+
input = @args.join(" ").strip
|
|
53
|
+
if input.empty?
|
|
54
|
+
warn "Usage: yanagi romaji <kana>"
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
puts Yanagi.romaji(input)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cmd_cyrillic
|
|
61
|
+
format = "human"
|
|
62
|
+
kanji = nil
|
|
63
|
+
reading = nil
|
|
64
|
+
|
|
65
|
+
parser = OptionParser.new do |opts|
|
|
66
|
+
opts.on("--format FORMAT", %w[human json], "Output format (human or json)") { |v| format = v }
|
|
67
|
+
opts.on("--kanji KANJI", "Kanji writing") { |v| kanji = v }
|
|
68
|
+
opts.on("--reading READING", "Kana reading") { |v| reading = v }
|
|
69
|
+
end
|
|
70
|
+
remaining = parser.parse!(@args)
|
|
71
|
+
|
|
72
|
+
input = reading || remaining.join(" ").strip
|
|
73
|
+
if input.empty? && kanji.nil?
|
|
74
|
+
warn "Usage: yanagi cyrillic <kana> [--format json|human] [--kanji <kanji>]"
|
|
75
|
+
exit 1
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
res = Yanagi.cyrillic(input, kanji: kanji, reading: reading)
|
|
79
|
+
if format == "json"
|
|
80
|
+
puts JSON.pretty_generate({
|
|
81
|
+
text: res.text,
|
|
82
|
+
source: res.source,
|
|
83
|
+
confidence: res.confidence,
|
|
84
|
+
notes: res.notes
|
|
85
|
+
})
|
|
86
|
+
else
|
|
87
|
+
puts res.text
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cmd_audit
|
|
92
|
+
tier2 = true
|
|
93
|
+
tier3 = false
|
|
94
|
+
format = "human"
|
|
95
|
+
out_path = nil
|
|
96
|
+
|
|
97
|
+
parser = OptionParser.new do |opts|
|
|
98
|
+
opts.on("--[no-]tier2", "Enable/disable Tier 2 reporting (default: true)") { |v| tier2 = v }
|
|
99
|
+
opts.on("--tier3", "Enable Tier 3 Polivanov reporting (default: false)") { |v| tier3 = v }
|
|
100
|
+
opts.on("--format FORMAT", %w[human json yaml], "Output format") { |v| format = v }
|
|
101
|
+
opts.on("--out FILE", "Output file for findings") { |v| out_path = v }
|
|
102
|
+
end
|
|
103
|
+
paths = parser.parse!(@args)
|
|
104
|
+
|
|
105
|
+
if paths.empty?
|
|
106
|
+
paths = ["."]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
findings = Yanagi.audit(paths, tier2: tier2, tier3: tier3)
|
|
110
|
+
|
|
111
|
+
if out_path
|
|
112
|
+
data = { "findings" => findings.map(&:to_h) }
|
|
113
|
+
content = format == "json" ? JSON.pretty_generate(data) : YAML.dump(data)
|
|
114
|
+
File.write(out_path, content)
|
|
115
|
+
puts "Wrote #{findings.length} findings to #{out_path}"
|
|
116
|
+
elsif format == "json"
|
|
117
|
+
puts JSON.pretty_generate(findings.map(&:to_h))
|
|
118
|
+
elsif format == "yaml"
|
|
119
|
+
puts YAML.dump(findings.map(&:to_h))
|
|
120
|
+
else
|
|
121
|
+
tier1_count = findings.count { |f| f.tier == 1 }
|
|
122
|
+
tier2_count = findings.count { |f| f.tier == 2 }
|
|
123
|
+
tier3_count = findings.count { |f| f.tier == 3 }
|
|
124
|
+
|
|
125
|
+
puts "Yanagi Audit Results:"
|
|
126
|
+
puts "--------------------"
|
|
127
|
+
puts "Tier 1 (AUTOFIX): #{tier1_count}"
|
|
128
|
+
puts "Tier 2 (REPORT): #{tier2_count}"
|
|
129
|
+
puts "Tier 3 (NOISE): #{tier3_count}"
|
|
130
|
+
puts "Total: #{findings.length}"
|
|
131
|
+
puts ""
|
|
132
|
+
|
|
133
|
+
findings.each do |f|
|
|
134
|
+
loc = f.file ? "#{f.file}:#{f.line}:#{f.col}" : "line #{f.line}:#{f.col}"
|
|
135
|
+
puts "[Tier #{f.tier}] #{loc} - '#{f.token}' #{f.canonical ? "-> '#{f.canonical}'" : ""}"
|
|
136
|
+
puts " #{f.message}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Exit non-zero if Tier 1 findings exist
|
|
141
|
+
exit 1 if findings.any? { |f| f.tier == 1 }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def cmd_apply
|
|
145
|
+
findings_file = @args.first
|
|
146
|
+
unless findings_file && File.exist?(findings_file)
|
|
147
|
+
warn "Usage: yanagi apply <findings.yml>"
|
|
148
|
+
exit 1
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
applied = Yanagi::Audit.apply(findings_file)
|
|
152
|
+
puts "Applied #{applied} approved corrections."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def cmd_doc_sync
|
|
156
|
+
path = @args.first
|
|
157
|
+
sync = Yanagi::DocSync.new(path: path)
|
|
158
|
+
|
|
159
|
+
diffs = sync.diff
|
|
160
|
+
if diffs.empty?
|
|
161
|
+
puts "Transliteration policy doc (#{sync.path}) is in sync with rules."
|
|
162
|
+
exit 0
|
|
163
|
+
else
|
|
164
|
+
warn "Discrepancies found in policy doc (#{sync.path}):"
|
|
165
|
+
diffs.each do |d|
|
|
166
|
+
warn " - #{d.inspect}"
|
|
167
|
+
end
|
|
168
|
+
exit 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def cmd_lexicon
|
|
173
|
+
subcmd = @args.shift
|
|
174
|
+
case subcmd
|
|
175
|
+
when "build"
|
|
176
|
+
glossary_path = nil
|
|
177
|
+
out_path = nil
|
|
178
|
+
|
|
179
|
+
parser = OptionParser.new do |opts|
|
|
180
|
+
opts.on("--glossary FILE", "Path to glossary.org") { |v| glossary_path = v }
|
|
181
|
+
opts.on("--out FILE", "Output path for lexicon.yml") { |v| out_path = v }
|
|
182
|
+
end
|
|
183
|
+
parser.parse!(@args)
|
|
184
|
+
|
|
185
|
+
glossary_path ||= File.expand_path("~/projects/meijin/books/meijin/glossary.org")
|
|
186
|
+
unless File.exist?(glossary_path)
|
|
187
|
+
warn "Glossary file not found: #{glossary_path}"
|
|
188
|
+
exit 1
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
lex = Yanagi::Lexicon.build_from_glossary(glossary_path, out_path: out_path)
|
|
192
|
+
puts "Built lexicon with #{lex.length} entries."
|
|
193
|
+
|
|
194
|
+
when "merge"
|
|
195
|
+
out_path = nil
|
|
196
|
+
parser = OptionParser.new do |opts|
|
|
197
|
+
opts.on("--out FILE", "Output path for lexicon.yml") { |v| out_path = v }
|
|
198
|
+
end
|
|
199
|
+
remaining = parser.parse!(@args)
|
|
200
|
+
proposal_file = remaining.first
|
|
201
|
+
|
|
202
|
+
unless proposal_file && File.exist?(proposal_file)
|
|
203
|
+
warn "Usage: yanagi lexicon merge <proposal.yml> [--out <file>]"
|
|
204
|
+
exit 1
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
lex = Yanagi::Lexicon.merge_proposal(proposal_file, out_path: out_path)
|
|
208
|
+
puts "Successfully merged proposals. Lexicon now contains #{lex.length} entries."
|
|
209
|
+
|
|
210
|
+
else
|
|
211
|
+
warn "Usage: yanagi lexicon [build|merge] [options]"
|
|
212
|
+
exit 1
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Forbidden Cyrillic sequences: Polivanov leftovers and yoon/long-vowel
|
|
217
|
+
# violations. Derived from the rules data so the doc, the audit and this
|
|
218
|
+
# check cannot drift apart.
|
|
219
|
+
def gold_forbidden_patterns
|
|
220
|
+
pats = []
|
|
221
|
+
(Rules.mora_map || {}).each_value do |data|
|
|
222
|
+
canonical = data[:cyrillic].to_s
|
|
223
|
+
Array(data[:forbidden]).each do |forb|
|
|
224
|
+
f = forb.to_s
|
|
225
|
+
next if f.empty?
|
|
226
|
+
|
|
227
|
+
# A forbidden sequence is only a violation when it is not already part
|
|
228
|
+
# of the canonical rendering: «за» is wrong on its own but correct
|
|
229
|
+
# inside «дза», and «ху» is wrong except inside a longer correct form.
|
|
230
|
+
prefix = canonical.end_with?(f) ? canonical[0...-f.length] : nil
|
|
231
|
+
pats << if prefix && !prefix.empty?
|
|
232
|
+
Regexp.new("(?<!#{Regexp.escape(prefix)})#{Regexp.escape(f)}", Regexp::IGNORECASE)
|
|
233
|
+
else
|
|
234
|
+
Regexp.new(Regexp.escape(f), Regexp::IGNORECASE)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
pats << /оо/ # long vowels are never doubled (see transliteration.md 3.3)
|
|
239
|
+
pats.uniq { |r| r.source }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def cmd_verify_gold
|
|
243
|
+
glossary_path = @args.first || File.expand_path("~/projects/meijin/books/meijin/glossary.org")
|
|
244
|
+
unless File.exist?(glossary_path)
|
|
245
|
+
warn "Glossary file not found: #{glossary_path}"
|
|
246
|
+
exit 1
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
content = File.read(glossary_path, encoding: "UTF-8")
|
|
250
|
+
rows = []
|
|
251
|
+
content.each_line do |line|
|
|
252
|
+
next unless line.start_with?("|")
|
|
253
|
+
parts = line.split("|").map(&:strip).reject(&:empty?)
|
|
254
|
+
next if parts.empty? || parts[0].start_with?("-") || parts[0] =~ /Японське/i
|
|
255
|
+
|
|
256
|
+
col0 = parts[0]
|
|
257
|
+
next unless col0 =~ /^(.+?)\s*\((.+?)\)$/
|
|
258
|
+
|
|
259
|
+
rows << {
|
|
260
|
+
kanji: Regexp.last_match(1).strip,
|
|
261
|
+
reading: Regexp.last_match(2).strip,
|
|
262
|
+
ukrainian: parts[1],
|
|
263
|
+
note: parts[2]
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
puts "Verifying #{rows.length} gold glossary terms against Yanagi rules..."
|
|
268
|
+
|
|
269
|
+
exceptions = Rules.exceptions || []
|
|
270
|
+
pending = exceptions.select { |e| e[:status].to_s == "pending" }
|
|
271
|
+
accepted_terms = exceptions.select { |e| e[:status].to_s == "accepted" }.map { |e| e[:term].to_s }
|
|
272
|
+
|
|
273
|
+
patterns = gold_forbidden_patterns
|
|
274
|
+
passed = 0
|
|
275
|
+
failures = []
|
|
276
|
+
|
|
277
|
+
rows.each do |row|
|
|
278
|
+
reading = row[:reading]
|
|
279
|
+
|
|
280
|
+
# Non-Japanese entries carry their own language's transliteration.
|
|
281
|
+
entry = Rules.lexicon[row[:kanji].to_sym] || Rules.lexicon[row[:kanji]]
|
|
282
|
+
if entry && entry[:origin].to_s == "zh"
|
|
283
|
+
passed += 1
|
|
284
|
+
next
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
if accepted_terms.any? { |t| reading.include?(t) }
|
|
288
|
+
passed += 1
|
|
289
|
+
next
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
hit = patterns.find { |pat| reading =~ pat }
|
|
293
|
+
if hit
|
|
294
|
+
failures << { kanji: row[:kanji], reading: reading, reason: "Forbidden sequence #{hit.source}" }
|
|
295
|
+
else
|
|
296
|
+
passed += 1
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
pass_rate = rows.empty? ? 100.0 : (passed.to_f / rows.length * 100).round(1)
|
|
301
|
+
puts "Gold Verification Summary:"
|
|
302
|
+
puts "-------------------------"
|
|
303
|
+
puts "Total Terms: #{rows.length}"
|
|
304
|
+
puts "Passed: #{passed} (#{pass_rate}%)"
|
|
305
|
+
puts "Failures: #{failures.length}"
|
|
306
|
+
puts "Pending defect: #{pending.length}"
|
|
307
|
+
|
|
308
|
+
unless failures.empty?
|
|
309
|
+
warn "\nFailures:"
|
|
310
|
+
failures.each { |f| warn " - #{f[:kanji]} (#{f[:reading]}): #{f[:reason]}" }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A pending exception is an uncorrected data defect: fail so it stays visible.
|
|
314
|
+
unless pending.empty?
|
|
315
|
+
warn "\nPending defects in data/exceptions.yml (correct the source data or reclassify):"
|
|
316
|
+
pending.each { |e| warn " - #{e[:term]} (#{e[:kanji]}): #{e[:reason]}" }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
if failures.empty? && pending.empty?
|
|
320
|
+
puts "All gold pairs verified successfully!"
|
|
321
|
+
exit 0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
exit 1
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def print_help
|
|
328
|
+
puts <<~HELP
|
|
329
|
+
Yanagi (柳) - Deterministic Japanese -> Ukrainian transliteration & policy tool
|
|
330
|
+
|
|
331
|
+
Usage:
|
|
332
|
+
yanagi romaji <kana>
|
|
333
|
+
yanagi cyrillic <kana> [--format json|human] [--kanji <kanji>]
|
|
334
|
+
yanagi audit <paths...> [--tier2] [--tier3] [--format json|human|yaml] [--out <file>]
|
|
335
|
+
yanagi apply <findings.yml>
|
|
336
|
+
yanagi doc-sync [path/to/transliteration.md]
|
|
337
|
+
yanagi lexicon build --glossary <glossary.org> [--out <file>]
|
|
338
|
+
yanagi lexicon merge <proposal.yml> [--out <file>]
|
|
339
|
+
yanagi verify-gold [path/to/glossary.org]
|
|
340
|
+
yanagi version
|
|
341
|
+
yanagi help
|
|
342
|
+
HELP
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|