@willwade/aac-processors 0.2.16 → 0.2.18
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.
- package/dist/browser/processors/gridset/commands.js +56 -1
- package/dist/browser/processors/gridsetProcessor.js +16 -2
- package/dist/browser/processors/snapProcessor.js +42 -4
- package/dist/browser/utilities/analytics/metrics/core.js +182 -33
- package/dist/browser/utilities/analytics/metrics/effort.js +1 -0
- package/dist/browser/utilities/analytics/morphology/engine.js +24 -2
- package/dist/browser/utilities/analytics/morphology/index.js +1 -0
- package/dist/browser/utilities/analytics/morphology/tdsnapLexiconParser.js +182 -0
- package/dist/core/treeStructure.d.ts +3 -2
- package/dist/index.node.d.ts +1 -0
- package/dist/index.node.js +4 -2
- package/dist/processors/gridset/commands.js +56 -1
- package/dist/processors/gridsetProcessor.js +16 -2
- package/dist/processors/snapProcessor.js +42 -4
- package/dist/types/aac.d.ts +1 -1
- package/dist/utilities/analytics/metrics/core.d.ts +33 -0
- package/dist/utilities/analytics/metrics/core.js +182 -33
- package/dist/utilities/analytics/metrics/effort.d.ts +1 -0
- package/dist/utilities/analytics/metrics/effort.js +1 -0
- package/dist/utilities/analytics/metrics/types.d.ts +26 -0
- package/dist/utilities/analytics/morphology/engine.d.ts +4 -0
- package/dist/utilities/analytics/morphology/engine.js +24 -2
- package/dist/utilities/analytics/morphology/index.d.ts +2 -0
- package/dist/utilities/analytics/morphology/index.js +3 -1
- package/dist/utilities/analytics/morphology/tdsnapLexiconParser.d.ts +28 -0
- package/dist/utilities/analytics/morphology/tdsnapLexiconParser.js +186 -0
- package/package.json +5 -5
|
@@ -909,6 +909,61 @@ export function getAllPluginIds() {
|
|
|
909
909
|
const plugins = new Set(Object.values(GRID3_COMMANDS).map((cmd) => cmd.pluginId));
|
|
910
910
|
return Array.from(plugins).sort();
|
|
911
911
|
}
|
|
912
|
+
function textOfStructured(val) {
|
|
913
|
+
if (!val || typeof val !== 'object')
|
|
914
|
+
return undefined;
|
|
915
|
+
const parts = [];
|
|
916
|
+
const processS = (s) => {
|
|
917
|
+
if (!s)
|
|
918
|
+
return;
|
|
919
|
+
if (s.r !== undefined) {
|
|
920
|
+
const rElements = Array.isArray(s.r) ? s.r : [s.r];
|
|
921
|
+
for (const r of rElements) {
|
|
922
|
+
if (typeof r === 'number') {
|
|
923
|
+
if (r !== 0)
|
|
924
|
+
parts.push(String(r));
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (typeof r === 'object' && r !== null) {
|
|
928
|
+
if ('#text' in r)
|
|
929
|
+
parts.push(String(r['#text']));
|
|
930
|
+
else if ('#cdata' in r)
|
|
931
|
+
parts.push(String(r['#cdata']));
|
|
932
|
+
else
|
|
933
|
+
parts.push(String(r));
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
parts.push(String(r));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
if (val.p) {
|
|
942
|
+
const sElements = Array.isArray(val.p.s) ? val.p.s : val.p.s ? [val.p.s] : [];
|
|
943
|
+
sElements.forEach(processS);
|
|
944
|
+
}
|
|
945
|
+
else if (val.s) {
|
|
946
|
+
const sElements = Array.isArray(val.s) ? val.s : [val.s];
|
|
947
|
+
sElements.forEach(processS);
|
|
948
|
+
}
|
|
949
|
+
else if (val.r !== undefined) {
|
|
950
|
+
processS(val);
|
|
951
|
+
}
|
|
952
|
+
return parts.length > 0 ? parts.join('').trim() : undefined;
|
|
953
|
+
}
|
|
954
|
+
function extractParamValue(param) {
|
|
955
|
+
if (typeof param === 'string')
|
|
956
|
+
return param;
|
|
957
|
+
if (param.p || param.s || (param.r !== undefined && typeof param.r !== 'string')) {
|
|
958
|
+
const structured = textOfStructured(param);
|
|
959
|
+
if (structured !== undefined)
|
|
960
|
+
return structured;
|
|
961
|
+
}
|
|
962
|
+
const simple = param['#text'] ?? param.text ?? param.value;
|
|
963
|
+
if (simple !== undefined)
|
|
964
|
+
return simple;
|
|
965
|
+
return textOfStructured(param);
|
|
966
|
+
}
|
|
912
967
|
export function extractCommandParameters(command) {
|
|
913
968
|
const parameters = {};
|
|
914
969
|
const params = command.Parameter || command.parameter;
|
|
@@ -917,7 +972,7 @@ export function extractCommandParameters(command) {
|
|
|
917
972
|
const paramArray = Array.isArray(params) ? params : [params];
|
|
918
973
|
for (const param of paramArray) {
|
|
919
974
|
const key = param['@_Key'] || param.Key || param.key;
|
|
920
|
-
let value = param
|
|
975
|
+
let value = extractParamValue(param);
|
|
921
976
|
if (key && value !== undefined) {
|
|
922
977
|
// Try to convert to number if it looks numeric
|
|
923
978
|
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
@@ -700,6 +700,13 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
700
700
|
}
|
|
701
701
|
}
|
|
702
702
|
}
|
|
703
|
+
if (pageWordListItems.length > 0) {
|
|
704
|
+
page.wordListItems = pageWordListItems.map((item) => ({
|
|
705
|
+
text: item.text,
|
|
706
|
+
image: item.image,
|
|
707
|
+
partOfSpeech: item.partOfSpeech,
|
|
708
|
+
}));
|
|
709
|
+
}
|
|
703
710
|
// Track WordList AutoContent cells and their positions for "more" button placement
|
|
704
711
|
const wordListAutoContentCells = [];
|
|
705
712
|
let wordListCellIndex = 0;
|
|
@@ -1032,6 +1039,15 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
1032
1039
|
const param = getRawParam(key);
|
|
1033
1040
|
if (param === undefined)
|
|
1034
1041
|
return undefined;
|
|
1042
|
+
if (typeof param === 'string')
|
|
1043
|
+
return param;
|
|
1044
|
+
if (param.p ||
|
|
1045
|
+
param.s ||
|
|
1046
|
+
(param.r !== undefined && typeof param.r !== 'string')) {
|
|
1047
|
+
const structuredValue = this.textOf(param);
|
|
1048
|
+
if (structuredValue !== undefined)
|
|
1049
|
+
return structuredValue;
|
|
1050
|
+
}
|
|
1035
1051
|
const simpleValue = param['#text'] ?? param.text ?? param.value;
|
|
1036
1052
|
if (typeof simpleValue === 'string')
|
|
1037
1053
|
return simpleValue;
|
|
@@ -1040,8 +1056,6 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
1040
1056
|
const structuredValue = this.textOf(param);
|
|
1041
1057
|
if (structuredValue !== undefined)
|
|
1042
1058
|
return structuredValue;
|
|
1043
|
-
if (typeof param === 'string')
|
|
1044
|
-
return param;
|
|
1045
1059
|
return undefined;
|
|
1046
1060
|
};
|
|
1047
1061
|
// Skip PredictThis in primary action loop as it was handled in pre-pass
|
|
@@ -333,6 +333,9 @@ class SnapProcessor extends BaseProcessor {
|
|
|
333
333
|
buttonColumns.has('BackgroundColor') ? 'b.BackgroundColor' : 'NULL AS BackgroundColor',
|
|
334
334
|
buttonColumns.has('NavigatePageId') ? 'b.NavigatePageId' : 'NULL AS NavigatePageId',
|
|
335
335
|
buttonColumns.has('ContentType') ? 'b.ContentType' : 'NULL AS ContentType',
|
|
336
|
+
buttonColumns.has('SerializedContentTypeHandler')
|
|
337
|
+
? 'b.SerializedContentTypeHandler'
|
|
338
|
+
: 'NULL AS SerializedContentTypeHandler',
|
|
336
339
|
];
|
|
337
340
|
if (this.loadAudio) {
|
|
338
341
|
selectFields.push(buttonColumns.has('MessageRecordingId')
|
|
@@ -548,22 +551,54 @@ class SnapProcessor extends BaseProcessor {
|
|
|
548
551
|
},
|
|
549
552
|
};
|
|
550
553
|
}
|
|
554
|
+
let snapContentType;
|
|
555
|
+
let snapContentSubType;
|
|
556
|
+
let snapGrammarPos;
|
|
557
|
+
let snapGrammarHandler;
|
|
558
|
+
if (btnRow.ContentType === 1) {
|
|
559
|
+
snapContentType = 'AutoContent';
|
|
560
|
+
snapContentSubType = 'Prediction';
|
|
561
|
+
}
|
|
562
|
+
else if (btnRow.ContentType === 3 && btnRow.SerializedContentTypeHandler) {
|
|
563
|
+
snapContentType = 'Inflector';
|
|
564
|
+
snapGrammarHandler = String(btnRow.SerializedContentTypeHandler);
|
|
565
|
+
const colonIdx = snapGrammarHandler.indexOf(':');
|
|
566
|
+
if (colonIdx !== -1) {
|
|
567
|
+
const subtype = snapGrammarHandler.substring(colonIdx + 1).split(',')[0];
|
|
568
|
+
snapContentSubType = subtype;
|
|
569
|
+
const { TDSnapLexiconParser, } = require('../utilities/analytics/morphology/tdsnapLexiconParser');
|
|
570
|
+
snapGrammarPos = TDSnapLexiconParser.tagToPos(subtype);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
551
573
|
const button = new AACButton({
|
|
552
574
|
id: String(btnRow.Id),
|
|
553
575
|
label: btnRow.Label || (btnRow.ContentType === 1 ? '[Prediction]' : ''),
|
|
554
576
|
message: btnRow.Message || (btnRow.ContentType === 1 ? '[Prediction]' : btnRow.Label || ''),
|
|
555
577
|
targetPageId: targetPageUniqueId,
|
|
556
578
|
semanticAction: semanticAction,
|
|
557
|
-
contentType:
|
|
558
|
-
contentSubType:
|
|
579
|
+
contentType: snapContentType,
|
|
580
|
+
contentSubType: snapContentSubType,
|
|
559
581
|
audioRecording: audioRecording,
|
|
560
582
|
visibility: mapSnapVisibility(btnRow.Visible),
|
|
561
583
|
semantic_id: btnRow.LibrarySymbolId
|
|
562
584
|
? `snap_symbol_${btnRow.LibrarySymbolId}`
|
|
563
|
-
: undefined,
|
|
585
|
+
: undefined,
|
|
564
586
|
image: buttonImage,
|
|
565
587
|
resolvedImageEntry: buttonImage,
|
|
566
|
-
parameters:
|
|
588
|
+
parameters: {
|
|
589
|
+
...(Object.keys(buttonParameters).length > 0 ? buttonParameters : {}),
|
|
590
|
+
...(snapGrammarHandler
|
|
591
|
+
? {
|
|
592
|
+
grammar: {
|
|
593
|
+
handler: snapGrammarHandler,
|
|
594
|
+
category: snapGrammarHandler.substring(0, snapGrammarHandler.indexOf(':') !== -1
|
|
595
|
+
? snapGrammarHandler.indexOf(':')
|
|
596
|
+
: snapGrammarHandler.length),
|
|
597
|
+
subtype: snapContentSubType,
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
: {}),
|
|
601
|
+
},
|
|
567
602
|
style: {
|
|
568
603
|
backgroundColor: btnRow.BackgroundColor
|
|
569
604
|
? `#${btnRow.BackgroundColor.toString(16)}`
|
|
@@ -576,6 +611,9 @@ class SnapProcessor extends BaseProcessor {
|
|
|
576
611
|
fontStyle: btnRow.FontStyle?.toString(),
|
|
577
612
|
},
|
|
578
613
|
});
|
|
614
|
+
if (snapGrammarPos) {
|
|
615
|
+
button.pos = snapGrammarPos;
|
|
616
|
+
}
|
|
579
617
|
// Add to the intended parent page
|
|
580
618
|
const parentPage = tree.getPage(parentUniqueId);
|
|
581
619
|
if (parentPage) {
|
|
@@ -585,12 +585,20 @@ export class MetricsCalculator {
|
|
|
585
585
|
/**
|
|
586
586
|
* Quick check whether any button in the tree has a POS tag.
|
|
587
587
|
* Used to auto-enable smart grammar without requiring explicit opt-in.
|
|
588
|
+
*
|
|
589
|
+
* IMPORTANT: Only counts POS from non-Inflector and non-Suffix buttons.
|
|
590
|
+
* TDSnap Inflector buttons and Grid3 Suffix buttons are grammar controls,
|
|
591
|
+
* not content words — they should NOT auto-enable morphology.
|
|
588
592
|
*/
|
|
589
593
|
treeHasPosTags(tree) {
|
|
590
594
|
for (const page of Object.values(tree.pages)) {
|
|
591
595
|
for (const row of page.grid) {
|
|
592
596
|
for (const btn of row) {
|
|
593
|
-
if (btn?.pos &&
|
|
597
|
+
if (btn?.pos &&
|
|
598
|
+
btn.pos !== 'Unknown' &&
|
|
599
|
+
btn.pos !== 'Ignore' &&
|
|
600
|
+
btn.pos !== 'Suffix' &&
|
|
601
|
+
btn.contentType !== 'Inflector') {
|
|
594
602
|
return true;
|
|
595
603
|
}
|
|
596
604
|
}
|
|
@@ -608,8 +616,44 @@ export class MetricsCalculator {
|
|
|
608
616
|
*/
|
|
609
617
|
expandMorphologicalPredictions(tree, options) {
|
|
610
618
|
const locale = options.morphologyLocale || 'en-gb';
|
|
611
|
-
|
|
612
|
-
|
|
619
|
+
let morph;
|
|
620
|
+
if (options.tdsnapLexiconPath) {
|
|
621
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
622
|
+
const { TDSnapLexiconParser } = require('../morphology/tdsnapLexiconParser');
|
|
623
|
+
const parser = new TDSnapLexiconParser();
|
|
624
|
+
const lexiconData = parser.parseDb(options.tdsnapLexiconPath, locale.replace('-', '_'));
|
|
625
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
626
|
+
morph = MorphologyEngine.fromTDSnapLexicon(lexiconData);
|
|
627
|
+
this.expandTDSnapPredictions(tree, morph);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (options.grid3VerbsPath) {
|
|
631
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
632
|
+
const { Grid3VerbsParser } = require('../morphology/grid3VerbsParser');
|
|
633
|
+
const parser = new Grid3VerbsParser();
|
|
634
|
+
const verbForms = parser.parseZip(options.grid3VerbsPath);
|
|
635
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
636
|
+
morph = MorphologyEngine.fromGrid3Verbs(verbForms);
|
|
637
|
+
this.expandGrid3Predictions(tree, morph);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
morph = new MorphologyEngine(locale);
|
|
641
|
+
this.expandGrid3Predictions(tree, morph);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Expand morphological predictions for Grid3 pagesets.
|
|
645
|
+
*
|
|
646
|
+
* Grid3 uses suffix buttons (pos='Suffix') on the same page as content words.
|
|
647
|
+
* Different pages have different suffix buttons — e.g., topic pages may only
|
|
648
|
+
* have -s (plural), while the Magic Wand page has -s, -er, -est, -ly, -y, -'s.
|
|
649
|
+
*
|
|
650
|
+
* Rules:
|
|
651
|
+
* 1. Build a suffix→formSlot map (-s → plural, -er → comparative, etc.)
|
|
652
|
+
* 2. For each page, collect available suffix buttons
|
|
653
|
+
* 3. Only generate forms for slots that have matching suffix buttons on that page
|
|
654
|
+
* 4. POS inference is used for untagged content words (Grid3 grids often lack POS)
|
|
655
|
+
*/
|
|
656
|
+
expandGrid3Predictions(tree, morph) {
|
|
613
657
|
const skipInference = new Set([
|
|
614
658
|
'a',
|
|
615
659
|
'an',
|
|
@@ -692,30 +736,56 @@ export class MetricsCalculator {
|
|
|
692
736
|
'wow',
|
|
693
737
|
'sorry',
|
|
694
738
|
]);
|
|
739
|
+
// Map suffix button labels to the morphology slots they produce
|
|
740
|
+
const SUFFIX_TO_SLOT = {
|
|
741
|
+
'-s': ['plural'],
|
|
742
|
+
"-'s": ['possessive'],
|
|
743
|
+
'-er': ['comparative'],
|
|
744
|
+
'-est': ['superlative'],
|
|
745
|
+
'-ly': ['adverb'],
|
|
746
|
+
'-y': ['adjective'],
|
|
747
|
+
};
|
|
748
|
+
// POS → slots that POS can produce (for filtering)
|
|
749
|
+
const POS_TO_SUFFIX_SLOTS = {
|
|
750
|
+
Noun: new Set(['plural', 'possessive']),
|
|
751
|
+
Verb: new Set(['plural']),
|
|
752
|
+
Adjective: new Set(['comparative', 'superlative', 'adverb', 'adjective']),
|
|
753
|
+
};
|
|
695
754
|
for (const page of Object.values(tree.pages)) {
|
|
755
|
+
// Collect suffix buttons on this page
|
|
756
|
+
const pageSuffixes = new Set();
|
|
757
|
+
const pageSuffixSlots = new Set();
|
|
758
|
+
for (const row of page.grid) {
|
|
759
|
+
for (const btn of row) {
|
|
760
|
+
if (btn?.pos === 'Suffix' && btn.label) {
|
|
761
|
+
pageSuffixes.add(btn.label);
|
|
762
|
+
const slots = SUFFIX_TO_SLOT[btn.label];
|
|
763
|
+
if (slots) {
|
|
764
|
+
for (const s of slots)
|
|
765
|
+
pageSuffixSlots.add(s);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// No suffix buttons on this page → no morphology
|
|
771
|
+
if (pageSuffixSlots.size === 0)
|
|
772
|
+
continue;
|
|
696
773
|
for (const row of page.grid) {
|
|
697
774
|
for (const btn of row) {
|
|
698
775
|
if (!btn || !btn.label)
|
|
699
776
|
continue;
|
|
777
|
+
if (btn.pos === 'Suffix')
|
|
778
|
+
continue;
|
|
700
779
|
let pos = btn.pos;
|
|
701
|
-
// If no POS tag (or Unknown/Ignore), attempt POS inference.
|
|
702
|
-
// Many content words on topic pages lack POS tags even though
|
|
703
|
-
// they are clearly nouns (e.g., "bird", "tree", "cloud").
|
|
704
|
-
// Strategy: check irregular tables first for confident POS,
|
|
705
|
-
// then fall back to Noun for single-word content labels.
|
|
706
780
|
if (!pos || pos === 'Unknown' || pos === 'Ignore') {
|
|
707
781
|
const lower = btn.label.toLowerCase();
|
|
708
|
-
// Skip function words and multi-word labels
|
|
709
782
|
if (!skipInference.has(lower) && !lower.includes(' ') && lower.length > 1) {
|
|
710
|
-
// Check irregular tables for confident POS assignment
|
|
711
783
|
const inferredPOS = morph.inferPOS(lower);
|
|
712
784
|
if (inferredPOS) {
|
|
713
785
|
pos = inferredPOS;
|
|
714
786
|
btn.pos = inferredPOS;
|
|
715
787
|
}
|
|
716
788
|
else {
|
|
717
|
-
// Default to Noun for untagged content words.
|
|
718
|
-
// This generates plurals (e.g., bird → birds, tree → trees).
|
|
719
789
|
pos = 'Noun';
|
|
720
790
|
btn.pos = 'Noun';
|
|
721
791
|
}
|
|
@@ -723,16 +793,90 @@ export class MetricsCalculator {
|
|
|
723
793
|
}
|
|
724
794
|
if (!pos || pos === 'Unknown' || pos === 'Ignore')
|
|
725
795
|
continue;
|
|
726
|
-
|
|
727
|
-
|
|
796
|
+
// Check if this POS can produce forms matching the page's suffix slots
|
|
797
|
+
const posSlots = POS_TO_SUFFIX_SLOTS[pos];
|
|
798
|
+
if (!posSlots)
|
|
799
|
+
continue;
|
|
800
|
+
const hasRelevantSlot = [...posSlots].some((s) => pageSuffixSlots.has(s));
|
|
801
|
+
if (!hasRelevantSlot)
|
|
802
|
+
continue;
|
|
803
|
+
const allForms = morph.inflect(btn.label, pos);
|
|
804
|
+
if (allForms.length === 0)
|
|
805
|
+
continue;
|
|
806
|
+
// Filter forms: only include those producible by suffixes on this page
|
|
807
|
+
// For the built-in engine, we can't easily map forms to slots, so
|
|
808
|
+
// include all forms when any relevant suffix exists. The per-page
|
|
809
|
+
// gate (suffix presence) is the main filter.
|
|
810
|
+
const existing = btn.predictions || [];
|
|
811
|
+
const merged = new Set([...existing, ...allForms]);
|
|
812
|
+
btn.predictions = Array.from(merged);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Expand morphological predictions for TDSnap pagesets.
|
|
819
|
+
*
|
|
820
|
+
* TDSnap uses Inflector buttons (ContentType=3) on "Word Forms" pages to
|
|
821
|
+
* provide morphology. These pages are loaded dynamically by the runtime,
|
|
822
|
+
* NOT via navigation buttons, so they are unreachable in our tree model.
|
|
823
|
+
*
|
|
824
|
+
* Rules:
|
|
825
|
+
* 1. If the pageset has NO Inflector buttons → no morphology at all
|
|
826
|
+
* 2. Only generate forms whose grammar tag matches an available Inflector
|
|
827
|
+
* (e.g., if there's no -ly Inflector, don't generate "happily")
|
|
828
|
+
* 3. No POS inference — only the lexicon determines which words get forms
|
|
829
|
+
*/
|
|
830
|
+
expandTDSnapPredictions(tree, morph) {
|
|
831
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
832
|
+
const { TDSnapLexiconParser } = require('../morphology/tdsnapLexiconParser');
|
|
833
|
+
// Step 1: Collect available grammar tags from Inflector buttons
|
|
834
|
+
const availableTags = new Set();
|
|
835
|
+
for (const page of Object.values(tree.pages)) {
|
|
836
|
+
for (const row of page.grid) {
|
|
837
|
+
for (const btn of row) {
|
|
838
|
+
if (btn?.contentType === 'Inflector' && btn.parameters?.grammar?.handler) {
|
|
839
|
+
const parsed = TDSnapLexiconParser.parseContentTypeHandler(btn.parameters.grammar.handler);
|
|
840
|
+
if (parsed) {
|
|
841
|
+
const key = `${parsed.category}:${parsed.subtype}`;
|
|
842
|
+
const tag = TDSnapLexiconParser.HANDLER_TAG_MAP[key];
|
|
843
|
+
if (tag)
|
|
844
|
+
availableTags.add(tag);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (availableTags.size === 0)
|
|
851
|
+
return;
|
|
852
|
+
// Step 2: For each button, look up lexicon forms filtered by available tags
|
|
853
|
+
for (const page of Object.values(tree.pages)) {
|
|
854
|
+
for (const row of page.grid) {
|
|
855
|
+
for (const btn of row) {
|
|
856
|
+
if (!btn || !btn.label || btn.contentType === 'Inflector')
|
|
857
|
+
continue;
|
|
858
|
+
const filtered = this.filterFormsByAvailableTags(morph, btn.label, availableTags);
|
|
859
|
+
if (filtered.length > 0) {
|
|
728
860
|
const existing = btn.predictions || [];
|
|
729
|
-
const merged = new Set([...existing, ...
|
|
861
|
+
const merged = new Set([...existing, ...filtered]);
|
|
730
862
|
btn.predictions = Array.from(merged);
|
|
731
863
|
}
|
|
732
864
|
}
|
|
733
865
|
}
|
|
734
866
|
}
|
|
735
867
|
}
|
|
868
|
+
filterFormsByAvailableTags(morph, base, availableTags) {
|
|
869
|
+
const entry = morph.getLexiconEntry(base.toLowerCase());
|
|
870
|
+
if (!entry)
|
|
871
|
+
return [];
|
|
872
|
+
const forms = [];
|
|
873
|
+
for (const f of entry.forms) {
|
|
874
|
+
if (availableTags.has(f.tag) && f.form.toLowerCase() !== base.toLowerCase()) {
|
|
875
|
+
forms.push(f.form);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return forms;
|
|
879
|
+
}
|
|
736
880
|
/**
|
|
737
881
|
* Calculate metrics for word forms (smart grammar predictions)
|
|
738
882
|
*
|
|
@@ -749,7 +893,7 @@ export class MetricsCalculator {
|
|
|
749
893
|
* @param options - Metrics options
|
|
750
894
|
* @returns Object containing word form metrics and labels that were replaced
|
|
751
895
|
*/
|
|
752
|
-
calculateWordFormMetrics(tree, buttons,
|
|
896
|
+
calculateWordFormMetrics(tree, buttons, options = {}) {
|
|
753
897
|
const wordFormMetrics = [];
|
|
754
898
|
const replacedLabels = new Set();
|
|
755
899
|
// Track buttons by label to compare efforts
|
|
@@ -811,23 +955,28 @@ export class MetricsCalculator {
|
|
|
811
955
|
// Calculate effort for each word form
|
|
812
956
|
btn.predictions.forEach((wordForm, index) => {
|
|
813
957
|
const wordFormLower = wordForm.toLowerCase();
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
958
|
+
const isSuggestWords = suggestWordsSet.has(wordFormLower);
|
|
959
|
+
let wordFormEffort;
|
|
960
|
+
if (options.tdsnapLexiconPath && !isSuggestWords) {
|
|
961
|
+
// TDSnap Inflector-based form: the grammar overlay appears
|
|
962
|
+
// dynamically when a word is selected. The cost is a fixed
|
|
963
|
+
// single selection to tap the Inflector button.
|
|
964
|
+
wordFormEffort =
|
|
965
|
+
parentMetrics.effort + EFFORT_CONSTANTS.TDSNAP_GRAMMAR_OVERLAY_EFFORT;
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
// Grid-based prediction layout (Suggest Words, or Grid3 morphology)
|
|
969
|
+
const predictionsGridCols = 2;
|
|
970
|
+
const predictionRowIndex = Math.floor(index / predictionsGridCols);
|
|
971
|
+
const predictionColIndex = index % predictionsGridCols;
|
|
972
|
+
const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex;
|
|
973
|
+
const predictionSelectionEffort = visualScanEffort(predictionPriorItems);
|
|
974
|
+
const suggestWordsConfirmation = isSuggestWords
|
|
975
|
+
? EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT
|
|
976
|
+
: 0;
|
|
977
|
+
wordFormEffort =
|
|
978
|
+
parentMetrics.effort + predictionSelectionEffort + suggestWordsConfirmation;
|
|
979
|
+
}
|
|
831
980
|
// Check if this word already exists as a regular button
|
|
832
981
|
const existingBtn = existingLabels.get(wordFormLower);
|
|
833
982
|
// If word exists and has lower or equal effort, skip the word form
|
|
@@ -32,6 +32,7 @@ export const EFFORT_CONSTANTS = {
|
|
|
32
32
|
DEFAULT_SCAN_ERROR_RATE: 0.1, // 10% chance of missing a selection
|
|
33
33
|
SCAN_RETRY_PENALTY: 1.0, // Cost multiplier for a full loop retry
|
|
34
34
|
SUGGEST_WORDS_SELECTION_EFFORT: 0.5, // Extra tap to confirm a Suggest Words prediction
|
|
35
|
+
TDSNAP_GRAMMAR_OVERLAY_EFFORT: 0.1, // Fixed cost: select an Inflector button from the dynamic grammar overlay
|
|
35
36
|
};
|
|
36
37
|
/**
|
|
37
38
|
* Calculate button size effort based on grid dimensions
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export class MorphologyEngine {
|
|
2
|
+
getLexiconEntry(word) {
|
|
3
|
+
return this.tdsnapLexicon?.words.get(word.toLowerCase());
|
|
4
|
+
}
|
|
2
5
|
constructor(ruleSetOrLocale) {
|
|
3
6
|
this.cache = new Map();
|
|
4
7
|
if (typeof ruleSetOrLocale === 'string') {
|
|
@@ -18,6 +21,16 @@ export class MorphologyEngine {
|
|
|
18
21
|
engine.grid3Verbs = verbForms.verbs;
|
|
19
22
|
return engine;
|
|
20
23
|
}
|
|
24
|
+
static fromTDSnapLexicon(lexiconData) {
|
|
25
|
+
const engine = new MorphologyEngine({
|
|
26
|
+
locale: lexiconData.locale,
|
|
27
|
+
version: 1,
|
|
28
|
+
irregular: {},
|
|
29
|
+
regular: {},
|
|
30
|
+
});
|
|
31
|
+
engine.tdsnapLexicon = lexiconData;
|
|
32
|
+
return engine;
|
|
33
|
+
}
|
|
21
34
|
get locale() {
|
|
22
35
|
return this.ruleSet.locale;
|
|
23
36
|
}
|
|
@@ -26,9 +39,18 @@ export class MorphologyEngine {
|
|
|
26
39
|
const cached = this.cache.get(key);
|
|
27
40
|
if (cached)
|
|
28
41
|
return cached;
|
|
42
|
+
if (this.tdsnapLexicon) {
|
|
43
|
+
const entry = this.tdsnapLexicon.words.get(base.toLowerCase());
|
|
44
|
+
if (entry) {
|
|
45
|
+
const forms = entry.forms.map((f) => f.form);
|
|
46
|
+
this.cache.set(key, forms);
|
|
47
|
+
return forms;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
29
50
|
if (this.grid3Verbs) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
51
|
+
const raw = this.grid3Verbs.get(base) || this.grid3Verbs.get(base.toLowerCase());
|
|
52
|
+
if (raw) {
|
|
53
|
+
const forms = raw.filter((f) => !f.includes('{'));
|
|
32
54
|
this.cache.set(key, forms);
|
|
33
55
|
return forms;
|
|
34
56
|
}
|