Merge branch 'epic/autocorrect' into chore/merge-master-into-autocorrect

This commit is contained in:
Marc Durdin 2025-03-31 12:13:34 +07:00 committed by GitHub
commit 0db2d7b7d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 135 additions and 41 deletions

View file

@ -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);

View file

@ -90,13 +90,12 @@
android:layout_gravity="center_vertical"
android:text="@string/suggestions_radio_2" />
<!-- Auto-correct disabled for Keyman 18.0 #12767 -->
<!--com.google.android.material.radiobutton.MaterialRadioButton
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/suggestion_radio_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:text="@string/suggestions_radio_3" /-->
android:text="@string/suggestions_radio_3" />
</RadioGroup>

View file

@ -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

View file

@ -21,7 +21,7 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
private _mayPredict: boolean = true;
private _mayCorrect: boolean = true;
private _mayAutoCorrect: boolean = false; // initialized to false - #12767
private _mayAutoCorrect: boolean = true;
private _state: StateChangeEnum = 'inactive';

View file

@ -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!
*/

View file

@ -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 };
export { placeholder, ascii, default_ as default, default_ as defaultWordbreaker, WordBreakProperty };

View file

@ -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?

View file

@ -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: {

View file

@ -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() {