diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java index 96abc83f8a..977489f40a 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java @@ -108,11 +108,11 @@ public final class LanguageSettingsActivity extends AppCompatActivity { RadioGroup radioGroup = (RadioGroup) findViewById(R.id.suggestion_radio_group); radioGroup.clearCheck(); - // Auto-correct disabled for Keyman 18.0 #12767 int[] RadioButtonArray = { R.id.suggestion_radio_0, R.id.suggestion_radio_1, - R.id.suggestion_radio_2}; + R.id.suggestion_radio_2, + R.id.suggestion_radio_3}; RadioButton radioButton = (RadioButton)radioGroup.findViewById(RadioButtonArray[maySuggest]); radioButton.setChecked(true); diff --git a/android/KMEA/app/src/main/res/layout/language_settings_list_layout.xml b/android/KMEA/app/src/main/res/layout/language_settings_list_layout.xml index 42ae9b1511..6399987b04 100644 --- a/android/KMEA/app/src/main/res/layout/language_settings_list_layout.xml +++ b/android/KMEA/app/src/main/res/layout/language_settings_list_layout.xml @@ -90,13 +90,12 @@ android:layout_gravity="center_vertical" android:text="@string/suggestions_radio_2" /> - - + android:text="@string/suggestions_radio_3" /> diff --git a/web/README.md b/web/README.md index 7f7415c698..05f9432c20 100644 --- a/web/README.md +++ b/web/README.md @@ -1,5 +1,5 @@ # Keyman Engine for Web -The Original Code is (C) SIL International +The Original Code is (C) SIL Global ## Prerequisites See [build configuration](../docs/build/index.md) for details on how to diff --git a/web/src/engine/main/src/headless/languageProcessor.ts b/web/src/engine/main/src/headless/languageProcessor.ts index 3e8eb54143..34dcdf696a 100644 --- a/web/src/engine/main/src/headless/languageProcessor.ts +++ b/web/src/engine/main/src/headless/languageProcessor.ts @@ -21,7 +21,7 @@ export class LanguageProcessor extends EventEmitter { private _mayPredict: boolean = true; private _mayCorrect: boolean = true; - private _mayAutoCorrect: boolean = false; // initialized to false - #12767 + private _mayAutoCorrect: boolean = true; private _state: StateChangeEnum = 'inactive'; diff --git a/web/src/engine/predictive-text/wordbreakers/src/main/default/index.ts b/web/src/engine/predictive-text/wordbreakers/src/main/default/index.ts index 390ba9806a..580fe39a30 100644 --- a/web/src/engine/predictive-text/wordbreakers/src/main/default/index.ts +++ b/web/src/engine/predictive-text/wordbreakers/src/main/default/index.ts @@ -38,7 +38,7 @@ export interface DefaultWordBreakerOptions { * @see http://unicode.org/reports/tr29/#Word_Boundaries * @see https://github.com/eddieantonio/unicode-default-word-boundary/tree/v12.0.0 */ -export default function default_(text: string, options?: DefaultWordBreakerOptions): LexicalModelTypes.Span[] { + function default_(text: string, options?: DefaultWordBreakerOptions): LexicalModelTypes.Span[] { let boundaries = findBoundaries(text, options); if (boundaries.length == 0) { return []; @@ -64,6 +64,16 @@ export default function default_(text: string, options?: DefaultWordBreakerOptio return spans; } +// Exposes `searchForProperty` for external use while associating it with this wordbreaker. +const def = Object.assign(default_, { + /** + * This method returns enum values corresponding to the character type as perceived by the wordbreaking algorithm. + */ + searchForProperty: searchForProperty +}); + +export default def; + /** * A span that does not cut out the substring until it absolutely has to! */ diff --git a/web/src/engine/predictive-text/wordbreakers/src/main/index.ts b/web/src/engine/predictive-text/wordbreakers/src/main/index.ts index 7f1682fca2..92976d91fd 100644 --- a/web/src/engine/predictive-text/wordbreakers/src/main/index.ts +++ b/web/src/engine/predictive-text/wordbreakers/src/main/index.ts @@ -1,5 +1,6 @@ import placeholder from "./placeholder.js"; import ascii from "./ascii.js"; import default_ from "./default/index.js"; +import { WordBreakProperty } from "./default/data.inc.js"; -export { placeholder, ascii, default_ as default, default_ as defaultWordbreaker }; \ No newline at end of file +export { placeholder, ascii, default_ as default, default_ as defaultWordbreaker, WordBreakProperty }; \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index ab07355d01..ef943eb923 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -6,6 +6,9 @@ import { ContextTracker, TrackedContextState } from './correction/context-tracke import { ExecutionTimer } from './correction/execution-timer.js'; import ModelCompositor from './model-compositor.js'; import { LexicalModelTypes } from '@keymanapp/common-types'; +import { defaultWordbreaker, WordBreakProperty } from '@keymanapp/models-wordbreakers'; +const searchForProperty = defaultWordbreaker.searchForProperty; + import Context = LexicalModelTypes.Context; import Distribution = LexicalModelTypes.Distribution; import Keep = LexicalModelTypes.Keep; @@ -615,6 +618,35 @@ export function processSimilarity( }); } +/** + * This function may be used to prevent auto-selection/auto-correct from applying in + * unexpected ways. For example, when typing numbers in English, we don't expect + * '5' to auto-correct to '5th' just because there are no pure-number entries in + * the lexicon rooted on '5'. + * @param correction + * @returns + */ +export function correctionValidForAutoSelect(correction: string) { + let chars = [...correction]; + + // If the _correction_ - the actual, existing text - does not include any letters, + // then predictions built upon it should not be considered valid for auto-correction. + for(let c of chars) { + // Found even one letter? We'll consider it valid. + switch(searchForProperty(c.codePointAt(0))) { + case WordBreakProperty.ALetter: + case WordBreakProperty.Hebrew_Letter: + case WordBreakProperty.Katakana: + return true; + default: + } + } + + // Only reached when the correction has nothing that passes as a letter in-context. + // (MidLet and MidNumLet only count when there are adjacent letters.) + return false; +} + export function predictionAutoSelect(suggestionDistribution: CorrectionPredictionTuple[]) { if(suggestionDistribution.length == 0) { return; @@ -633,6 +665,11 @@ export function predictionAutoSelect(suggestionDistribution: CorrectionPredictio suggestionDistribution = suggestionDistribution.slice(1); if(suggestionDistribution.length == 1) { + // Prevent auto-acceptance when the root doesn't meet validation criteria. + if(!correctionValidForAutoSelect(suggestionDistribution[0].correction.sample)) { + return; + } + // Mark for auto-acceptance; there are no alternatives. suggestionDistribution[0].prediction.sample.autoAccept = true; return; @@ -676,6 +713,10 @@ export function predictionAutoSelect(suggestionDistribution: CorrectionPredictio return; } + if(!correctionValidForAutoSelect(bestSuggestion.correction.sample)) { + return; + } + // compare correction-cost aspects? We disable if the base correction is lower than best, // but should we do other comparisons too? diff --git a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/auto-correct.js b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/auto-correct.js index d1e4d377a1..84bb8b412f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/auto-correct.js +++ b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/auto-correct.js @@ -26,7 +26,7 @@ describe('predictionAutoSelect', () => { const predictions = [ { correction: { - sample: 'apple', // can be null / "mocked out" + sample: 'apple', p: 1 }, prediction: { @@ -52,6 +52,56 @@ describe('predictionAutoSelect', () => { assert.isOk(autoselected); }); + it(`does not select suggestions if the root correction has no letters`, () => { + /** + * @type {import('#./predict-helpers.js').CorrectionPredictionTuple[]} + */ + const predictions = [ + { + correction: { + sample: '5', + p: 1 + }, + prediction: { + sample: { + tag: 'keep', + transform: { + insert: '5', + deleteLeft: 0 + }, + matchesModel: false + }, + p: 0.01 + }, + totalProb: 0.01 + }, + { + correction: { + sample: '5', + p: 1 + }, + prediction: { + sample: { + transform: { + insert: '5th', + deleteLeft: 0 + }, + matchesModel: true + }, + p: 0.8 + }, + totalProb: 0.8 + } + ]; + + const originalPredictions = [].concat(predictions); + assert.doesNotThrow(() => predictionAutoSelect(predictions)); + assert.sameDeepOrderedMembers(predictions, originalPredictions); + + const autoselected = predictions.find((entry) => entry.prediction.sample.autoAccept); + assert.isNotOk(autoselected); + }); + it(`does not select solitary 'keep' suggestion that doesn't match the model`, () => { /** * @type {import('#./predict-helpers.js').CorrectionPredictionTuple[]} @@ -59,7 +109,7 @@ describe('predictionAutoSelect', () => { const predictions = [ { correction: { - sample: 'appl', // can be null / "mocked out" + sample: 'appl', p: 1 }, prediction: { @@ -91,7 +141,7 @@ describe('predictionAutoSelect', () => { */ const keepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -110,7 +160,7 @@ describe('predictionAutoSelect', () => { const highestNonKeepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -133,7 +183,7 @@ describe('predictionAutoSelect', () => { highestNonKeepSuggestion, { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -149,7 +199,7 @@ describe('predictionAutoSelect', () => { }, { correction: { - sample: 'thic', // can be null / "mocked out" + sample: 'thic', p: .2 }, prediction: { @@ -181,7 +231,7 @@ describe('predictionAutoSelect', () => { */ const keepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -204,7 +254,7 @@ describe('predictionAutoSelect', () => { // Refer to AUTOSELECT_PROPORTION_THRESHOLD in predict-helpers.ts. const onlyNonKeepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -246,7 +296,7 @@ describe('predictionAutoSelect', () => { */ const keepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -269,7 +319,7 @@ describe('predictionAutoSelect', () => { // Refer to AUTOSELECT_PROPORTION_THRESHOLD in predict-helpers.ts. const highestNonKeepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -292,7 +342,7 @@ describe('predictionAutoSelect', () => { highestNonKeepSuggestion, { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -308,7 +358,7 @@ describe('predictionAutoSelect', () => { }, { correction: { - sample: 'thic', // can be null / "mocked out" + sample: 'thic', p: .2 }, prediction: { @@ -343,7 +393,7 @@ describe('predictionAutoSelect', () => { */ const keepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .8 }, prediction: { @@ -362,7 +412,7 @@ describe('predictionAutoSelect', () => { const highestNonKeepSuggestion = { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .9 }, prediction: { @@ -385,7 +435,7 @@ describe('predictionAutoSelect', () => { highestNonKeepSuggestion, { correction: { - sample: 'thin', // can be null / "mocked out" + sample: 'thin', p: .9 }, prediction: { @@ -401,7 +451,7 @@ describe('predictionAutoSelect', () => { }, { correction: { - sample: 'thic', // can be null / "mocked out" + sample: 'thic', p: .1 }, prediction: { @@ -436,7 +486,7 @@ describe('predictionAutoSelect', () => { */ const keepSuggestion = { correction: { - sample: 'cant', // can be null / "mocked out" + sample: 'cant', p: 1 }, prediction: { @@ -456,7 +506,7 @@ describe('predictionAutoSelect', () => { const expectedSuggestion = { correction: { - sample: 'cant', // can be null / "mocked out" + sample: 'cant', p: 1 }, prediction: { @@ -480,7 +530,7 @@ describe('predictionAutoSelect', () => { expectedSuggestion, { correction: { - sample: 'cant', // can be null / "mocked out" + sample: 'cant', p: 1 }, prediction: { @@ -515,7 +565,7 @@ describe('predictionAutoSelect', () => { */ const keepSuggestion = { correction: { - sample: 'thi', // can be null / "mocked out" + sample: 'thi', p: .7 }, prediction: { @@ -534,7 +584,7 @@ describe('predictionAutoSelect', () => { const highestCorrectionSuggestion = { correction: { - sample: 'thi', // can be null / "mocked out" + sample: 'thi', p: .7 }, prediction: { @@ -551,7 +601,7 @@ describe('predictionAutoSelect', () => { const highestNonKeepSuggestion = { correction: { - sample: 'the', // can be null / "mocked out" + sample: 'the', p: .3 }, prediction: { diff --git a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js index 6ec7a0bbd9..cf8473c65c 100644 --- a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js +++ b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js @@ -159,21 +159,14 @@ describe("PredictionContext", () => { assert.equal(updateFake.callCount, 3); suggestions = updateFake.thirdCall.args[0]; - // Note: this unit test was originally written with auto-correct on! - // #11941 was written 2024-07-25 (added unit test for auto-correction method) - // #12169 was written 2024-08-14, which is what added THIS unit test. - // This does re-use the apply-revert oriented mocking. // Should skip the (second) "apple", "apply", "apps" round, as it became outdated // by its following request before its response could be received. - assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['applied']); // '“apple”' included with auto-correct enabled. - // Is not displayed; we only display it if auto-correct is on, as 'applied' would be automatic then. - assert.equal(predictiveContext.keepSuggestion.displayAs, '“apple”'); - // assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, '“apple”'); // with auto-correct enabled. - assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'applied'); + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['“apple”', 'applied']); + assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, '“apple”'); // Our reused mocking doesn't directly provide the 'keep' suggestion; we // need to remove it before testing for set equality. - assert.deepEqual(suggestions /*.splice(1)*/, expected); + assert.deepEqual(suggestions.splice(1), expected); }); it('sendUpdateState retrieves the most recent suggestion set', async function() {