kotoshu 0.5.1 → 0.6.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 +4 -4
- data/data/en/grammar/rules.yaml +274 -0
- data/lib/kotoshu/algorithms/edit_distance.rb +75 -0
- data/lib/kotoshu/algorithms.rb +1 -0
- data/lib/kotoshu/data_structures/bloom_filter.rb +11 -5
- data/lib/kotoshu/dictionary/base.rb +8 -0
- data/lib/kotoshu/grammar/pattern_matchers/possessive_contraction_matcher.rb +87 -0
- data/lib/kotoshu/grammar/pattern_matchers/sentence_start_matcher.rb +84 -0
- data/lib/kotoshu/grammar/pattern_matchers/word_list_matcher.rb +66 -0
- data/lib/kotoshu/grammar/rule.rb +6 -0
- data/lib/kotoshu/grammar.rb +3 -0
- data/lib/kotoshu/language/normalizer/base.rb +0 -2
- data/lib/kotoshu/languages/en/language.rb +4 -0
- data/lib/kotoshu/languages/es/language.rb +4 -0
- data/lib/kotoshu/languages/pt/language.rb +4 -0
- data/lib/kotoshu/languages/ru/language.rb +4 -0
- data/lib/kotoshu/suggestions/strategies/composite_strategy.rb +8 -10
- data/lib/kotoshu/suggestions/strategies/edit_distance_strategy.rb +7 -45
- data/lib/kotoshu/suggestions/suggestion_set.rb +28 -4
- data/lib/kotoshu/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 503b4db65b4971cdc8854123771d9c1855af0de6f408f625d63219d5470c848c
|
|
4
|
+
data.tar.gz: caa57938fc7a76f3c0f7d026346db8e7f247db34b1a9b51d79ab2e9962668f57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bb38cd338777586b8bf3c4700d36c5bd083b81f002fc9df0ee3ab667bde36a2a31b0dd8ebd9096543b57de8e151c18b9265df900306c7c71ae06cdc8e33825f
|
|
7
|
+
data.tar.gz: 6a11f2cbc34632d9533af0665308943063f06d952816e277fd1e995adfbbe84a56915693a6b9a9a53a4c64589ff8d09399c66ace9c7f7c657f865e4b0ef7adc4
|
data/data/en/grammar/rules.yaml
CHANGED
|
@@ -466,3 +466,277 @@ rules:
|
|
|
466
466
|
suggestion: "used to"
|
|
467
467
|
message: '"use to" should be "used to"'
|
|
468
468
|
suggestion: 'used to'
|
|
469
|
+
|
|
470
|
+
- id: EN_ITS_IT_S
|
|
471
|
+
name: "its/it's"
|
|
472
|
+
category: confusion
|
|
473
|
+
severity: warning
|
|
474
|
+
description: |
|
|
475
|
+
"its" (no apostrophe) is the possessive pronoun — like "his"
|
|
476
|
+
or "her". "it's" (with apostrophe) is a contraction of "it is"
|
|
477
|
+
or "it has". When "its" is followed by an adjective, adverb,
|
|
478
|
+
or past participle, the writer usually meant the contraction.
|
|
479
|
+
message: "Did you mean \"it's\" (contraction of \"it is\")?"
|
|
480
|
+
suggestion: "it's"
|
|
481
|
+
patterns:
|
|
482
|
+
- context:
|
|
483
|
+
target_token: "its"
|
|
484
|
+
trigger_when_followed_by:
|
|
485
|
+
tags: [ADJ, ADV]
|
|
486
|
+
words:
|
|
487
|
+
- been
|
|
488
|
+
- going
|
|
489
|
+
- getting
|
|
490
|
+
- becoming
|
|
491
|
+
- appearing
|
|
492
|
+
- seeming
|
|
493
|
+
- looking
|
|
494
|
+
- feeling
|
|
495
|
+
- sounding
|
|
496
|
+
- tasting
|
|
497
|
+
- smelling
|
|
498
|
+
conditions:
|
|
499
|
+
- type: possessive_contraction_check
|
|
500
|
+
|
|
501
|
+
- id: EN_YOUR_YOURE
|
|
502
|
+
name: "your/you're"
|
|
503
|
+
category: confusion
|
|
504
|
+
severity: warning
|
|
505
|
+
description: |
|
|
506
|
+
"your" is the possessive pronoun. "you're" is a contraction of
|
|
507
|
+
"you are". When "your" is followed by an adjective or past
|
|
508
|
+
participle, the writer usually meant the contraction.
|
|
509
|
+
message: "Did you mean \"you're\" (contraction of \"you are\")?"
|
|
510
|
+
suggestion: "you're"
|
|
511
|
+
patterns:
|
|
512
|
+
- context:
|
|
513
|
+
target_token: "your"
|
|
514
|
+
trigger_when_followed_by:
|
|
515
|
+
tags: [ADJ, ADV]
|
|
516
|
+
words:
|
|
517
|
+
- going
|
|
518
|
+
- getting
|
|
519
|
+
- becoming
|
|
520
|
+
- coming
|
|
521
|
+
- leaving
|
|
522
|
+
- arriving
|
|
523
|
+
- doing
|
|
524
|
+
- making
|
|
525
|
+
- saying
|
|
526
|
+
- thinking
|
|
527
|
+
- feeling
|
|
528
|
+
- looking
|
|
529
|
+
- sounding
|
|
530
|
+
conditions:
|
|
531
|
+
- type: possessive_contraction_check
|
|
532
|
+
|
|
533
|
+
- id: EN_WHOSE_WHOS
|
|
534
|
+
name: "whose/who's"
|
|
535
|
+
category: confusion
|
|
536
|
+
severity: warning
|
|
537
|
+
description: |
|
|
538
|
+
"whose" is the possessive form of "who". "who's" is a
|
|
539
|
+
contraction of "who is" or "who has". When "whose" is followed
|
|
540
|
+
by a verb form, the writer usually meant the contraction.
|
|
541
|
+
message: "Did you mean \"who's\" (contraction of \"who is\")?"
|
|
542
|
+
suggestion: "who's"
|
|
543
|
+
patterns:
|
|
544
|
+
- context:
|
|
545
|
+
target_token: "whose"
|
|
546
|
+
trigger_when_followed_by:
|
|
547
|
+
tags: [VERB]
|
|
548
|
+
words:
|
|
549
|
+
- coming
|
|
550
|
+
- going
|
|
551
|
+
- here
|
|
552
|
+
- there
|
|
553
|
+
- talking
|
|
554
|
+
- speaking
|
|
555
|
+
- calling
|
|
556
|
+
- looking
|
|
557
|
+
- watching
|
|
558
|
+
- working
|
|
559
|
+
- doing
|
|
560
|
+
- making
|
|
561
|
+
- been
|
|
562
|
+
- got
|
|
563
|
+
conditions:
|
|
564
|
+
- type: possessive_contraction_check
|
|
565
|
+
|
|
566
|
+
- id: EN_SENTENCE_START_CAP
|
|
567
|
+
name: "Sentence start capitalization"
|
|
568
|
+
category: capitalization
|
|
569
|
+
severity: error
|
|
570
|
+
description: |
|
|
571
|
+
The first word of a sentence should be capitalized. Sentence
|
|
572
|
+
boundaries are: the start of the stream, or any token whose
|
|
573
|
+
final character is ".", "!", or "?".
|
|
574
|
+
message: "Sentences should start with a capital letter"
|
|
575
|
+
suggestion: "Capitalize the first letter"
|
|
576
|
+
patterns:
|
|
577
|
+
- conditions:
|
|
578
|
+
- type: sentence_start_check
|
|
579
|
+
|
|
580
|
+
- id: EN_PROPER_NOUN_CAP
|
|
581
|
+
name: "Proper noun capitalization"
|
|
582
|
+
category: capitalization
|
|
583
|
+
severity: error
|
|
584
|
+
description: |
|
|
585
|
+
Days of the week, months, languages, and some other common
|
|
586
|
+
proper nouns must be capitalized in English.
|
|
587
|
+
message: "Proper nouns should be capitalized"
|
|
588
|
+
suggestion: "Capitalize the proper noun"
|
|
589
|
+
patterns:
|
|
590
|
+
- conditions:
|
|
591
|
+
- type: word_list_check
|
|
592
|
+
corrections:
|
|
593
|
+
# Days of the week
|
|
594
|
+
monday: Monday
|
|
595
|
+
tuesday: Tuesday
|
|
596
|
+
wednesday: Wednesday
|
|
597
|
+
thursday: Thursday
|
|
598
|
+
friday: Friday
|
|
599
|
+
saturday: Saturday
|
|
600
|
+
sunday: Sunday
|
|
601
|
+
# Months
|
|
602
|
+
january: January
|
|
603
|
+
february: February
|
|
604
|
+
march: March
|
|
605
|
+
april: April
|
|
606
|
+
june: June
|
|
607
|
+
july: July
|
|
608
|
+
august: August
|
|
609
|
+
september: September
|
|
610
|
+
october: October
|
|
611
|
+
november: November
|
|
612
|
+
december: December
|
|
613
|
+
# Languages and nationalities
|
|
614
|
+
english: English
|
|
615
|
+
spanish: Spanish
|
|
616
|
+
french: French
|
|
617
|
+
german: German
|
|
618
|
+
italian: Italian
|
|
619
|
+
russian: Russian
|
|
620
|
+
chinese: Chinese
|
|
621
|
+
japanese: Japanese
|
|
622
|
+
korean: Korean
|
|
623
|
+
arabic: Arabic
|
|
624
|
+
hebrew: Hebrew
|
|
625
|
+
hindi: Hindi
|
|
626
|
+
portuguese: Portuguese
|
|
627
|
+
dutch: Dutch
|
|
628
|
+
swedish: Swedish
|
|
629
|
+
polish: Polish
|
|
630
|
+
turkish: Turkish
|
|
631
|
+
greek: Greek
|
|
632
|
+
latin: Latin
|
|
633
|
+
|
|
634
|
+
- id: EN_MORE_THEN
|
|
635
|
+
name: "more then → more than"
|
|
636
|
+
category: grammar
|
|
637
|
+
severity: error
|
|
638
|
+
description: |
|
|
639
|
+
"more then" is a common confusion. The comparative "more"
|
|
640
|
+
takes "than" (the comparison conjunction), not "then"
|
|
641
|
+
(the time adverb).
|
|
642
|
+
patterns:
|
|
643
|
+
- conditions:
|
|
644
|
+
- type: phrase_check
|
|
645
|
+
wrong_phrase: "more then"
|
|
646
|
+
suggestion: "more than"
|
|
647
|
+
message: '"more then" should be "more than"'
|
|
648
|
+
suggestion: 'more than'
|
|
649
|
+
|
|
650
|
+
- id: EN_LESS_THEN
|
|
651
|
+
name: "less then → less than"
|
|
652
|
+
category: grammar
|
|
653
|
+
severity: error
|
|
654
|
+
description: |
|
|
655
|
+
"less then" is a common confusion. "less" takes "than" for
|
|
656
|
+
comparison.
|
|
657
|
+
patterns:
|
|
658
|
+
- conditions:
|
|
659
|
+
- type: phrase_check
|
|
660
|
+
wrong_phrase: "less then"
|
|
661
|
+
suggestion: "less than"
|
|
662
|
+
message: '"less then" should be "less than"'
|
|
663
|
+
suggestion: 'less than'
|
|
664
|
+
|
|
665
|
+
- id: EN_RATHER_THEN
|
|
666
|
+
name: "rather then → rather than"
|
|
667
|
+
category: grammar
|
|
668
|
+
severity: error
|
|
669
|
+
description: |
|
|
670
|
+
"rather then" is a common confusion. "rather" takes "than"
|
|
671
|
+
in preference constructions ("I would rather X than Y").
|
|
672
|
+
patterns:
|
|
673
|
+
- conditions:
|
|
674
|
+
- type: phrase_check
|
|
675
|
+
wrong_phrase: "rather then"
|
|
676
|
+
suggestion: "rather than"
|
|
677
|
+
message: '"rather then" should be "rather than"'
|
|
678
|
+
suggestion: 'rather than'
|
|
679
|
+
|
|
680
|
+
- id: EN_OTHER_THEN
|
|
681
|
+
name: "other then → other than"
|
|
682
|
+
category: grammar
|
|
683
|
+
severity: error
|
|
684
|
+
description: |
|
|
685
|
+
"other then" is a common confusion. "other than" is the
|
|
686
|
+
correct idiom for "except for" or "apart from".
|
|
687
|
+
patterns:
|
|
688
|
+
- conditions:
|
|
689
|
+
- type: phrase_check
|
|
690
|
+
wrong_phrase: "other then"
|
|
691
|
+
suggestion: "other than"
|
|
692
|
+
message: '"other then" should be "other than"'
|
|
693
|
+
suggestion: 'other than'
|
|
694
|
+
|
|
695
|
+
- id: EN_COULD_CARE_LESS
|
|
696
|
+
name: "could care less → couldn't care less"
|
|
697
|
+
category: grammar
|
|
698
|
+
severity: warning
|
|
699
|
+
description: |
|
|
700
|
+
"I could care less" literally means you DO care (because
|
|
701
|
+
it's possible to care less). The intended idiom is
|
|
702
|
+
"I couldn't care less" (it's impossible to care less
|
|
703
|
+
because you already care zero).
|
|
704
|
+
patterns:
|
|
705
|
+
- conditions:
|
|
706
|
+
- type: phrase_check
|
|
707
|
+
wrong_phrase: "could care less"
|
|
708
|
+
suggestion: "couldn't care less"
|
|
709
|
+
message: "\"could care less\" should be \"couldn't care less\""
|
|
710
|
+
suggestion: "couldn't care less"
|
|
711
|
+
|
|
712
|
+
- id: EN_FOR_FREE
|
|
713
|
+
name: "for free → free"
|
|
714
|
+
category: grammar
|
|
715
|
+
severity: suggestion
|
|
716
|
+
description: |
|
|
717
|
+
"for free" is redundant — "free" already means "at no cost".
|
|
718
|
+
Prefer just "free" (e.g., "I got it free", not "I got it
|
|
719
|
+
for free").
|
|
720
|
+
patterns:
|
|
721
|
+
- conditions:
|
|
722
|
+
- type: phrase_check
|
|
723
|
+
wrong_phrase: "for free"
|
|
724
|
+
suggestion: "free"
|
|
725
|
+
message: '"for free" is redundant — consider just "free"'
|
|
726
|
+
suggestion: 'free'
|
|
727
|
+
|
|
728
|
+
- id: EN_ALOT
|
|
729
|
+
name: "alot → a lot"
|
|
730
|
+
category: grammar
|
|
731
|
+
severity: error
|
|
732
|
+
description: |
|
|
733
|
+
"alot" is not a word. The correct spelling is "a lot"
|
|
734
|
+
(two words). "allot" (verb, to distribute) is a different
|
|
735
|
+
word.
|
|
736
|
+
patterns:
|
|
737
|
+
- conditions:
|
|
738
|
+
- type: phrase_check
|
|
739
|
+
wrong_phrase: "alot"
|
|
740
|
+
suggestion: "a lot"
|
|
741
|
+
message: '"alot" is not a word — use "a lot"'
|
|
742
|
+
suggestion: 'a lot'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kotoshu
|
|
4
|
+
module Algorithms
|
|
5
|
+
# Damerau-Levenshtein edit distance.
|
|
6
|
+
#
|
|
7
|
+
# Counts the minimum number of operations (insertion, deletion,
|
|
8
|
+
# substitution, or transposition of adjacent characters) needed
|
|
9
|
+
# to transform one string into another. The transposition
|
|
10
|
+
# extension distinguishes this from plain Levenshtein — a
|
|
11
|
+
# transposition (e.g. "teh" → "the") costs 1 instead of 2.
|
|
12
|
+
#
|
|
13
|
+
# Extracted from EditDistanceStrategy so that the algorithm is
|
|
14
|
+
# reusable independent of the strategy pipeline and testable
|
|
15
|
+
# without send-to-private.
|
|
16
|
+
module EditDistance
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Compute the Damerau-Levenshtein distance between two strings.
|
|
20
|
+
#
|
|
21
|
+
# @param str1 [String] First string
|
|
22
|
+
# @param str2 [String] Second string
|
|
23
|
+
# @return [Integer] Edit distance (0 when str1 == str2)
|
|
24
|
+
def distance(str1, str2)
|
|
25
|
+
return str2.length if str1.empty?
|
|
26
|
+
return str1.length if str2.empty?
|
|
27
|
+
|
|
28
|
+
len1 = str1.length
|
|
29
|
+
len2 = str2.length
|
|
30
|
+
|
|
31
|
+
d = Array.new(len1 + 1) { Array.new(len2 + 1, 0) }
|
|
32
|
+
|
|
33
|
+
(0..len1).each { |i| d[i][0] = i }
|
|
34
|
+
(0..len2).each { |j| d[0][j] = j }
|
|
35
|
+
|
|
36
|
+
(1..len1).each do |i|
|
|
37
|
+
(1..len2).each do |j|
|
|
38
|
+
cost = str1[i - 1] == str2[j - 1] ? 0 : 1
|
|
39
|
+
|
|
40
|
+
d[i][j] = [
|
|
41
|
+
d[i - 1][j] + 1, # deletion
|
|
42
|
+
d[i][j - 1] + 1, # insertion
|
|
43
|
+
d[i - 1][j - 1] + cost # substitution
|
|
44
|
+
].min
|
|
45
|
+
|
|
46
|
+
next unless i > 1 && j > 1 &&
|
|
47
|
+
str1[i - 1] == str2[j - 2] &&
|
|
48
|
+
str1[i - 2] == str2[j - 1]
|
|
49
|
+
|
|
50
|
+
d[i][j] = [d[i][j], d[i - 2][j - 2] + 1].min
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
d[len1][len2]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Compute edit distance with early-exit threshold.
|
|
58
|
+
#
|
|
59
|
+
# Returns nil when the true distance exceeds +threshold+,
|
|
60
|
+
# letting callers prune candidate pairs without paying for
|
|
61
|
+
# the full computation. The current implementation computes
|
|
62
|
+
# the full distance and then thresholds — kept as a seam so
|
|
63
|
+
# a future banded-DP optimization can drop in here.
|
|
64
|
+
#
|
|
65
|
+
# @param str1 [String] First string
|
|
66
|
+
# @param str2 [String] Second string
|
|
67
|
+
# @param threshold [Integer] Maximum distance to report
|
|
68
|
+
# @return [Integer, nil] Distance if ≤ threshold, else nil
|
|
69
|
+
def distance_with_threshold(str1, str2, threshold)
|
|
70
|
+
dist = distance(str1, str2)
|
|
71
|
+
dist <= threshold ? dist : nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/kotoshu/algorithms.rb
CHANGED
|
@@ -17,5 +17,6 @@ module Kotoshu
|
|
|
17
17
|
autoload :NgramSuggest, "kotoshu/algorithms/ngram_suggest"
|
|
18
18
|
autoload :PhonetSuggest, "kotoshu/algorithms/phonet_suggest"
|
|
19
19
|
autoload :Capitalization, "kotoshu/algorithms/capitalization"
|
|
20
|
+
autoload :EditDistance, "kotoshu/algorithms/edit_distance"
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -164,17 +164,23 @@ module Kotoshu
|
|
|
164
164
|
|
|
165
165
|
# Calculate hash index for item with seed.
|
|
166
166
|
#
|
|
167
|
-
# Uses double hashing
|
|
168
|
-
#
|
|
167
|
+
# Uses double hashing (Kirsch-Mitzenmacher):
|
|
168
|
+
# h_i(x) = (h1(x) + i * h2(x)) mod m
|
|
169
|
+
#
|
|
170
|
+
# The scheme requires h1 and h2 to be *independent* hash
|
|
171
|
+
# functions. Using `item.hash` for both (as an earlier version
|
|
172
|
+
# did) makes them linearly dependent and inflates the false-
|
|
173
|
+
# positive rate well above the configured target — observed
|
|
174
|
+
# ~6–16% versus the theoretical 1% on a filter sized for
|
|
175
|
+
# 1% FPR. Deriving h2 from a salted copy of the item restores
|
|
176
|
+
# independence and brings the observed rate back into line.
|
|
169
177
|
#
|
|
170
178
|
# @param item [String] The item to hash
|
|
171
179
|
# @param seed [Integer] Hash function index
|
|
172
180
|
# @return [Integer] Bit array index
|
|
173
181
|
def hash_index(item, seed)
|
|
174
|
-
# Use Ruby's built-in hash with different seeds
|
|
175
182
|
hash1 = item.hash
|
|
176
|
-
hash2 =
|
|
177
|
-
|
|
183
|
+
hash2 = "#{item}#bloom#salt".hash
|
|
178
184
|
(hash1 + (seed * hash2.abs)) % @size
|
|
179
185
|
end
|
|
180
186
|
end
|
|
@@ -232,6 +232,14 @@ module Kotoshu
|
|
|
232
232
|
@registry ||= {}
|
|
233
233
|
end
|
|
234
234
|
|
|
235
|
+
# Reset the type registry. Used by specs that register throw-away
|
|
236
|
+
# dictionary classes; not part of the runtime contract.
|
|
237
|
+
#
|
|
238
|
+
# @return [void]
|
|
239
|
+
def self.reset_registry!
|
|
240
|
+
@registry = {}
|
|
241
|
+
end
|
|
242
|
+
|
|
235
243
|
# Register a dictionary type.
|
|
236
244
|
#
|
|
237
245
|
# @param type [Symbol] The type key
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kotoshu
|
|
4
|
+
module Grammar
|
|
5
|
+
module PatternMatchers
|
|
6
|
+
# Matcher for possessive-vs-contraction confusion rules.
|
|
7
|
+
#
|
|
8
|
+
# Detects when a possessive form ("its", "your", "whose") is
|
|
9
|
+
# used where the corresponding contraction ("it's", "you're",
|
|
10
|
+
# "who's") is intended — or vice versa. The trigger is the
|
|
11
|
+
# following token's POS tag or word form.
|
|
12
|
+
#
|
|
13
|
+
# Rule shape:
|
|
14
|
+
#
|
|
15
|
+
# patterns:
|
|
16
|
+
# - context:
|
|
17
|
+
# target_token: "its"
|
|
18
|
+
# trigger_when_followed_by:
|
|
19
|
+
# tags: [ADJ, ADV]
|
|
20
|
+
# words: [been, going, getting]
|
|
21
|
+
# conditions:
|
|
22
|
+
# - type: possessive_contraction_check
|
|
23
|
+
#
|
|
24
|
+
# When the target token is followed by a token whose POS tag is
|
|
25
|
+
# in +tags+ OR whose lowercased form is in +words+, emit an
|
|
26
|
+
# error with +rule.suggestion+ as the replacement.
|
|
27
|
+
#
|
|
28
|
+
# Examples:
|
|
29
|
+
# "its cold" → suggest "it's" (cold is ADJ)
|
|
30
|
+
# "your ready" → suggest "you're"
|
|
31
|
+
# "whose coming" → suggest "who's"
|
|
32
|
+
class PossessiveContractionMatcher < BaseMatcher
|
|
33
|
+
def match(tokens, rule)
|
|
34
|
+
errors = []
|
|
35
|
+
target = pattern_target_token
|
|
36
|
+
triggers = pattern_triggers
|
|
37
|
+
|
|
38
|
+
tokens.each_with_index do |token, idx|
|
|
39
|
+
next unless matches_target?(token, target)
|
|
40
|
+
|
|
41
|
+
next_token = tokens[idx + 1]
|
|
42
|
+
next unless next_token
|
|
43
|
+
next unless triggers_match?(next_token, triggers)
|
|
44
|
+
|
|
45
|
+
errors << build_error(token, next_token, rule)
|
|
46
|
+
end
|
|
47
|
+
errors
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
def pattern_target_token
|
|
53
|
+
@pattern.dig('context', 'target_token')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pattern_triggers
|
|
57
|
+
@pattern.dig('context', 'trigger_when_followed_by') || {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def matches_target?(token, target)
|
|
61
|
+
target && token[:token]&.downcase == target.downcase
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def triggers_match?(next_token, triggers)
|
|
65
|
+
trigger_tags = triggers['tags'] || []
|
|
66
|
+
trigger_words = (triggers['words'] || []).map(&:downcase)
|
|
67
|
+
|
|
68
|
+
tag = next_token[:pos_tag]
|
|
69
|
+
word = next_token[:token]&.downcase
|
|
70
|
+
|
|
71
|
+
trigger_tags.include?(tag) || trigger_words.include?(word)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_error(token, next_token, rule)
|
|
75
|
+
{
|
|
76
|
+
rule_id: rule.id,
|
|
77
|
+
position: token[:position],
|
|
78
|
+
message: rule.message,
|
|
79
|
+
suggestion: rule.suggestion,
|
|
80
|
+
context: "\"#{token[:token]} #{next_token[:token]}\"",
|
|
81
|
+
suggestions: [rule.suggestion]
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kotoshu
|
|
4
|
+
module Grammar
|
|
5
|
+
module PatternMatchers
|
|
6
|
+
# Matcher for sentence-start capitalization rules.
|
|
7
|
+
#
|
|
8
|
+
# English (and most Latin-script languages) capitalizes the
|
|
9
|
+
# first word of every sentence. This matcher walks the token
|
|
10
|
+
# stream tracking sentence boundaries and flags lowercase
|
|
11
|
+
# sentence starts.
|
|
12
|
+
#
|
|
13
|
+
# A sentence starts:
|
|
14
|
+
# - at the beginning of the stream, OR
|
|
15
|
+
# - immediately after a token whose final character is a
|
|
16
|
+
# sentence-ending punctuation mark (. ! ?).
|
|
17
|
+
#
|
|
18
|
+
# The matcher only considers alphabetic tokens. Numbers,
|
|
19
|
+
# symbols, and whitespace don't reset the sentence-start flag.
|
|
20
|
+
#
|
|
21
|
+
# Rule shape:
|
|
22
|
+
#
|
|
23
|
+
# patterns:
|
|
24
|
+
# - conditions:
|
|
25
|
+
# - type: sentence_start_check
|
|
26
|
+
#
|
|
27
|
+
class SentenceStartMatcher < BaseMatcher
|
|
28
|
+
SENTENCE_ENDERS = %w[. ! ?].freeze
|
|
29
|
+
|
|
30
|
+
def match(tokens, rule)
|
|
31
|
+
errors = []
|
|
32
|
+
expecting_capital = true
|
|
33
|
+
|
|
34
|
+
tokens.each do |token|
|
|
35
|
+
word = token[:token]
|
|
36
|
+
next if word.nil? || word.empty?
|
|
37
|
+
|
|
38
|
+
if sentence_start_word?(word, expecting_capital)
|
|
39
|
+
errors << build_error(token, rule)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Any non-empty token resets the sentence-start expectation.
|
|
43
|
+
# Only sentence-ending punctuation re-arms it (below).
|
|
44
|
+
expecting_capital = false
|
|
45
|
+
expecting_capital = true if ends_sentence?(word)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
errors
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
def sentence_start_word?(word, expecting_capital)
|
|
54
|
+
expecting_capital && alphabetic_word?(word) && first_letter_lowercase?(word)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def alphabetic_word?(word)
|
|
58
|
+
!word.nil? && !word.empty? && word[0].match?(/[[:alpha:]]/)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def first_letter_lowercase?(word)
|
|
62
|
+
first = word[0]
|
|
63
|
+
first == first.downcase && first != first.upcase
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ends_sentence?(word)
|
|
67
|
+
SENTENCE_ENDERS.include?(word[-1])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_error(token, rule)
|
|
71
|
+
capitalized = token[:token].sub(/\A(\w)/) { $1.upcase }
|
|
72
|
+
{
|
|
73
|
+
rule_id: rule.id,
|
|
74
|
+
position: token[:position],
|
|
75
|
+
message: rule.message,
|
|
76
|
+
suggestion: capitalized,
|
|
77
|
+
context: "\"#{token[:token]}\"",
|
|
78
|
+
suggestions: [capitalized]
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kotoshu
|
|
4
|
+
module Grammar
|
|
5
|
+
module PatternMatchers
|
|
6
|
+
# Matcher for word-list corrections.
|
|
7
|
+
#
|
|
8
|
+
# Flags any token whose lowercase form is a key in the rule's
|
|
9
|
+
# +corrections+ map. Useful for proper-noun capitalization
|
|
10
|
+
# (monday → Monday, english → English) where a single rule
|
|
11
|
+
# covers dozens of word pairs.
|
|
12
|
+
#
|
|
13
|
+
# Rule shape:
|
|
14
|
+
#
|
|
15
|
+
# patterns:
|
|
16
|
+
# - conditions:
|
|
17
|
+
# - type: word_list_check
|
|
18
|
+
# corrections:
|
|
19
|
+
# monday: Monday
|
|
20
|
+
# tuesday: Tuesday
|
|
21
|
+
# english: English
|
|
22
|
+
#
|
|
23
|
+
# The matcher only fires when the actual token text differs from
|
|
24
|
+
# the mapped correction (so "Monday" doesn't get flagged).
|
|
25
|
+
#
|
|
26
|
+
class WordListMatcher < BaseMatcher
|
|
27
|
+
def match(tokens, rule)
|
|
28
|
+
corrections = word_list_condition&.dig('corrections') || {}
|
|
29
|
+
return [] if corrections.empty?
|
|
30
|
+
|
|
31
|
+
errors = []
|
|
32
|
+
tokens.each do |token|
|
|
33
|
+
word = token[:token]
|
|
34
|
+
next unless word
|
|
35
|
+
|
|
36
|
+
correction = corrections[word.downcase]
|
|
37
|
+
next unless correction
|
|
38
|
+
next if word == correction # already correct
|
|
39
|
+
|
|
40
|
+
errors << build_error(token, correction, rule)
|
|
41
|
+
end
|
|
42
|
+
errors
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def word_list_condition
|
|
48
|
+
@word_list_condition ||= @pattern['conditions']&.find do |c|
|
|
49
|
+
c['type'] == 'word_list_check'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_error(token, correction, rule)
|
|
54
|
+
{
|
|
55
|
+
rule_id: rule.id,
|
|
56
|
+
position: token[:position],
|
|
57
|
+
message: rule.message,
|
|
58
|
+
suggestion: correction,
|
|
59
|
+
context: "\"#{token[:token]}\"",
|
|
60
|
+
suggestions: [correction]
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/kotoshu/grammar/rule.rb
CHANGED
|
@@ -80,10 +80,16 @@ module Kotoshu
|
|
|
80
80
|
PatternMatchers::VowelSoundMatcher.new(pattern, exceptions)
|
|
81
81
|
elsif condition_types.include?('context_check')
|
|
82
82
|
PatternMatchers::PossessiveContextMatcher.new(pattern, exceptions)
|
|
83
|
+
elsif condition_types.include?('possessive_contraction_check')
|
|
84
|
+
PatternMatchers::PossessiveContractionMatcher.new(pattern, exceptions)
|
|
83
85
|
elsif condition_types.include?('distance_check')
|
|
84
86
|
PatternMatchers::DoubleNegativeMatcher.new(pattern, exceptions)
|
|
85
87
|
elsif condition_types.include?('phrase_check')
|
|
86
88
|
PatternMatchers::PhraseMatcher.new(pattern, exceptions)
|
|
89
|
+
elsif condition_types.include?('sentence_start_check')
|
|
90
|
+
PatternMatchers::SentenceStartMatcher.new(pattern, exceptions)
|
|
91
|
+
elsif condition_types.include?('word_list_check')
|
|
92
|
+
PatternMatchers::WordListMatcher.new(pattern, exceptions)
|
|
87
93
|
else
|
|
88
94
|
PatternMatchers::BaseMatcher.new(pattern)
|
|
89
95
|
end
|
data/lib/kotoshu/grammar.rb
CHANGED
|
@@ -14,8 +14,11 @@ module Kotoshu
|
|
|
14
14
|
autoload :BaseMatcher, "kotoshu/grammar/pattern_matchers/base_matcher"
|
|
15
15
|
autoload :VowelSoundMatcher, "kotoshu/grammar/pattern_matchers/vowel_sound_matcher"
|
|
16
16
|
autoload :PossessiveContextMatcher, "kotoshu/grammar/pattern_matchers/possessive_context_matcher"
|
|
17
|
+
autoload :PossessiveContractionMatcher, "kotoshu/grammar/pattern_matchers/possessive_contraction_matcher"
|
|
17
18
|
autoload :DoubleNegativeMatcher, "kotoshu/grammar/pattern_matchers/double_negative_matcher"
|
|
18
19
|
autoload :PhraseMatcher, "kotoshu/grammar/pattern_matchers/phrase_matcher"
|
|
20
|
+
autoload :SentenceStartMatcher, "kotoshu/grammar/pattern_matchers/sentence_start_matcher"
|
|
21
|
+
autoload :WordListMatcher, "kotoshu/grammar/pattern_matchers/word_list_matcher"
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
end
|
|
@@ -61,21 +61,19 @@ module Kotoshu
|
|
|
61
61
|
@strategies.select { |s| s.handles?(context) }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# Generate suggestions by delegating to all
|
|
64
|
+
# Generate suggestions by delegating to all applicable strategies.
|
|
65
|
+
#
|
|
66
|
+
# Candidates are collected from every strategy first, then handed
|
|
67
|
+
# to a single SuggestionSet so dedup and ranking run once over the
|
|
68
|
+
# full pool (not as a side effect of each merge). TODO 56 T5.1.
|
|
65
69
|
#
|
|
66
70
|
# @param context [Context] The suggestion context
|
|
67
71
|
# @return [SuggestionSet] Combined suggestions from all strategies
|
|
68
72
|
def generate(context)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# Process each applicable strategy
|
|
73
|
-
applicable_strategies(context).each do |strategy|
|
|
74
|
-
strategy_result = strategy.generate(context)
|
|
75
|
-
result.merge!(strategy_result)
|
|
73
|
+
candidates = applicable_strategies(context).flat_map do |strategy|
|
|
74
|
+
strategy.generate(context).suggestions
|
|
76
75
|
end
|
|
77
|
-
|
|
78
|
-
result
|
|
76
|
+
SuggestionSet.new(candidates, max_size: context.max_results)
|
|
79
77
|
end
|
|
80
78
|
|
|
81
79
|
# Check if any strategy can handle the context.
|
|
@@ -267,52 +267,13 @@ module Kotoshu
|
|
|
267
267
|
end
|
|
268
268
|
|
|
269
269
|
# Calculate Damerau-Levenshtein edit distance between two strings.
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
# Examples:
|
|
273
|
-
# "wrold" → "world" = 1 (transposition of 'r' and 'o')
|
|
274
|
-
# "hello" → "hell" = 1 (deletion)
|
|
275
|
-
# "cat" → "cut" = 1 (substitution)
|
|
270
|
+
# Delegates to Algorithms::EditDistance.
|
|
276
271
|
#
|
|
277
272
|
# @param str1 [String] First string
|
|
278
273
|
# @param str2 [String] Second string
|
|
279
274
|
# @return [Integer] Edit distance
|
|
280
275
|
def edit_distance(str1, str2)
|
|
281
|
-
|
|
282
|
-
return str2.length if str1.empty?
|
|
283
|
-
return str1.length if str2.empty?
|
|
284
|
-
|
|
285
|
-
len1 = str1.length
|
|
286
|
-
len2 = str2.length
|
|
287
|
-
|
|
288
|
-
# Create a 2D array for dynamic programming
|
|
289
|
-
d = Array.new(len1 + 1) { Array.new(len2 + 1, 0) }
|
|
290
|
-
|
|
291
|
-
# Initialize the first row and column
|
|
292
|
-
(0..len1).each { |i| d[i][0] = i }
|
|
293
|
-
(0..len2).each { |j| d[0][j] = j }
|
|
294
|
-
|
|
295
|
-
# Fill the matrix
|
|
296
|
-
(1..len1).each do |i|
|
|
297
|
-
(1..len2).each do |j|
|
|
298
|
-
cost = str1[i - 1] == str2[j - 1] ? 0 : 1
|
|
299
|
-
|
|
300
|
-
d[i][j] = [
|
|
301
|
-
d[i - 1][j] + 1, # deletion
|
|
302
|
-
d[i][j - 1] + 1, # insertion
|
|
303
|
-
d[i - 1][j - 1] + cost # substitution
|
|
304
|
-
].min
|
|
305
|
-
|
|
306
|
-
# Check for transposition (Damerau extension)
|
|
307
|
-
if i > 1 && j > 1 &&
|
|
308
|
-
str1[i - 1] == str2[j - 2] &&
|
|
309
|
-
str1[i - 2] == str2[j - 1]
|
|
310
|
-
d[i][j] = [d[i][j], d[i - 2][j - 2] + 1].min
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
d[len1][len2]
|
|
276
|
+
Algorithms::EditDistance.distance(str1, str2)
|
|
316
277
|
end
|
|
317
278
|
|
|
318
279
|
# Optimized edit distance with early termination.
|
|
@@ -323,12 +284,11 @@ module Kotoshu
|
|
|
323
284
|
# @param threshold [Integer] Maximum distance to calculate
|
|
324
285
|
# @return [Integer, nil] Distance or nil if exceeds threshold
|
|
325
286
|
def edit_distance_with_threshold(str1, str2, threshold)
|
|
326
|
-
|
|
327
|
-
# This can be optimized later with early termination
|
|
328
|
-
dist = edit_distance(str1, str2)
|
|
329
|
-
dist <= threshold ? dist : nil
|
|
287
|
+
Algorithms::EditDistance.distance_with_threshold(str1, str2, threshold)
|
|
330
288
|
end
|
|
331
289
|
|
|
290
|
+
public
|
|
291
|
+
|
|
332
292
|
# Calculate enhanced score combining multiple factors.
|
|
333
293
|
#
|
|
334
294
|
# Lower score = better suggestion
|
|
@@ -479,6 +439,8 @@ module Kotoshu
|
|
|
479
439
|
bonus
|
|
480
440
|
end
|
|
481
441
|
|
|
442
|
+
private
|
|
443
|
+
|
|
482
444
|
# Resolve keyboard layout using OOP registry pattern
|
|
483
445
|
#
|
|
484
446
|
# @param keyboard_layout [Keyboard::Layout, String, nil] Layout override
|
|
@@ -7,13 +7,16 @@ module Kotoshu
|
|
|
7
7
|
class SuggestionSet
|
|
8
8
|
include Enumerable
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# @return [Integer] Number of duplicate suggestions removed by the
|
|
11
|
+
# last call to {#deduplicate!}. Zero until deduplicate! runs.
|
|
12
|
+
attr_reader :suggestions, :max_size, :duplicates_removed
|
|
11
13
|
|
|
12
14
|
# @param suggestions [Array<Suggestion>] Initial suggestions
|
|
13
15
|
# @param max_size [Integer] Maximum number of suggestions to keep
|
|
14
16
|
def initialize(suggestions = [], max_size: 10)
|
|
15
17
|
@suggestions = suggestions
|
|
16
18
|
@max_size = max_size
|
|
19
|
+
@duplicates_removed = 0
|
|
17
20
|
sort_and_limit!
|
|
18
21
|
end
|
|
19
22
|
|
|
@@ -227,13 +230,34 @@ module Kotoshu
|
|
|
227
230
|
|
|
228
231
|
private
|
|
229
232
|
|
|
230
|
-
# Sort suggestions by combined score
|
|
231
|
-
|
|
232
|
-
def sort_and_limit!
|
|
233
|
+
# Sort suggestions by combined score.
|
|
234
|
+
def sort!
|
|
233
235
|
@suggestions.sort!
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Remove duplicate suggestions (same word, case-insensitive).
|
|
239
|
+
# Called explicitly so the dedup is visible as a named step,
|
|
240
|
+
# not buried inside sort_and_limit as a side effect (TODO 56 T5.1).
|
|
241
|
+
# Tracks the count of removed duplicates for diagnostics.
|
|
242
|
+
def deduplicate!
|
|
243
|
+
before = @suggestions.length
|
|
234
244
|
@suggestions.uniq! { |s| s.word.downcase }
|
|
245
|
+
@duplicates_removed = before - @suggestions.length
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Truncate to max_size.
|
|
249
|
+
def limit!
|
|
235
250
|
@suggestions = @suggestions.first(@max_size)
|
|
236
251
|
end
|
|
252
|
+
|
|
253
|
+
# Sort, deduplicate, then limit. The canonical pipeline called
|
|
254
|
+
# after every mutation (add, concat, merge!). Each step is a
|
|
255
|
+
# named method so callers can reason about the order.
|
|
256
|
+
def sort_and_limit!
|
|
257
|
+
sort!
|
|
258
|
+
deduplicate!
|
|
259
|
+
limit!
|
|
260
|
+
end
|
|
237
261
|
end
|
|
238
262
|
end
|
|
239
263
|
end
|
data/lib/kotoshu/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kotoshu
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|
|
@@ -87,6 +87,7 @@ files:
|
|
|
87
87
|
- lib/kotoshu.rb
|
|
88
88
|
- lib/kotoshu/algorithms.rb
|
|
89
89
|
- lib/kotoshu/algorithms/capitalization.rb
|
|
90
|
+
- lib/kotoshu/algorithms/edit_distance.rb
|
|
90
91
|
- lib/kotoshu/algorithms/lookup.rb
|
|
91
92
|
- lib/kotoshu/algorithms/ngram_suggest.rb
|
|
92
93
|
- lib/kotoshu/algorithms/permutations.rb
|
|
@@ -187,7 +188,10 @@ files:
|
|
|
187
188
|
- lib/kotoshu/grammar/pattern_matchers/double_negative_matcher.rb
|
|
188
189
|
- lib/kotoshu/grammar/pattern_matchers/phrase_matcher.rb
|
|
189
190
|
- lib/kotoshu/grammar/pattern_matchers/possessive_context_matcher.rb
|
|
191
|
+
- lib/kotoshu/grammar/pattern_matchers/possessive_contraction_matcher.rb
|
|
192
|
+
- lib/kotoshu/grammar/pattern_matchers/sentence_start_matcher.rb
|
|
190
193
|
- lib/kotoshu/grammar/pattern_matchers/vowel_sound_matcher.rb
|
|
194
|
+
- lib/kotoshu/grammar/pattern_matchers/word_list_matcher.rb
|
|
191
195
|
- lib/kotoshu/grammar/rule.rb
|
|
192
196
|
- lib/kotoshu/grammar/rule_engine.rb
|
|
193
197
|
- lib/kotoshu/grammar/rule_loader.rb
|