jlpt 1.0.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/LICENSE +21 -0
- data/README.ja.md +246 -0
- data/README.md +224 -0
- data/assets/logo.png +0 -0
- data/bin/jlpt +7 -0
- data/lib/jlpt/active_model/validator.rb +43 -0
- data/lib/jlpt/analyzers/comparer.rb +44 -0
- data/lib/jlpt/analyzers/conjugation.rb +60 -0
- data/lib/jlpt/analyzers/corpus_stats.rb +38 -0
- data/lib/jlpt/analyzers/dokkai.rb +53 -0
- data/lib/jlpt/analyzers/exam_readiness.rb +52 -0
- data/lib/jlpt/analyzers/grammar.rb +50 -0
- data/lib/jlpt/analyzers/jukugo.rb +46 -0
- data/lib/jlpt/analyzers/loanwords.rb +54 -0
- data/lib/jlpt/analyzers/onomatopeia.rb +29 -0
- data/lib/jlpt/analyzers/pitch_accent.rb +51 -0
- data/lib/jlpt/analyzers/pos_profiler.rb +47 -0
- data/lib/jlpt/analyzers/simplifier.rb +48 -0
- data/lib/jlpt/analyzers/style_profiler.rb +62 -0
- data/lib/jlpt/analyzers/syntax.rb +43 -0
- data/lib/jlpt/cli.rb +121 -0
- data/lib/jlpt/core/analysis_result.rb +45 -0
- data/lib/jlpt/core/analyzer.rb +58 -0
- data/lib/jlpt/core/cache_manager.rb +90 -0
- data/lib/jlpt/core/config.rb +45 -0
- data/lib/jlpt/core/dictionary.rb +113 -0
- data/lib/jlpt/core/engine.rb +142 -0
- data/lib/jlpt/core/kanji.rb +54 -0
- data/lib/jlpt/core/kanji_details.rb +60 -0
- data/lib/jlpt/core/preprocessor.rb +48 -0
- data/lib/jlpt/core/tokenizer.rb +64 -0
- data/lib/jlpt/core/vocab_classifier.rb +48 -0
- data/lib/jlpt/data/kanji.json +2213 -0
- data/lib/jlpt/data/vocab.json +50298 -0
- data/lib/jlpt/formatters/batch_processor.rb +41 -0
- data/lib/jlpt/formatters/exporter.rb +55 -0
- data/lib/jlpt/formatters/furigana.rb +69 -0
- data/lib/jlpt/formatters/reporter.rb +72 -0
- data/lib/jlpt/version.rb +5 -0
- data/lib/jlpt.rb +50 -0
- metadata +184 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Corpus Lexical Diversity and Statistical Analyzer.
|
|
5
|
+
#
|
|
6
|
+
# Computes Type-Token Ratio (TTR), total word count, unique word count,
|
|
7
|
+
# and hapax legomena count for text.
|
|
8
|
+
#
|
|
9
|
+
module CorpusStats
|
|
10
|
+
class << self
|
|
11
|
+
# Analyze text corpus stats and lexical diversity (TTR)
|
|
12
|
+
#
|
|
13
|
+
# @param text [String] Japanese text
|
|
14
|
+
# @return [Hash] corpus statistics hash
|
|
15
|
+
def analyze(text)
|
|
16
|
+
cleaned = Preprocessor.clean(text)
|
|
17
|
+
return empty_stats if cleaned.empty?
|
|
18
|
+
|
|
19
|
+
tokens = Tokenizer.lemmata(cleaned)
|
|
20
|
+
return empty_stats if tokens.empty?
|
|
21
|
+
|
|
22
|
+
total = tokens.length
|
|
23
|
+
counts = tokens.tally
|
|
24
|
+
unique = counts.keys.length
|
|
25
|
+
hapax = counts.values.count(1)
|
|
26
|
+
ttr = (unique.to_f / total).round(2)
|
|
27
|
+
|
|
28
|
+
{ total_words: total, unique_words: unique, hapax_count: hapax, ttr: ttr }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def empty_stats
|
|
34
|
+
{ total_words: 0, unique_words: 0, hapax_count: 0, ttr: 0.0 }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Dokkai (Reading Comprehension) Readability Evaluator.
|
|
5
|
+
#
|
|
6
|
+
# Evaluates Japanese text complexity for reading comprehension tasks,
|
|
7
|
+
# sentence length distribution, particle density, and recommended target reading time.
|
|
8
|
+
#
|
|
9
|
+
module Dokkai
|
|
10
|
+
PARTICLES_REGEX = /[はがをにで行とからよりへね]/.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Evaluate reading comprehension complexity and estimated reading time
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] Japanese text
|
|
16
|
+
# @return [Hash] Dokkai metrics including reading time and readability score
|
|
17
|
+
def evaluate(text)
|
|
18
|
+
cleaned = Preprocessor.clean(text)
|
|
19
|
+
return empty_evaluation if cleaned.empty?
|
|
20
|
+
|
|
21
|
+
build_evaluation_metrics(cleaned)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_evaluation_metrics(cleaned)
|
|
27
|
+
sents = Preprocessor.sentences(cleaned)
|
|
28
|
+
avg_len = (cleaned.length.to_f / [sents.length, 1].max).round(1)
|
|
29
|
+
particles = cleaned.scan(PARTICLES_REGEX).length
|
|
30
|
+
|
|
31
|
+
{ readability_score: calculate_readability(avg_len, particles, cleaned.length),
|
|
32
|
+
estimated_reading_time_min: (cleaned.length / 400.0).round(2),
|
|
33
|
+
sentence_count: sents.length, avg_sentence_length: avg_len, particle_count: particles }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def calculate_readability(avg_len, particles, char_len)
|
|
37
|
+
density = (particles.to_f / [char_len, 1].max) * 100.0
|
|
38
|
+
score = (avg_len * 0.5) + (density * 2.0)
|
|
39
|
+
[score.round(2), 100.0].min
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty_evaluation
|
|
43
|
+
{
|
|
44
|
+
readability_score: 0.0,
|
|
45
|
+
estimated_reading_time_min: 0.0,
|
|
46
|
+
sentence_count: 0,
|
|
47
|
+
avg_sentence_length: 0.0,
|
|
48
|
+
particle_count: 0
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Exam Readiness and Level Coverage Evaluator.
|
|
5
|
+
#
|
|
6
|
+
# Evaluates how well input text aligns with a target JLPT exam level (N5-N1),
|
|
7
|
+
# calculating target level vocabulary density and readiness score.
|
|
8
|
+
#
|
|
9
|
+
module ExamReadiness
|
|
10
|
+
LEVEL_THRESHOLD_MAP = { n5: 1, n4: 2, n3: 3, n2: 4, n1: 5 }.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Evaluate readiness score for a target JLPT level
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] Japanese text
|
|
16
|
+
# @param target_level [Symbol, String] target exam level (:n5, :n4, :n3, :n2, :n1)
|
|
17
|
+
# @return [Hash] exam readiness metrics
|
|
18
|
+
def evaluate(text, target_level: :n3)
|
|
19
|
+
target_sym = target_level.to_s.downcase.to_sym
|
|
20
|
+
target_val = LEVEL_THRESHOLD_MAP[target_sym] || 3
|
|
21
|
+
|
|
22
|
+
res = JLPT.analyze(text)
|
|
23
|
+
current_val = LEVEL_THRESHOLD_MAP[res.recommended_level] || 1
|
|
24
|
+
|
|
25
|
+
readiness = calculate_readiness_score(current_val, target_val, res.score)
|
|
26
|
+
status = determine_readiness_status(readiness)
|
|
27
|
+
|
|
28
|
+
{ target_level: target_sym, current_level: res.recommended_level,
|
|
29
|
+
readiness_score: readiness, status: status }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def calculate_readiness_score(current_val, target_val, score)
|
|
35
|
+
diff = current_val - target_val
|
|
36
|
+
if diff >= 0
|
|
37
|
+
100.0
|
|
38
|
+
else
|
|
39
|
+
[(100.0 + (diff * 25.0) + (score * 0.2)).round(2), 0.0].max
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def determine_readiness_status(score)
|
|
44
|
+
case score
|
|
45
|
+
when 90.0..100.0 then :ready
|
|
46
|
+
when 60.0..89.99 then :moderate
|
|
47
|
+
else :needs_practice
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# JLPT Grammar Pattern Detection Engine.
|
|
5
|
+
#
|
|
6
|
+
# Recognizes Japanese grammar constructions across levels N5 to N1.
|
|
7
|
+
#
|
|
8
|
+
module Grammar
|
|
9
|
+
PATTERNS = [
|
|
10
|
+
{ pattern: /てはいけない|てはならない/, level: :n5, name: '〜てはいけない' },
|
|
11
|
+
{ pattern: /てください|てくださいませんか/, level: :n5, name: '〜てください' },
|
|
12
|
+
{ pattern: /なければならない|なければいけない/, level: :n5, name: '〜なければならない' },
|
|
13
|
+
{ pattern: /ことができる|ことができない/, level: :n4, name: '〜ことができる' },
|
|
14
|
+
{ pattern: /ようになっている/, level: :n4, name: '〜ようになっている' },
|
|
15
|
+
{ pattern: /わけがない|わけではない/, level: :n3, name: '〜わけがない' },
|
|
16
|
+
{ pattern: /に違いない/, level: :n2, name: '〜に違いない' },
|
|
17
|
+
{ pattern: /ざるを得ない/, level: :n1, name: '〜ざるを得ない' }
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Detect grammar patterns in text
|
|
22
|
+
#
|
|
23
|
+
# @param text [String] Japanese text
|
|
24
|
+
# @return [Array<Hash>] array of detected grammar hashes
|
|
25
|
+
def extract(text)
|
|
26
|
+
cleaned = Preprocessor.clean(text)
|
|
27
|
+
return [] if cleaned.empty?
|
|
28
|
+
|
|
29
|
+
PATTERNS.filter_map do |rule|
|
|
30
|
+
{ name: rule[:name], level: rule[:level], pattern: rule[:pattern] } if cleaned.match?(rule[:pattern])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Breakdown of detected grammar patterns by JLPT level
|
|
35
|
+
#
|
|
36
|
+
# @param text [String] Japanese text
|
|
37
|
+
# @return [Hash{Symbol => Array<String>}]
|
|
38
|
+
def breakdown(text)
|
|
39
|
+
extracted = extract(text)
|
|
40
|
+
res = { n5: [], n4: [], n3: [], n2: [], n1: [] }
|
|
41
|
+
|
|
42
|
+
extracted.each do |g|
|
|
43
|
+
lvl = g[:level]
|
|
44
|
+
res[lvl] << g[:name] if res.key?(lvl)
|
|
45
|
+
end
|
|
46
|
+
res
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Jukugo (Kanji Compound) Extractor and Classifier.
|
|
5
|
+
#
|
|
6
|
+
# Identifies multi-kanji compound words (2-kanji, 3-kanji, 4-kanji Yojijukugo)
|
|
7
|
+
# in Japanese text.
|
|
8
|
+
#
|
|
9
|
+
module Jukugo
|
|
10
|
+
JUKUGO_REGEX = /[\u4E00-\u9FFF]{2,}/.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Extract all kanji compounds from Japanese text
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] Japanese text
|
|
16
|
+
# @return [Array<String>] unique array of Jukugo compounds
|
|
17
|
+
def extract(text)
|
|
18
|
+
cleaned = Preprocessor.clean(text)
|
|
19
|
+
return [] if cleaned.empty?
|
|
20
|
+
|
|
21
|
+
cleaned.scan(JUKUGO_REGEX).uniq
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Breakdown compounds by character length (2-kanji, 3-kanji, 4-kanji+)
|
|
25
|
+
#
|
|
26
|
+
# @param text [String] Japanese text
|
|
27
|
+
# @return [Hash] hash with keys :two_char, :three_char, :yojijukugo, :long
|
|
28
|
+
def breakdown(text)
|
|
29
|
+
result = { two_char: [], three_char: [], yojijukugo: [], long: [] }
|
|
30
|
+
extract(text).each { |c| categorize_compound(c, result) }
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def categorize_compound(compound, result)
|
|
37
|
+
case compound.length
|
|
38
|
+
when 2 then result[:two_char] << compound
|
|
39
|
+
when 3 then result[:three_char] << compound
|
|
40
|
+
when 4 then result[:yojijukugo] << compound
|
|
41
|
+
else result[:long] << compound
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Japanese Word Origin and Loanword (Gairaigo 外来語) Detector.
|
|
5
|
+
#
|
|
6
|
+
# Categorizes vocabulary into Katakana loanwords (Gairaigo), Sino-Japanese
|
|
7
|
+
# Kanji compounds (Kango), and native Japanese words (Wago).
|
|
8
|
+
#
|
|
9
|
+
module Loanwords
|
|
10
|
+
KATAKANA_REGEX = /^[\u30A0-\u30FFー]+$/.freeze
|
|
11
|
+
KANJI_REGEX = /[\u4E00-\u9FFF]/.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Extract all Katakana loanwords (Gairaigo) from text
|
|
15
|
+
#
|
|
16
|
+
# @param text [String] Japanese text
|
|
17
|
+
# @return [Array<String>] unique array of loanword lemmata
|
|
18
|
+
def extract(text)
|
|
19
|
+
cleaned = Preprocessor.clean(text)
|
|
20
|
+
return [] if cleaned.empty?
|
|
21
|
+
|
|
22
|
+
lemmas = Tokenizer.lemmata(cleaned)
|
|
23
|
+
lemmas.select { |lemma| lemma.match?(KATAKANA_REGEX) && lemma.length >= 2 }.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Categorize text vocabulary by word origin (Wago, Kango, Gairaigo)
|
|
27
|
+
#
|
|
28
|
+
# @param text [String] Japanese text
|
|
29
|
+
# @return [Hash{Symbol => Array<String>}] origin breakdown hash
|
|
30
|
+
def breakdown(text)
|
|
31
|
+
cleaned = Preprocessor.clean(text)
|
|
32
|
+
res = { gairaigo: [], kango: [], wago: [] }
|
|
33
|
+
return res if cleaned.empty?
|
|
34
|
+
|
|
35
|
+
Tokenizer.lemmata(cleaned).uniq.each do |lemma|
|
|
36
|
+
categorize_lemma(lemma, res)
|
|
37
|
+
end
|
|
38
|
+
res
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def categorize_lemma(lemma, res)
|
|
44
|
+
if lemma.match?(KATAKANA_REGEX) && lemma.length >= 2
|
|
45
|
+
res[:gairaigo] << lemma
|
|
46
|
+
elsif lemma.match?(KANJI_REGEX)
|
|
47
|
+
res[:kango] << lemma
|
|
48
|
+
else
|
|
49
|
+
res[:wago] << lemma
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Onomatopoeia (擬音語 Giongo & 擬態語 Gitaito) Extractor.
|
|
5
|
+
#
|
|
6
|
+
# Identifies Japanese sound-symbolic and mimetic words in Hiragana or Katakana.
|
|
7
|
+
#
|
|
8
|
+
module Onomatopeia
|
|
9
|
+
ONOMATOPOEIA_PATTERNS = %w[
|
|
10
|
+
どきどき わくわく ぺらぺら にこにこ ふわふわ ぎっしり
|
|
11
|
+
ギリギリ イライラ キラキラ ドキドキ ニコニコ ペラペラ
|
|
12
|
+
もちもち すらすら どんどん だんだん いろいろ
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Extract onomatopoeia words from Japanese text
|
|
17
|
+
#
|
|
18
|
+
# @param text [String] Japanese text
|
|
19
|
+
# @return [Array<String>] unique array of onomatopoeias
|
|
20
|
+
def extract(text)
|
|
21
|
+
cleaned = Preprocessor.clean(text)
|
|
22
|
+
return [] if cleaned.empty?
|
|
23
|
+
|
|
24
|
+
matches = ONOMATOPOEIA_PATTERNS.select { |word| cleaned.include?(word) }
|
|
25
|
+
matches.uniq
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Pitch Accent Profiler and Pattern Lookup.
|
|
5
|
+
#
|
|
6
|
+
# Categorizes pitch accent types (Heiban / 平板 [0], Atamadaka / 頭高 [1], Nakadaka / 中高 [2+], Odaka / 尾高)
|
|
7
|
+
# for Japanese vocabulary words.
|
|
8
|
+
#
|
|
9
|
+
module PitchAccent
|
|
10
|
+
# Built-in pitch accent database for common words
|
|
11
|
+
ACCENT_MAP = {
|
|
12
|
+
'学校' => { type: :odaka, pattern: 0 },
|
|
13
|
+
'私' => { type: :heiban, pattern: 0 },
|
|
14
|
+
'日本' => { type: :atamadaka, pattern: 1 },
|
|
15
|
+
'猫' => { type: :atamadaka, pattern: 1 },
|
|
16
|
+
'犬' => { type: :atamadaka, pattern: 2 },
|
|
17
|
+
'本' => { type: :atamadaka, pattern: 1 },
|
|
18
|
+
'先生' => { type: :nakadaka, pattern: 3 }
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Lookup pitch accent for a vocabulary word
|
|
23
|
+
#
|
|
24
|
+
# @param word [String] vocabulary word
|
|
25
|
+
# @return [Hash, nil] pitch accent hash with :type and :pattern
|
|
26
|
+
def accent_for(word)
|
|
27
|
+
return nil if word.nil? || word.to_s.strip.empty?
|
|
28
|
+
|
|
29
|
+
ACCENT_MAP[word.to_s.strip] || { type: :heiban, pattern: 0 }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Profile pitch accent patterns in Japanese text
|
|
33
|
+
#
|
|
34
|
+
# @param text [String] Japanese text
|
|
35
|
+
# @return [Hash] breakdown of pitch accent types in text
|
|
36
|
+
def profile(text)
|
|
37
|
+
tokens = Tokenizer.tokenize(text)
|
|
38
|
+
result = { heiban: 0, atamadaka: 0, nakadaka: 0, odaka: 0 }
|
|
39
|
+
|
|
40
|
+
tokens.each do |t|
|
|
41
|
+
info = accent_for(t[:surface])
|
|
42
|
+
next unless info
|
|
43
|
+
|
|
44
|
+
type = info[:type]
|
|
45
|
+
result[type] += 1 if result.key?(type)
|
|
46
|
+
end
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Part-of-Speech (POS) Distribution Profiler.
|
|
5
|
+
#
|
|
6
|
+
# Analyzes Japanese tokens and calculates percentage breakdown per POS category
|
|
7
|
+
# (nouns, verbs, adjectives, particles, adverbs, symbols).
|
|
8
|
+
#
|
|
9
|
+
module PosProfiler
|
|
10
|
+
POS_LABELS = {
|
|
11
|
+
'名詞' => :noun,
|
|
12
|
+
'動詞' => :verb,
|
|
13
|
+
'形容詞' => :adjective,
|
|
14
|
+
'助詞' => :particle,
|
|
15
|
+
'副詞' => :adverb,
|
|
16
|
+
'記号' => :symbol
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Analyze text POS distribution
|
|
21
|
+
#
|
|
22
|
+
# @param text [String] Japanese text
|
|
23
|
+
# @return [Hash] POS breakdown hash with counts and distribution
|
|
24
|
+
def profile(text)
|
|
25
|
+
tokens = Tokenizer.tokenize(text)
|
|
26
|
+
return empty_profile if tokens.empty?
|
|
27
|
+
|
|
28
|
+
counts = Hash.new(0)
|
|
29
|
+
tokens.each do |token|
|
|
30
|
+
category = POS_LABELS[token[:pos]] || :other
|
|
31
|
+
counts[category] += 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
total = tokens.length
|
|
35
|
+
dist = counts.transform_values { |cnt| (cnt.to_f / total * 100).round(2) }
|
|
36
|
+
|
|
37
|
+
{ counts: counts, distribution: dist, total_tokens: total }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def empty_profile
|
|
43
|
+
{ counts: {}, distribution: {}, total_tokens: 0 }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Text Simplifier and Beginner Synonym Recommender.
|
|
5
|
+
#
|
|
6
|
+
# Scans Japanese text for complex N2/N1 vocabulary and recommends simpler
|
|
7
|
+
# N5/N4/N3 synonym alternatives for Japanese language learners.
|
|
8
|
+
#
|
|
9
|
+
module Simplifier
|
|
10
|
+
SIMPLIFICATIONS = {
|
|
11
|
+
'著しい' => { reading: 'いちじるしい', suggestions: %w[大きい 目立つ] },
|
|
12
|
+
'著しく' => { reading: 'いちじるしく', suggestions: %w[大きく 目立って] },
|
|
13
|
+
'未曽有' => { reading: 'みぞう', suggestions: %w[今までにない 初めての] },
|
|
14
|
+
'浸透' => { reading: 'しんとう', suggestions: %w[広がる 伝わる] },
|
|
15
|
+
'阻害' => { reading: 'そがい', suggestions: %w[邪魔する 妨げる] },
|
|
16
|
+
'急速' => { reading: 'きゅうそく', suggestions: %w[とても速い 急に] },
|
|
17
|
+
'懸念' => { reading: 'けねん', suggestions: %w[心配 不安] },
|
|
18
|
+
'普及' => { reading: 'ふきゅう', suggestions: %w[広まる みんなが使う] }
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Analyze text and return simplification suggestions for high-level words
|
|
23
|
+
#
|
|
24
|
+
# @param text [String] Japanese text
|
|
25
|
+
# @return [Array<Hash>] array of simplification suggestions
|
|
26
|
+
def simplify(text)
|
|
27
|
+
cleaned = Preprocessor.clean(text)
|
|
28
|
+
return [] if cleaned.empty?
|
|
29
|
+
|
|
30
|
+
SIMPLIFICATIONS.filter_map do |word, data|
|
|
31
|
+
build_suggestion(word, data) if cleaned.include?(word)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_suggestion(word, data)
|
|
38
|
+
level = Dictionary.vocab_level(word) || :n1
|
|
39
|
+
{
|
|
40
|
+
word: word,
|
|
41
|
+
level: level,
|
|
42
|
+
reading: data[:reading],
|
|
43
|
+
suggestions: data[:suggestions]
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Style and Speech Register Profiler.
|
|
5
|
+
#
|
|
6
|
+
# Analyzes polite vs plain form usage, Keigo honorifics (Sonkeigo/Kenjougo),
|
|
7
|
+
# dialect markers, and internet slang in Japanese text.
|
|
8
|
+
#
|
|
9
|
+
module StyleProfiler
|
|
10
|
+
POLITE_ENDINGS = /(?:です|ます|でした|ました|ではありません|ではありませんでした|でしょう)\b/.freeze
|
|
11
|
+
KEIGO_SONKEIGO_PATTERNS = /(?:お|ご)\p{L}+(?:になる|なさる|くださる)|いらっしゃる|おっしゃる|ご覧/.freeze
|
|
12
|
+
KEIGO_KENJOUGO_PATTERNS = /(?:お|ご)\p{L}+(?:する|いたします)|申す|申し上げ|参る|参り|いただく|いただき|存じる|存じ/.freeze
|
|
13
|
+
SLANG_PATTERNS = /(?:[wW]{2,}|笑|ww|やねん|やで|ほんま)/.freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Analyze text style and return detailed profile hash
|
|
17
|
+
#
|
|
18
|
+
# @param text [String] Japanese text
|
|
19
|
+
# @return [Hash] style profile with formality, keigo, and register metrics
|
|
20
|
+
def profile(text)
|
|
21
|
+
cleaned = Preprocessor.clean(text)
|
|
22
|
+
return empty_profile if cleaned.empty?
|
|
23
|
+
|
|
24
|
+
build_style_profile(cleaned)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_style_profile(cleaned)
|
|
30
|
+
polite = cleaned.scan(POLITE_ENDINGS).length
|
|
31
|
+
sonkeigo = cleaned.scan(KEIGO_SONKEIGO_PATTERNS).length
|
|
32
|
+
kenjougo = cleaned.scan(KEIGO_KENJOUGO_PATTERNS).length
|
|
33
|
+
slang = cleaned.scan(SLANG_PATTERNS).length
|
|
34
|
+
|
|
35
|
+
formality = calculate_formality(cleaned, polite, sonkeigo, kenjougo)
|
|
36
|
+
register = formality > 50.0 ? :polite : :plain
|
|
37
|
+
|
|
38
|
+
{ formality_score: formality, register: register, polite_count: polite,
|
|
39
|
+
sonkeigo_count: sonkeigo, kenjougo_count: kenjougo, slang_count: slang }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def calculate_formality(text, polite_count, sonkeigo_count, kenjougo_count)
|
|
43
|
+
sentences = Preprocessor.sentences(text)
|
|
44
|
+
return 0.0 if sentences.empty?
|
|
45
|
+
|
|
46
|
+
ratio = (polite_count + (sonkeigo_count * 1.5) + (kenjougo_count * 1.5)) / sentences.length
|
|
47
|
+
[(ratio * 60.0).round(2), 100.0].min
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def empty_profile
|
|
51
|
+
{
|
|
52
|
+
formality_score: 0.0,
|
|
53
|
+
register: :plain,
|
|
54
|
+
polite_count: 0,
|
|
55
|
+
sonkeigo_count: 0,
|
|
56
|
+
kenjougo_count: 0,
|
|
57
|
+
slang_count: 0
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JLPT
|
|
4
|
+
# Syntax and Sentence Clause Complexity Analyzer.
|
|
5
|
+
#
|
|
6
|
+
# Identifies conjunction particles and subordinate clause connectives
|
|
7
|
+
# (が, けど, から, ので, とき, たら, ば, なら, のに, けれども), calculating
|
|
8
|
+
# clause density and syntactic complexity indices.
|
|
9
|
+
#
|
|
10
|
+
module Syntax
|
|
11
|
+
CONJUNCTION_PATTERNS = /(?:けれども|けれど|けど|ので|から|とき|たら|なら|のに|が|し)\b/.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Analyze syntactic clause complexity of Japanese text
|
|
15
|
+
#
|
|
16
|
+
# @param text [String] Japanese text
|
|
17
|
+
# @return [Hash] syntax complexity metrics
|
|
18
|
+
def analyze(text)
|
|
19
|
+
cleaned = Preprocessor.clean(text)
|
|
20
|
+
return empty_analysis if cleaned.empty?
|
|
21
|
+
|
|
22
|
+
compute_syntax_metrics(cleaned)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def compute_syntax_metrics(cleaned)
|
|
28
|
+
sents = Preprocessor.sentences(cleaned)
|
|
29
|
+
matches = cleaned.scan(CONJUNCTION_PATTERNS)
|
|
30
|
+
cnt = matches.length
|
|
31
|
+
avg_clauses = ((cnt + sents.length).to_f / [sents.length, 1].max).round(2)
|
|
32
|
+
|
|
33
|
+
{ conjunction_count: cnt, conjunctions: matches.uniq, sentence_count: sents.length,
|
|
34
|
+
avg_clauses_per_sentence: avg_clauses, complexity_index: [(avg_clauses * 25.0).round(2), 100.0].min }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def empty_analysis
|
|
38
|
+
{ conjunction_count: 0, conjunctions: [], sentence_count: 0,
|
|
39
|
+
avg_clauses_per_sentence: 1.0, complexity_index: 0.0 }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|