psychowl 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 +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/exe/psychowl +106 -0
- data/ext/psychowl_native/core/data/LICENSE-whatlang +26 -0
- data/ext/psychowl_native/core/data/README.md +32 -0
- data/ext/psychowl_native/core/data/alphabets.tsv +16 -0
- data/ext/psychowl_native/core/data/languages.tsv +20 -0
- data/ext/psychowl_native/core/data/scripts.tsv +28 -0
- data/ext/psychowl_native/core/data/trigrams/ara.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/deu.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/eng.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/fra.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/hin.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/ind.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/ita.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/nld.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/pol.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/por.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/rus.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/spa.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/tur.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/ukr.txt +302 -0
- data/ext/psychowl_native/core/data/trigrams/vie.txt +302 -0
- data/lib/psychowl/active_model.rb +64 -0
- data/lib/psychowl/data_file.rb +14 -0
- data/lib/psychowl/detector.rb +123 -0
- data/lib/psychowl/engine.rb +23 -0
- data/lib/psychowl/info.rb +39 -0
- data/lib/psychowl/lang.rb +55 -0
- data/lib/psychowl/locale/en.yml +6 -0
- data/lib/psychowl/lookup.rb +25 -0
- data/lib/psychowl/native_speedup.rb +46 -0
- data/lib/psychowl/postgres.rb +35 -0
- data/lib/psychowl/railtie.rb +10 -0
- data/lib/psychowl/ruby_engine/segmentation.rb +84 -0
- data/lib/psychowl/ruby_engine/trigrams.rb +74 -0
- data/lib/psychowl/ruby_engine.rb +166 -0
- data/lib/psychowl/script.rb +39 -0
- data/lib/psychowl/segment.rb +24 -0
- data/lib/psychowl/tables.rb +55 -0
- data/lib/psychowl/text.rb +26 -0
- data/lib/psychowl/version.rb +5 -0
- data/lib/psychowl.rb +82 -0
- metadata +91 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# vie: 300 most frequent trigrams, most frequent first. "_" marks a word boundary.
|
|
2
|
+
# Ported from whatlang 0.18.0 (MIT), see ../LICENSE-whatlang.
|
|
3
|
+
ng_
|
|
4
|
+
̣c_
|
|
5
|
+
́c_
|
|
6
|
+
_qu
|
|
7
|
+
_th
|
|
8
|
+
à_
|
|
9
|
+
nh_
|
|
10
|
+
_ng
|
|
11
|
+
̣i_
|
|
12
|
+
_nh
|
|
13
|
+
và
|
|
14
|
+
_va
|
|
15
|
+
̀n_
|
|
16
|
+
uyê
|
|
17
|
+
_ph
|
|
18
|
+
_ca
|
|
19
|
+
quy
|
|
20
|
+
ền
|
|
21
|
+
yề
|
|
22
|
+
̀i_
|
|
23
|
+
_ch
|
|
24
|
+
̀nh
|
|
25
|
+
_tr
|
|
26
|
+
_cu
|
|
27
|
+
ngư
|
|
28
|
+
i_n
|
|
29
|
+
gươ
|
|
30
|
+
ườ
|
|
31
|
+
́t_
|
|
32
|
+
ời
|
|
33
|
+
_gi
|
|
34
|
+
ác
|
|
35
|
+
_co
|
|
36
|
+
̣t_
|
|
37
|
+
ó_
|
|
38
|
+
c_t
|
|
39
|
+
ự_
|
|
40
|
+
n_t
|
|
41
|
+
cá
|
|
42
|
+
ông
|
|
43
|
+
_kh
|
|
44
|
+
ượ
|
|
45
|
+
ợc
|
|
46
|
+
_tư
|
|
47
|
+
_đư
|
|
48
|
+
iệ
|
|
49
|
+
đươ
|
|
50
|
+
ìn
|
|
51
|
+
́i_
|
|
52
|
+
_ha
|
|
53
|
+
có
|
|
54
|
+
i_đ
|
|
55
|
+
gia
|
|
56
|
+
_đê
|
|
57
|
+
pha
|
|
58
|
+
_mo
|
|
59
|
+
ọi
|
|
60
|
+
mọ
|
|
61
|
+
như
|
|
62
|
+
n_n
|
|
63
|
+
củ
|
|
64
|
+
_ba
|
|
65
|
+
̣n_
|
|
66
|
+
̉a_
|
|
67
|
+
ủa
|
|
68
|
+
n_c
|
|
69
|
+
̀u_
|
|
70
|
+
̃ng
|
|
71
|
+
ân_
|
|
72
|
+
ều
|
|
73
|
+
ất
|
|
74
|
+
_bi
|
|
75
|
+
tự
|
|
76
|
+
hôn
|
|
77
|
+
_vi
|
|
78
|
+
g_t
|
|
79
|
+
_la
|
|
80
|
+
n_đ
|
|
81
|
+
đề
|
|
82
|
+
nhâ
|
|
83
|
+
_ti
|
|
84
|
+
t_c
|
|
85
|
+
_đô
|
|
86
|
+
ên_
|
|
87
|
+
bả
|
|
88
|
+
hiê
|
|
89
|
+
u_c
|
|
90
|
+
_tô
|
|
91
|
+
do_
|
|
92
|
+
hân
|
|
93
|
+
_do
|
|
94
|
+
ch_
|
|
95
|
+
́_q
|
|
96
|
+
̀_t
|
|
97
|
+
_na
|
|
98
|
+
́n_
|
|
99
|
+
ay_
|
|
100
|
+
_hi
|
|
101
|
+
àn
|
|
102
|
+
̣_d
|
|
103
|
+
ới
|
|
104
|
+
há
|
|
105
|
+
_đi
|
|
106
|
+
hay
|
|
107
|
+
g_n
|
|
108
|
+
_mô
|
|
109
|
+
ốc
|
|
110
|
+
uố
|
|
111
|
+
n_v
|
|
112
|
+
ội
|
|
113
|
+
hữ
|
|
114
|
+
thư
|
|
115
|
+
́p_
|
|
116
|
+
quô
|
|
117
|
+
_ho
|
|
118
|
+
̣p_
|
|
119
|
+
nà
|
|
120
|
+
ào
|
|
121
|
+
̀ng
|
|
122
|
+
̉n_
|
|
123
|
+
ị_
|
|
124
|
+
́ch
|
|
125
|
+
ôn_
|
|
126
|
+
̀o_
|
|
127
|
+
khô
|
|
128
|
+
c_h
|
|
129
|
+
i_c
|
|
130
|
+
c_đ
|
|
131
|
+
_hô
|
|
132
|
+
i_v
|
|
133
|
+
tro
|
|
134
|
+
_đa
|
|
135
|
+
́ng
|
|
136
|
+
mộ
|
|
137
|
+
i_t
|
|
138
|
+
ột
|
|
139
|
+
g_v
|
|
140
|
+
ia_
|
|
141
|
+
̣ng
|
|
142
|
+
ản
|
|
143
|
+
ướ
|
|
144
|
+
ữn
|
|
145
|
+
̉ng
|
|
146
|
+
h_t
|
|
147
|
+
hư_
|
|
148
|
+
ện
|
|
149
|
+
n_b
|
|
150
|
+
ộc
|
|
151
|
+
ả_
|
|
152
|
+
là
|
|
153
|
+
c_c
|
|
154
|
+
g_c
|
|
155
|
+
_đo
|
|
156
|
+
̉_c
|
|
157
|
+
n_h
|
|
158
|
+
hà
|
|
159
|
+
hộ
|
|
160
|
+
_bâ
|
|
161
|
+
ã_
|
|
162
|
+
̀y_
|
|
163
|
+
_vơ
|
|
164
|
+
̣_t
|
|
165
|
+
̉i_
|
|
166
|
+
iế
|
|
167
|
+
_cô
|
|
168
|
+
t_t
|
|
169
|
+
g_đ
|
|
170
|
+
ức
|
|
171
|
+
iên
|
|
172
|
+
_vê
|
|
173
|
+
viê
|
|
174
|
+
vớ
|
|
175
|
+
h_v
|
|
176
|
+
ớc
|
|
177
|
+
ực
|
|
178
|
+
ật
|
|
179
|
+
tha
|
|
180
|
+
̉m_
|
|
181
|
+
ron
|
|
182
|
+
ong
|
|
183
|
+
áp
|
|
184
|
+
g_b
|
|
185
|
+
hươ
|
|
186
|
+
_sư
|
|
187
|
+
a_c
|
|
188
|
+
sự
|
|
189
|
+
̉o_
|
|
190
|
+
ảo
|
|
191
|
+
h_c
|
|
192
|
+
ể_
|
|
193
|
+
o_v
|
|
194
|
+
uậ
|
|
195
|
+
a_m
|
|
196
|
+
ế_
|
|
197
|
+
iá
|
|
198
|
+
̀_c
|
|
199
|
+
cho
|
|
200
|
+
qua
|
|
201
|
+
hạ
|
|
202
|
+
ục
|
|
203
|
+
_mi
|
|
204
|
+
̀_n
|
|
205
|
+
phâ
|
|
206
|
+
c_q
|
|
207
|
+
côn
|
|
208
|
+
o_c
|
|
209
|
+
á_
|
|
210
|
+
i_h
|
|
211
|
+
ại
|
|
212
|
+
_hơ
|
|
213
|
+
̃_h
|
|
214
|
+
_cư
|
|
215
|
+
n_l
|
|
216
|
+
bị
|
|
217
|
+
_lu
|
|
218
|
+
bấ
|
|
219
|
+
cả
|
|
220
|
+
ín
|
|
221
|
+
h_đ
|
|
222
|
+
_xa
|
|
223
|
+
độ
|
|
224
|
+
g_h
|
|
225
|
+
c_n
|
|
226
|
+
c_p
|
|
227
|
+
thu
|
|
228
|
+
ải
|
|
229
|
+
ệ_
|
|
230
|
+
_hư
|
|
231
|
+
́_c
|
|
232
|
+
o_n
|
|
233
|
+
_nư
|
|
234
|
+
ốn
|
|
235
|
+
́o_
|
|
236
|
+
áo
|
|
237
|
+
xã
|
|
238
|
+
oà
|
|
239
|
+
y_t
|
|
240
|
+
hả
|
|
241
|
+
tộ
|
|
242
|
+
̣_c
|
|
243
|
+
_tâ
|
|
244
|
+
thô
|
|
245
|
+
_du
|
|
246
|
+
m_v
|
|
247
|
+
mì
|
|
248
|
+
ho_
|
|
249
|
+
hứ
|
|
250
|
+
ệc
|
|
251
|
+
́_t
|
|
252
|
+
hợ
|
|
253
|
+
án
|
|
254
|
+
n_p
|
|
255
|
+
cũ
|
|
256
|
+
ũn
|
|
257
|
+
iể
|
|
258
|
+
ối
|
|
259
|
+
tiê
|
|
260
|
+
ề_
|
|
261
|
+
hấ
|
|
262
|
+
ợp
|
|
263
|
+
hoa
|
|
264
|
+
y_đ
|
|
265
|
+
chi
|
|
266
|
+
o_h
|
|
267
|
+
ở_
|
|
268
|
+
ày
|
|
269
|
+
̉_t
|
|
270
|
+
đó
|
|
271
|
+
c_l
|
|
272
|
+
về
|
|
273
|
+
̀_đ
|
|
274
|
+
i_b
|
|
275
|
+
kha
|
|
276
|
+
c_b
|
|
277
|
+
_đâ
|
|
278
|
+
luâ
|
|
279
|
+
ai_
|
|
280
|
+
̉_n
|
|
281
|
+
đố
|
|
282
|
+
ết
|
|
283
|
+
hự
|
|
284
|
+
tri
|
|
285
|
+
p_q
|
|
286
|
+
nươ
|
|
287
|
+
dụ
|
|
288
|
+
hí
|
|
289
|
+
g_q
|
|
290
|
+
yên
|
|
291
|
+
họ
|
|
292
|
+
́nh
|
|
293
|
+
_ta
|
|
294
|
+
_bă
|
|
295
|
+
c_g
|
|
296
|
+
n_g
|
|
297
|
+
thê
|
|
298
|
+
o_t
|
|
299
|
+
c_v
|
|
300
|
+
am_
|
|
301
|
+
c_m
|
|
302
|
+
an_
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
require 'psychowl'
|
|
5
|
+
|
|
6
|
+
I18n.load_path << File.expand_path('locale/en.yml', __dir__)
|
|
7
|
+
|
|
8
|
+
module ActiveModel
|
|
9
|
+
module Validations
|
|
10
|
+
# Validates the language an attribute is written in.
|
|
11
|
+
#
|
|
12
|
+
# validates :body, language: { in: %i[en fr] }
|
|
13
|
+
# validates :title, language: { not_in: :ru, reliable: true }, allow_blank: true
|
|
14
|
+
# validates :bio, language: { in: :en, minimum_confidence: 0.5 }
|
|
15
|
+
#
|
|
16
|
+
# Options:
|
|
17
|
+
# * +in+ / +not_in+ - languages (codes or Psychowl::Lang) to accept / reject
|
|
18
|
+
# * +reliable+ - require a reliable detection
|
|
19
|
+
# * +minimum_confidence+ - require at least this confidence (0.0 to 1.0)
|
|
20
|
+
#
|
|
21
|
+
# Errors: +:language_undetected+, +:language_not_allowed+,
|
|
22
|
+
# +:language_uncertain+, each with a +%{language}+ interpolation.
|
|
23
|
+
class LanguageValidator < EachValidator
|
|
24
|
+
def check_validity!
|
|
25
|
+
raise ArgumentError, 'pass either :in or :not_in, not both' if options[:in] && options[:not_in]
|
|
26
|
+
|
|
27
|
+
@allowed = resolve(options[:in])
|
|
28
|
+
@denied = resolve(options[:not_in])
|
|
29
|
+
minimum = options[:minimum_confidence]
|
|
30
|
+
return if minimum.nil? || (minimum.is_a?(Numeric) && minimum.between?(0, 1))
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, ':minimum_confidence must be between 0 and 1'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_each(record, attribute, value)
|
|
36
|
+
info = Psychowl.detect(value.to_s)
|
|
37
|
+
return add_error(record, attribute, :language_undetected, nil) if info.nil?
|
|
38
|
+
|
|
39
|
+
lang = info.lang
|
|
40
|
+
return add_error(record, attribute, :language_not_allowed, lang) if @allowed && !@allowed.include?(lang)
|
|
41
|
+
return add_error(record, attribute, :language_not_allowed, lang) if @denied&.include?(lang)
|
|
42
|
+
|
|
43
|
+
add_error(record, attribute, :language_uncertain, lang) if uncertain?(info)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def uncertain?(info)
|
|
49
|
+
return true if options[:reliable] && !info.reliable?
|
|
50
|
+
|
|
51
|
+
minimum = options[:minimum_confidence]
|
|
52
|
+
!minimum.nil? && info.confidence < minimum
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve(list) = list && Array(list).map { Psychowl::Lang.fetch(it) }
|
|
56
|
+
|
|
57
|
+
def add_error(record, attribute, type, lang)
|
|
58
|
+
details = { language: lang&.eng_name }
|
|
59
|
+
details[:message] = options[:message] if options[:message]
|
|
60
|
+
record.errors.add(attribute, type, **details)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Psychowl
|
|
4
|
+
# Reads the plain-text data files. No load-time side effects, so the data
|
|
5
|
+
# tools can use it before the tables exist.
|
|
6
|
+
module DataFile
|
|
7
|
+
# Lines of a file, without blank lines and # comments.
|
|
8
|
+
def self.lines(path)
|
|
9
|
+
File.readlines(path, chomp: true, encoding: Encoding::UTF_8).reject do |line|
|
|
10
|
+
line.empty? || line.start_with?('#')
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Psychowl
|
|
4
|
+
# A reusable, frozen detector, optionally limited by an allowlist or denylist.
|
|
5
|
+
#
|
|
6
|
+
# detector = Psychowl::Detector.new(allowlist: %i[en fr de])
|
|
7
|
+
# detector.detect_lang("Traversez la rue, je vous trouverai un travail.")
|
|
8
|
+
# # => #<Psychowl::Lang fra (French)>
|
|
9
|
+
class Detector
|
|
10
|
+
# @return [Array<Lang>, nil]
|
|
11
|
+
attr_reader :allowlist
|
|
12
|
+
|
|
13
|
+
# @return [Array<Lang>, nil]
|
|
14
|
+
attr_reader :denylist
|
|
15
|
+
|
|
16
|
+
# @param allowlist [Array<Lang, String, Symbol>, nil] only consider these
|
|
17
|
+
# @param denylist [Array<Lang, String, Symbol>, nil] never consider these
|
|
18
|
+
# @raise [ArgumentError] both lists given, an empty allowlist, or an
|
|
19
|
+
# unsupported language code
|
|
20
|
+
def initialize(allowlist: nil, denylist: nil)
|
|
21
|
+
raise ArgumentError, 'pass either allowlist: or denylist:, not both' unless allowlist.nil? || denylist.nil?
|
|
22
|
+
|
|
23
|
+
@allowlist = allowlist && resolve(allowlist)
|
|
24
|
+
@denylist = denylist && resolve(denylist)
|
|
25
|
+
raise ArgumentError, 'allowlist must not be empty' if @allowlist && @allowlist.empty?
|
|
26
|
+
|
|
27
|
+
@filter_mode, @filter_langs = engine_filter
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param text [String]
|
|
32
|
+
# @return [Info, nil] nil when no supported language was found
|
|
33
|
+
# @raise [TypeError] text is not a String
|
|
34
|
+
# @raise [EncodingError] text is not valid UTF-8 (or convertible to it)
|
|
35
|
+
def detect(text)
|
|
36
|
+
result = Engine.detect(Text.prepare(text), @filter_mode, @filter_langs)
|
|
37
|
+
result && Info.from_engine(result)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param (see #detect)
|
|
41
|
+
# @return [Lang, nil]
|
|
42
|
+
def detect_lang(text) = detect(text)&.lang
|
|
43
|
+
|
|
44
|
+
# Scripts ignore the language filter.
|
|
45
|
+
#
|
|
46
|
+
# @param (see #detect)
|
|
47
|
+
# @return [Script, nil]
|
|
48
|
+
def detect_script(text)
|
|
49
|
+
index = Engine.detect_script(Text.prepare(text))
|
|
50
|
+
index && Script.at(index)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every candidate language of the text's script with its score, best
|
|
54
|
+
# first. Scores are relative to each other, not probabilities.
|
|
55
|
+
#
|
|
56
|
+
# @param (see #detect)
|
|
57
|
+
# @param limit [Integer, nil] keep only the first +limit+ candidates
|
|
58
|
+
# @return [Array<Array(Lang, Float)>]
|
|
59
|
+
def candidates(text, limit: nil)
|
|
60
|
+
ranked = Engine.candidates(Text.prepare(text), @filter_mode, @filter_langs)
|
|
61
|
+
ranked = ranked.first(limit) if limit
|
|
62
|
+
ranked.map { |lang, score| [Lang.at(lang), score] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# On all CPU cores with the native engine.
|
|
66
|
+
#
|
|
67
|
+
# @param texts [Array<String>]
|
|
68
|
+
# @return [Array<Info, nil>] one result per text, in order
|
|
69
|
+
def detect_many(texts)
|
|
70
|
+
prepared = texts.map { Text.prepare(it) }
|
|
71
|
+
Engine.detect_many(prepared, @filter_mode, @filter_langs).map { |result| result && Info.from_engine(result) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Runs of same-language sentences, for mixed-language text.
|
|
75
|
+
#
|
|
76
|
+
# @param (see #detect)
|
|
77
|
+
# @return [Array<Segment>]
|
|
78
|
+
def segments(text)
|
|
79
|
+
text = Text.prepare(text)
|
|
80
|
+
Engine.segments(text, @filter_mode, @filter_langs).map do |start, finish, *result|
|
|
81
|
+
Segment.new(text: text[start...finish], range: start...finish, info: Info.from_engine(result))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Share of each writing system among the text's letters, largest first.
|
|
86
|
+
# Ignores the language filter.
|
|
87
|
+
#
|
|
88
|
+
# @param (see #detect)
|
|
89
|
+
# @return [Hash{Script => Float}] shares summing to 1.0; empty without letters
|
|
90
|
+
def scripts(text)
|
|
91
|
+
counts = Engine.script_counts(Text.prepare(text))
|
|
92
|
+
total = counts.sum
|
|
93
|
+
return {} if total.zero?
|
|
94
|
+
|
|
95
|
+
shares = counts.each_with_index.filter_map do |count, index|
|
|
96
|
+
[Script.at(index), count.fdiv(total)] if count.positive?
|
|
97
|
+
end
|
|
98
|
+
shares.sort_by { |_script, share| -share }.to_h
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inspect
|
|
102
|
+
filter =
|
|
103
|
+
if allowlist then " allowlist=#{allowlist.map(&:code)}"
|
|
104
|
+
elsif denylist then " denylist=#{denylist.map(&:code)}"
|
|
105
|
+
end
|
|
106
|
+
"#<#{self.class}#{filter}>"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# [mode, language indices] as the engines take them
|
|
112
|
+
def engine_filter
|
|
113
|
+
if allowlist then [Engine::FILTER_ALLOW, indices(allowlist)]
|
|
114
|
+
elsif denylist then [Engine::FILTER_DENY, indices(denylist)]
|
|
115
|
+
else [Engine::FILTER_ALL, [].freeze]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def indices(langs) = langs.map { Lang.index(it) }.freeze
|
|
120
|
+
|
|
121
|
+
def resolve(list) = Array(list).map { Lang.fetch(it) }.uniq.freeze
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Psychowl
|
|
4
|
+
# Delegates to RubyEngine until native_speedup.rb prepends the Rust
|
|
5
|
+
# extension. Same primitives either way.
|
|
6
|
+
module Engine
|
|
7
|
+
FILTER_ALL = 0
|
|
8
|
+
FILTER_ALLOW = 1
|
|
9
|
+
FILTER_DENY = 2
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def native? = false
|
|
13
|
+
|
|
14
|
+
def detect(text, filter_mode, filter_langs) = RubyEngine.detect(text, filter_mode, filter_langs)
|
|
15
|
+
def candidates(text, filter_mode, filter_langs) = RubyEngine.candidates(text, filter_mode, filter_langs)
|
|
16
|
+
def detect_many(texts, filter_mode, filter_langs) = RubyEngine.detect_many(texts, filter_mode, filter_langs)
|
|
17
|
+
def segments(text, filter_mode, filter_langs) = RubyEngine.segments(text, filter_mode, filter_langs)
|
|
18
|
+
def sentences(text) = RubyEngine.sentences(text)
|
|
19
|
+
def detect_script(text) = RubyEngine.detect_script(text)
|
|
20
|
+
def script_counts(text) = RubyEngine.script_counts(text)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Psychowl
|
|
4
|
+
# Outcome of a detection. Supports pattern matching:
|
|
5
|
+
#
|
|
6
|
+
# case Psychowl.detect(speech)
|
|
7
|
+
# in {lang: {code: "eng"}, reliable: true} then :tremendous
|
|
8
|
+
# in {reliable: false} then :fake_news
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] lang
|
|
12
|
+
# @return [Lang]
|
|
13
|
+
# @!attribute [r] script
|
|
14
|
+
# @return [Script]
|
|
15
|
+
# @!attribute [r] confidence
|
|
16
|
+
# @return [Float] 0.0 to 1.0
|
|
17
|
+
# @!attribute [r] reliable
|
|
18
|
+
# @return [Boolean] confidence above {RELIABLE_CONFIDENCE}
|
|
19
|
+
Info = ::Data.define(:lang, :script, :confidence, :reliable) do
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def reliable? = reliable
|
|
22
|
+
|
|
23
|
+
def inspect
|
|
24
|
+
"#<#{self.class} lang=#{lang.code} script=#{script.name} " \
|
|
25
|
+
"confidence=#{confidence.round(4)} reliable=#{reliable}>"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reliability threshold and construction from engine results.
|
|
30
|
+
class Info
|
|
31
|
+
RELIABLE_CONFIDENCE = 0.9
|
|
32
|
+
|
|
33
|
+
# @api private
|
|
34
|
+
def self.from_engine((lang, script, confidence))
|
|
35
|
+
new(lang: Lang.at(lang), script: Script.at(script), confidence:,
|
|
36
|
+
reliable: confidence > RELIABLE_CONFIDENCE)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Psychowl
|
|
4
|
+
# A supported language. Instances are canonical: every API hands out the
|
|
5
|
+
# same frozen object for a given language.
|
|
6
|
+
#
|
|
7
|
+
# Psychowl::Lang[:de] # ISO 639-1
|
|
8
|
+
# Psychowl::Lang["deu"] # ISO 639-3
|
|
9
|
+
# Psychowl::Lang.fetch(:x) # raises ArgumentError
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] code
|
|
12
|
+
# @return [String] ISO 639-3 code, e.g. "deu"
|
|
13
|
+
# @!attribute [r] iso639_1
|
|
14
|
+
# @return [String] ISO 639-1 code, e.g. "de"
|
|
15
|
+
# @!attribute [r] name
|
|
16
|
+
# @return [String] name in the language itself, e.g. "Deutsch"
|
|
17
|
+
# @!attribute [r] eng_name
|
|
18
|
+
# @return [String] English name, e.g. "German"
|
|
19
|
+
Lang = ::Data.define(:code, :iso639_1, :name, :eng_name) do
|
|
20
|
+
# @return [Symbol] the ISO 639-1 code as an I18n-style locale, e.g. :de
|
|
21
|
+
def locale = iso639_1.to_sym
|
|
22
|
+
|
|
23
|
+
# @return [String] the ISO 639-3 code
|
|
24
|
+
def to_s = code
|
|
25
|
+
|
|
26
|
+
def inspect = "#<#{self.class} #{code} (#{eng_name})>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The canonical instances and lookup by code.
|
|
30
|
+
class Lang
|
|
31
|
+
singleton_class.prepend(Lookup) # ahead of Data.define's own `[]` constructor
|
|
32
|
+
|
|
33
|
+
ALL = Tables::LANGUAGES.map do |code, iso639_1, eng_name, name|
|
|
34
|
+
new(code:, iso639_1:, name:, eng_name:)
|
|
35
|
+
end.freeze
|
|
36
|
+
BY_CODE = ALL.flat_map { |lang| [[lang.code, lang], [lang.iso639_1, lang]] }.to_h.freeze
|
|
37
|
+
INDEX = ALL.each_with_index.to_h.compare_by_identity.freeze
|
|
38
|
+
private_constant :ALL, :BY_CODE, :INDEX
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# @return [Array<Lang>] every supported language (frozen)
|
|
42
|
+
def all = ALL
|
|
43
|
+
|
|
44
|
+
# @api private
|
|
45
|
+
def index(lang) = INDEX.fetch(lang)
|
|
46
|
+
|
|
47
|
+
private :new
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def by_key = BY_CODE
|
|
52
|
+
def unknown_key_message = 'unsupported language'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Psychowl
|
|
4
|
+
# Case-insensitive lookup for Lang and Script. Prepended, because Data
|
|
5
|
+
# defines its own `[]`; expects `all`, `by_key` and `unknown_key_message`.
|
|
6
|
+
module Lookup
|
|
7
|
+
# @param key [self, String, Symbol]
|
|
8
|
+
# @return [self, nil]
|
|
9
|
+
def [](key)
|
|
10
|
+
return key if key.is_a?(self)
|
|
11
|
+
|
|
12
|
+
by_key[key.to_s.downcase]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param (see #[])
|
|
16
|
+
# @return [self]
|
|
17
|
+
# @raise [ArgumentError] unknown key
|
|
18
|
+
def fetch(key)
|
|
19
|
+
self[key] or raise ArgumentError, "#{unknown_key_message}: #{key.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @api private
|
|
23
|
+
def at(index) = all.fetch(index)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A non-empty DISABLE_PSYCHOWL_NATIVE or DISABLE_MATRYOSHKA_NATIVE forces pure
|
|
4
|
+
# Ruby; an empty one (as CI matrices set) does not.
|
|
5
|
+
return if %w[DISABLE_PSYCHOWL_NATIVE DISABLE_MATRYOSHKA_NATIVE].any? { !ENV.fetch(it, '').empty? }
|
|
6
|
+
|
|
7
|
+
# Platform gems ship the binary under the Ruby ABI version; a dev build (rake
|
|
8
|
+
# compile) leaves it next to this file. The pure Ruby gem has neither.
|
|
9
|
+
shipped = File.join(__dir__, RUBY_VERSION[/\A\d+\.\d+/], "psychowl_native.#{RbConfig::CONFIG['DLEXT']}")
|
|
10
|
+
begin
|
|
11
|
+
if File.exist?(shipped)
|
|
12
|
+
require shipped
|
|
13
|
+
else
|
|
14
|
+
require 'psychowl/psychowl_native'
|
|
15
|
+
end
|
|
16
|
+
rescue LoadError => e
|
|
17
|
+
# A shipped binary that will not load (glibc older than 2.34, Alpine without
|
|
18
|
+
# libgcc) is a real fault, not the pure Ruby gem: say so.
|
|
19
|
+
warn "psychowl: native extension failed to load (#{e.message}); using the pure Ruby engine" if File.exist?(shipped)
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An extension built from other data files would map indices to the wrong
|
|
24
|
+
# languages: keep the Ruby engine instead.
|
|
25
|
+
expected = [Psychowl::Tables::LANGUAGES.map(&:first), Psychowl::Tables::SCRIPTS.map(&:name)]
|
|
26
|
+
unless PsychowlNative.tables == expected
|
|
27
|
+
warn "psychowl: native extension was built from different language data (rebuild it); using the pure Ruby engine"
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Swaps the Rust extension in behind Engine.
|
|
32
|
+
module Psychowl
|
|
33
|
+
# Routes every Engine method to the Rust extension (PsychowlNative).
|
|
34
|
+
module NativeSpeedup
|
|
35
|
+
def native? = true
|
|
36
|
+
def detect(text, filter_mode, filter_langs) = PsychowlNative.detect(text, filter_mode, filter_langs)
|
|
37
|
+
def candidates(text, filter_mode, filter_langs) = PsychowlNative.candidates(text, filter_mode, filter_langs)
|
|
38
|
+
def detect_script(text) = PsychowlNative.detect_script(text)
|
|
39
|
+
def script_counts(text) = PsychowlNative.script_counts(text)
|
|
40
|
+
def detect_many(texts, filter_mode, filter_langs) = PsychowlNative.detect_many(texts, filter_mode, filter_langs)
|
|
41
|
+
def segments(text, filter_mode, filter_langs) = PsychowlNative.segments(text, filter_mode, filter_langs)
|
|
42
|
+
def sentences(text) = PsychowlNative.sentences(text)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Engine.singleton_class.prepend(NativeSpeedup)
|
|
46
|
+
end
|