@willwade/aac-processors 0.1.6 → 0.1.8
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/analytics.d.ts +7 -0
- package/dist/analytics.js +23 -0
- package/dist/browser/index.browser.js +5 -0
- package/dist/browser/metrics.js +17 -0
- package/dist/browser/processors/gridset/helpers.js +390 -0
- package/dist/browser/processors/snap/helpers.js +252 -0
- package/dist/browser/utilities/analytics/history.js +116 -0
- package/dist/browser/utilities/analytics/metrics/comparison.js +477 -0
- package/dist/browser/utilities/analytics/metrics/core.js +775 -0
- package/dist/browser/utilities/analytics/metrics/effort.js +221 -0
- package/dist/browser/utilities/analytics/metrics/obl-types.js +6 -0
- package/dist/browser/utilities/analytics/metrics/obl.js +282 -0
- package/dist/browser/utilities/analytics/metrics/sentence.js +121 -0
- package/dist/browser/utilities/analytics/metrics/types.js +6 -0
- package/dist/browser/utilities/analytics/metrics/vocabulary.js +138 -0
- package/dist/browser/utilities/analytics/reference/browser.js +67 -0
- package/dist/browser/utilities/analytics/reference/index.js +129 -0
- package/dist/browser/utils/dotnetTicks.js +17 -0
- package/dist/browser/utils/io.js +16 -2
- package/dist/browser/validation/gridsetValidator.js +7 -27
- package/dist/browser/validation/obfValidator.js +9 -4
- package/dist/browser/validation/snapValidator.js +6 -9
- package/dist/browser/validation/touchChatValidator.js +6 -7
- package/dist/index.browser.d.ts +1 -0
- package/dist/index.browser.js +18 -1
- package/dist/index.node.d.ts +2 -2
- package/dist/index.node.js +5 -5
- package/dist/metrics.d.ts +17 -0
- package/dist/metrics.js +44 -0
- package/dist/utilities/analytics/metrics/comparison.d.ts +2 -1
- package/dist/utilities/analytics/metrics/comparison.js +3 -3
- package/dist/utilities/analytics/metrics/vocabulary.d.ts +2 -2
- package/dist/utilities/analytics/reference/browser.d.ts +31 -0
- package/dist/utilities/analytics/reference/browser.js +73 -0
- package/dist/utilities/analytics/reference/index.d.ts +21 -0
- package/dist/utilities/analytics/reference/index.js +22 -46
- package/dist/utils/io.d.ts +2 -0
- package/dist/utils/io.js +18 -2
- package/dist/validation/applePanelsValidator.js +11 -28
- package/dist/validation/astericsValidator.js +11 -30
- package/dist/validation/dotValidator.js +11 -30
- package/dist/validation/excelValidator.js +5 -6
- package/dist/validation/gridsetValidator.js +29 -26
- package/dist/validation/index.d.ts +2 -1
- package/dist/validation/index.js +9 -32
- package/dist/validation/obfValidator.js +8 -3
- package/dist/validation/obfsetValidator.js +11 -30
- package/dist/validation/opmlValidator.js +11 -30
- package/dist/validation/snapValidator.js +6 -9
- package/dist/validation/touchChatValidator.js +6 -7
- package/examples/vitedemo/index.html +49 -0
- package/examples/vitedemo/src/main.ts +84 -0
- package/examples/vitedemo/vite.config.ts +26 -7
- package/package.json +9 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effort Score Calculation Algorithms
|
|
3
|
+
*
|
|
4
|
+
* Implements the core effort calculation algorithms from the Ruby aac-metrics tool.
|
|
5
|
+
* These algorithms calculate how difficult it is to access each button based on
|
|
6
|
+
* distance, visual scanning, grid complexity, and motor planning support.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Constants for effort score calculation
|
|
10
|
+
* Values match the Ruby implementation exactly
|
|
11
|
+
*/
|
|
12
|
+
export const EFFORT_CONSTANTS = {
|
|
13
|
+
SQRT2: Math.sqrt(2),
|
|
14
|
+
BUTTON_SIZE_MULTIPLIER: 0.09,
|
|
15
|
+
FIELD_SIZE_MULTIPLIER: 0.005,
|
|
16
|
+
VISUAL_SCAN_MULTIPLIER: 0.015,
|
|
17
|
+
BOARD_CHANGE_PROCESSING_EFFORT: 1.0,
|
|
18
|
+
BOARD_HOME_EFFORT: 1.0,
|
|
19
|
+
COMBINED_WORDS_REMEMBERING_EFFORT: 1.0,
|
|
20
|
+
DISTANCE_MULTIPLIER: 0.4,
|
|
21
|
+
DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN: 0.1,
|
|
22
|
+
SKIPPED_VISUAL_SCAN_DISTANCE_MULTIPLIER: 0.5,
|
|
23
|
+
SAME_LOCATION_AS_PRIOR_DISCOUNT: 0.1,
|
|
24
|
+
RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT: 0.5,
|
|
25
|
+
RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT: 0.5,
|
|
26
|
+
REUSED_SEMANTIC_FROM_OTHER_BONUS: 0.0025,
|
|
27
|
+
RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT: 0.33,
|
|
28
|
+
RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT: 0.33,
|
|
29
|
+
REUSED_CLONE_FROM_OTHER_BONUS: 0.005,
|
|
30
|
+
SCAN_STEP_COST: 0.015, // Matches visual scan multiplier
|
|
31
|
+
SCAN_SELECTION_COST: 0.1, // Cost of a switch selection
|
|
32
|
+
DEFAULT_SCAN_ERROR_RATE: 0.1, // 10% chance of missing a selection
|
|
33
|
+
SCAN_RETRY_PENALTY: 1.0, // Cost multiplier for a full loop retry
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Calculate button size effort based on grid dimensions
|
|
37
|
+
* Larger grids require more visual scanning and discrimination
|
|
38
|
+
*
|
|
39
|
+
* @param rows - Number of rows in the grid
|
|
40
|
+
* @param cols - Number of columns in the grid
|
|
41
|
+
* @returns Button size effort score
|
|
42
|
+
*/
|
|
43
|
+
export function buttonSizeEffort(rows, cols) {
|
|
44
|
+
return EFFORT_CONSTANTS.BUTTON_SIZE_MULTIPLIER * ((rows + cols) / 2);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Calculate field size effort based on number of visible buttons
|
|
48
|
+
* More buttons = more visual clutter = higher effort
|
|
49
|
+
*
|
|
50
|
+
* @param buttonCount - Number of visible buttons on the board
|
|
51
|
+
* @returns Field size effort score
|
|
52
|
+
*/
|
|
53
|
+
export function fieldSizeEffort(buttonCount) {
|
|
54
|
+
return EFFORT_CONSTANTS.FIELD_SIZE_MULTIPLIER * buttonCount;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Calculate visual scanning effort
|
|
58
|
+
* Effort increases with each button that must be scanned before reaching target
|
|
59
|
+
*
|
|
60
|
+
* @param priorButtons - Number of buttons visually scanned before target
|
|
61
|
+
* @returns Visual scan effort score
|
|
62
|
+
*/
|
|
63
|
+
export function visualScanEffort(priorButtons) {
|
|
64
|
+
return priorButtons * EFFORT_CONSTANTS.VISUAL_SCAN_MULTIPLIER;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Calculate distance effort from entry point to button center
|
|
68
|
+
* Uses Euclidean distance normalized by sqrt(2)
|
|
69
|
+
*
|
|
70
|
+
* @param x - Button center X coordinate (0-1 normalized)
|
|
71
|
+
* @param y - Button center Y coordinate (0-1 normalized)
|
|
72
|
+
* @param entryX - Entry point X coordinate (0-1 normalized, default 1.0 = bottom-right)
|
|
73
|
+
* @param entryY - Entry point Y coordinate (0-1 normalized, default 1.0 = bottom-right)
|
|
74
|
+
* @returns Distance effort score
|
|
75
|
+
*/
|
|
76
|
+
export function distanceEffort(x, y, entryX = 1.0, entryY = 1.0) {
|
|
77
|
+
const distance = Math.sqrt(Math.pow(x - entryX, 2) + Math.pow(y - entryY, 2));
|
|
78
|
+
return (distance / EFFORT_CONSTANTS.SQRT2) * EFFORT_CONSTANTS.DISTANCE_MULTIPLIER;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Calculate spelling effort for words not available in the board set
|
|
82
|
+
*
|
|
83
|
+
* @param word - The word to spell
|
|
84
|
+
* @param entryEffort - Effort to reach the spelling/keyboard page
|
|
85
|
+
* @param perLetterEffort - Average effort per letter on the keyboard
|
|
86
|
+
* @returns Spelling effort score
|
|
87
|
+
*/
|
|
88
|
+
export function spellingEffort(word, entryEffort = 10, perLetterEffort = 2.5) {
|
|
89
|
+
return entryEffort + word.length * perLetterEffort;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Calculate effort to access a word via prediction
|
|
93
|
+
*
|
|
94
|
+
* When prediction is available, the user:
|
|
95
|
+
* 1. Navigates to the spelling/keyboard page (entryEffort)
|
|
96
|
+
* 2. Types first 1-3 letters to trigger predictions
|
|
97
|
+
* 3. Selects from 1-3 predictions (average selections)
|
|
98
|
+
*
|
|
99
|
+
* @param entryEffort - Effort to reach the spelling/keyboard page
|
|
100
|
+
* @param perLetterEffort - Average effort per letter on the keyboard
|
|
101
|
+
* @param avgSelections - Average number of predictions to check (default 1.5)
|
|
102
|
+
* @param lettersToType - Letters to type before prediction appears (default 2)
|
|
103
|
+
* @returns Prediction effort score
|
|
104
|
+
*/
|
|
105
|
+
export function predictionEffort(entryEffort = 10, perLetterEffort = 2.5, avgSelections = 1.5, lettersToType = 2) {
|
|
106
|
+
// Cost to navigate to keyboard + type first few letters + select from predictions
|
|
107
|
+
const typingCost = lettersToType * perLetterEffort;
|
|
108
|
+
const selectionCost = avgSelections * EFFORT_CONSTANTS.SCAN_SELECTION_COST;
|
|
109
|
+
return entryEffort + typingCost + selectionCost;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Calculate base board effort
|
|
113
|
+
* Combines button size and field size efforts
|
|
114
|
+
*
|
|
115
|
+
* @param rows - Number of rows in the grid
|
|
116
|
+
* @param cols - Number of columns in the grid
|
|
117
|
+
* @param buttonCount - Number of visible buttons
|
|
118
|
+
* @returns Base board effort score
|
|
119
|
+
*/
|
|
120
|
+
export function baseBoardEffort(rows, cols, buttonCount) {
|
|
121
|
+
const sizeEffort = buttonSizeEffort(rows, cols);
|
|
122
|
+
const fieldEffort = fieldSizeEffort(buttonCount);
|
|
123
|
+
return sizeEffort + fieldEffort;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Apply reuse discount based on semantic_id/clone_id frequency
|
|
127
|
+
*
|
|
128
|
+
* @param boardEffort - Current board effort
|
|
129
|
+
* @param reuseDiscount - Calculated reuse discount
|
|
130
|
+
* @returns Adjusted board effort
|
|
131
|
+
*/
|
|
132
|
+
export function applyReuseDiscount(boardEffort, reuseDiscount) {
|
|
133
|
+
return Math.max(0, boardEffort - reuseDiscount);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Calculate button-level effort with motor planning discounts
|
|
137
|
+
*
|
|
138
|
+
* @param baseEffort - Base board effort
|
|
139
|
+
* @param boardPcts - Percentage of links matching semantic_id/clone_id
|
|
140
|
+
* @param button - Button data
|
|
141
|
+
* @returns Adjusted button effort
|
|
142
|
+
*/
|
|
143
|
+
export function calculateButtonEffort(baseEffort, boardPcts, button) {
|
|
144
|
+
let buttonEffort = baseEffort;
|
|
145
|
+
// Apply discounts for semantic_id
|
|
146
|
+
if (button.semantic_id && boardPcts[button.semantic_id]) {
|
|
147
|
+
const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.semantic_id];
|
|
148
|
+
buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
|
|
149
|
+
}
|
|
150
|
+
// Apply discounts for clone_id
|
|
151
|
+
if (button.clone_id && boardPcts[button.clone_id]) {
|
|
152
|
+
const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.clone_id];
|
|
153
|
+
buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
|
|
154
|
+
}
|
|
155
|
+
return buttonEffort;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Calculate distance with motor planning discounts
|
|
159
|
+
*
|
|
160
|
+
* @param distance - Raw distance effort
|
|
161
|
+
* @param boardPcts - Percentage of links matching semantic_id/clone_id
|
|
162
|
+
* @param button - Button data
|
|
163
|
+
* @param setPcts - Percentage of boards containing semantic_id/clone_id
|
|
164
|
+
* @returns Adjusted distance effort
|
|
165
|
+
*/
|
|
166
|
+
export function calculateDistanceWithDiscounts(distance, boardPcts, button, setPcts) {
|
|
167
|
+
let adjustedDistance = distance;
|
|
168
|
+
// Apply semantic_id discounts
|
|
169
|
+
if (button.semantic_id) {
|
|
170
|
+
if (boardPcts[button.semantic_id]) {
|
|
171
|
+
const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.semantic_id];
|
|
172
|
+
adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount);
|
|
173
|
+
}
|
|
174
|
+
else if (setPcts[button.semantic_id]) {
|
|
175
|
+
const discount = EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT / setPcts[button.semantic_id];
|
|
176
|
+
adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Apply clone_id discounts
|
|
180
|
+
if (button.clone_id) {
|
|
181
|
+
if (boardPcts[button.clone_id]) {
|
|
182
|
+
const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.clone_id];
|
|
183
|
+
adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount);
|
|
184
|
+
}
|
|
185
|
+
else if (setPcts[button.clone_id]) {
|
|
186
|
+
const discount = EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[button.clone_id];
|
|
187
|
+
adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return adjustedDistance;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check if visual scan should be skipped (button close to previous)
|
|
194
|
+
*
|
|
195
|
+
* @param distance - Distance from previous button
|
|
196
|
+
* @returns True if close enough to skip full visual scan
|
|
197
|
+
*/
|
|
198
|
+
export function shouldSkipVisualScan(distance) {
|
|
199
|
+
return distance < EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Calculate local scan effort when buttons are close
|
|
203
|
+
*
|
|
204
|
+
* @param distance - Distance between buttons
|
|
205
|
+
* @returns Local scan effort
|
|
206
|
+
*/
|
|
207
|
+
export function localScanEffort(distance) {
|
|
208
|
+
return distance * EFFORT_CONSTANTS.SKIPPED_VISUAL_SCAN_DISTANCE_MULTIPLIER;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Calculate effort for switch scanning
|
|
212
|
+
*
|
|
213
|
+
* @param steps - Number of scan steps to reach target
|
|
214
|
+
* @param selections - Number of switch selections required
|
|
215
|
+
* @param stepCost - Optional override for scan step cost
|
|
216
|
+
* @param selectionCost - Optional override for scan selection cost
|
|
217
|
+
* @returns Scanning effort score
|
|
218
|
+
*/
|
|
219
|
+
export function scanningEffort(steps, selections, stepCost = EFFORT_CONSTANTS.SCAN_STEP_COST, selectionCost = EFFORT_CONSTANTS.SCAN_SELECTION_COST) {
|
|
220
|
+
return steps * stepCost + selections * selectionCost;
|
|
221
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { AACSemanticIntent, AACSemanticCategory } from '../../../core/treeStructure';
|
|
2
|
+
/**
|
|
3
|
+
* .obl (Open Board Logging) Utility
|
|
4
|
+
*
|
|
5
|
+
* Provides parsing and generation support for the .obl format.
|
|
6
|
+
*/
|
|
7
|
+
export class OblUtil {
|
|
8
|
+
/**
|
|
9
|
+
* Parse an OBL JSON string.
|
|
10
|
+
* Handles the optional /* notice * / at the start of the file.
|
|
11
|
+
*/
|
|
12
|
+
static parse(json) {
|
|
13
|
+
// Remove potential comment at the start
|
|
14
|
+
let cleanJson = json.trim();
|
|
15
|
+
if (cleanJson.startsWith('/*')) {
|
|
16
|
+
const endComment = cleanJson.indexOf('*/');
|
|
17
|
+
if (endComment !== -1) {
|
|
18
|
+
cleanJson = cleanJson.substring(endComment + 2).trim();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return JSON.parse(cleanJson);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Stringify an OBL file object.
|
|
25
|
+
* Optionally adds the recommended notice comment.
|
|
26
|
+
*/
|
|
27
|
+
static stringify(obl, includeNotice = true) {
|
|
28
|
+
const json = JSON.stringify(obl, null, 2);
|
|
29
|
+
if (includeNotice) {
|
|
30
|
+
return `/* NOTICE: The following information represents an individual's communication and should be treated respectfully and securely. */\n${json}`;
|
|
31
|
+
}
|
|
32
|
+
return json;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Convert an OBL file to internal HistoryEntry format.
|
|
36
|
+
*/
|
|
37
|
+
static toHistoryEntries(obl) {
|
|
38
|
+
const entries = [];
|
|
39
|
+
const source = obl.source || 'OBL';
|
|
40
|
+
// OBL is session-based and event-based.
|
|
41
|
+
// HistoryEntry is content-based with occurrences.
|
|
42
|
+
// We'll group events by content (label/text) to match HistoryEntry structure.
|
|
43
|
+
const contentMap = new Map();
|
|
44
|
+
for (const session of obl.sessions) {
|
|
45
|
+
for (const event of session.events) {
|
|
46
|
+
let content = '';
|
|
47
|
+
const evtAny = event;
|
|
48
|
+
const occurrence = {
|
|
49
|
+
timestamp: new Date(event.timestamp),
|
|
50
|
+
modeling: event.modeling,
|
|
51
|
+
pageId: evtAny.board_id || null,
|
|
52
|
+
latitude: event.geo?.[0] || null,
|
|
53
|
+
longitude: event.geo?.[1] || null,
|
|
54
|
+
type: event.type,
|
|
55
|
+
// Store all other OBL fields in the occurrence
|
|
56
|
+
buttonId: evtAny.button_id || null,
|
|
57
|
+
boardId: evtAny.board_id || null,
|
|
58
|
+
spoken: evtAny.spoken,
|
|
59
|
+
vocalization: evtAny.vocalization,
|
|
60
|
+
imageUrl: evtAny.image_url,
|
|
61
|
+
actions: evtAny.actions,
|
|
62
|
+
};
|
|
63
|
+
if (event.type === 'button') {
|
|
64
|
+
const btn = event;
|
|
65
|
+
content = btn.vocalization || btn.label;
|
|
66
|
+
}
|
|
67
|
+
else if (event.type === 'utterance') {
|
|
68
|
+
const utt = event;
|
|
69
|
+
content = utt.text;
|
|
70
|
+
}
|
|
71
|
+
else if (event.type === 'action') {
|
|
72
|
+
const act = event;
|
|
73
|
+
content = act.action;
|
|
74
|
+
}
|
|
75
|
+
else if (event.type === 'note') {
|
|
76
|
+
const note = event;
|
|
77
|
+
content = note.text;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const evtAny = event;
|
|
81
|
+
content = evtAny.label || evtAny.text || evtAny.action || 'unknown';
|
|
82
|
+
}
|
|
83
|
+
const occurrences = contentMap.get(content) || [];
|
|
84
|
+
occurrences.push(occurrence);
|
|
85
|
+
contentMap.set(content, occurrences);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
contentMap.forEach((occurrences, content) => {
|
|
89
|
+
entries.push({
|
|
90
|
+
id: `obl:${content}`,
|
|
91
|
+
source: source,
|
|
92
|
+
content: content,
|
|
93
|
+
occurrences: occurrences.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Convert HistoryEntries to an OBL file object.
|
|
100
|
+
*/
|
|
101
|
+
static fromHistoryEntries(entries, userId, source) {
|
|
102
|
+
const events = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
for (const occ of entry.occurrences) {
|
|
105
|
+
const timestamp = occ.timestamp.toISOString();
|
|
106
|
+
const intent = occ.intent;
|
|
107
|
+
let oblType = occ.type || 'button';
|
|
108
|
+
let actionStr = undefined;
|
|
109
|
+
// Smart mapping based on AACSemanticIntent
|
|
110
|
+
if (intent === AACSemanticIntent.CLEAR_TEXT) {
|
|
111
|
+
oblType = 'action';
|
|
112
|
+
actionStr = ':clear';
|
|
113
|
+
}
|
|
114
|
+
else if (intent === AACSemanticIntent.GO_HOME) {
|
|
115
|
+
oblType = 'action';
|
|
116
|
+
actionStr = ':home';
|
|
117
|
+
}
|
|
118
|
+
else if (intent === AACSemanticIntent.NAVIGATE_TO) {
|
|
119
|
+
oblType = 'action';
|
|
120
|
+
actionStr = ':open_board';
|
|
121
|
+
}
|
|
122
|
+
else if (intent === AACSemanticIntent.GO_BACK) {
|
|
123
|
+
oblType = 'action';
|
|
124
|
+
actionStr = ':back';
|
|
125
|
+
}
|
|
126
|
+
else if (intent === AACSemanticIntent.DELETE_CHARACTER) {
|
|
127
|
+
oblType = 'action';
|
|
128
|
+
actionStr = ':backspace';
|
|
129
|
+
}
|
|
130
|
+
else if (intent === AACSemanticIntent.SPEAK_IMMEDIATE ||
|
|
131
|
+
intent === AACSemanticIntent.SPEAK_TEXT) {
|
|
132
|
+
// Speak could be a button or an utterance or an action
|
|
133
|
+
if (oblType !== 'utterance' && oblType !== 'button') {
|
|
134
|
+
oblType = 'action';
|
|
135
|
+
actionStr = ':speak';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const common = {
|
|
139
|
+
id: Math.random().toString(36).substring(2, 11),
|
|
140
|
+
timestamp,
|
|
141
|
+
modeling: occ.modeling,
|
|
142
|
+
type: oblType,
|
|
143
|
+
};
|
|
144
|
+
if (occ.latitude !== null &&
|
|
145
|
+
occ.latitude !== undefined &&
|
|
146
|
+
occ.longitude !== null &&
|
|
147
|
+
occ.longitude !== undefined) {
|
|
148
|
+
common.geo = [occ.latitude, occ.longitude];
|
|
149
|
+
}
|
|
150
|
+
if (oblType === 'utterance') {
|
|
151
|
+
events.push({
|
|
152
|
+
...common,
|
|
153
|
+
text: entry.content,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else if (oblType === 'action') {
|
|
157
|
+
events.push({
|
|
158
|
+
...common,
|
|
159
|
+
action: actionStr || entry.content,
|
|
160
|
+
destination_board_id: occ.boardId || undefined,
|
|
161
|
+
text: intent === AACSemanticIntent.SPEAK_TEXT ? entry.content : undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else if (oblType === 'note') {
|
|
165
|
+
events.push({
|
|
166
|
+
...common,
|
|
167
|
+
text: entry.content,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Default to button
|
|
172
|
+
events.push({
|
|
173
|
+
...common,
|
|
174
|
+
type: 'button',
|
|
175
|
+
label: occ.vocalization ? entry.content : entry.content,
|
|
176
|
+
spoken: occ.spoken ??
|
|
177
|
+
occ.category === AACSemanticCategory.COMMUNICATION,
|
|
178
|
+
button_id: occ.buttonId || undefined,
|
|
179
|
+
board_id: occ.boardId || occ.pageId || undefined,
|
|
180
|
+
vocalization: occ.vocalization || undefined,
|
|
181
|
+
image_url: occ.imageUrl || undefined,
|
|
182
|
+
actions: occ.actions || undefined,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Sort events by timestamp
|
|
188
|
+
events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
189
|
+
const started = events.length > 0 ? events[0].timestamp : new Date().toISOString();
|
|
190
|
+
const ended = events.length > 0 ? events[events.length - 1].timestamp : new Date().toISOString();
|
|
191
|
+
const session = {
|
|
192
|
+
id: 'session-1',
|
|
193
|
+
type: 'log',
|
|
194
|
+
started,
|
|
195
|
+
ended,
|
|
196
|
+
events,
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
format: 'open-board-log-0.1',
|
|
200
|
+
user_id: userId,
|
|
201
|
+
source: source || 'aac-processors',
|
|
202
|
+
sessions: [session],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* .obl Anonymization Utility
|
|
208
|
+
*/
|
|
209
|
+
export class OblAnonymizer {
|
|
210
|
+
/**
|
|
211
|
+
* Apply anonymization to an OBL file.
|
|
212
|
+
*/
|
|
213
|
+
static anonymize(obl, types) {
|
|
214
|
+
const newObl = JSON.parse(JSON.stringify(obl));
|
|
215
|
+
newObl.anonymized = true;
|
|
216
|
+
for (const session of newObl.sessions) {
|
|
217
|
+
session.anonymizations = session.anonymizations || [];
|
|
218
|
+
if (types.includes('timestamp_shift')) {
|
|
219
|
+
this.applyTimestampShift(session);
|
|
220
|
+
if (!session.anonymizations.includes('timestamp_shift'))
|
|
221
|
+
session.anonymizations.push('timestamp_shift');
|
|
222
|
+
}
|
|
223
|
+
if (types.includes('geolocation_masking')) {
|
|
224
|
+
this.applyGeolocationMasking(session);
|
|
225
|
+
if (!session.anonymizations.includes('geolocation_masking'))
|
|
226
|
+
session.anonymizations.push('geolocation_masking');
|
|
227
|
+
}
|
|
228
|
+
if (types.includes('url_stripping')) {
|
|
229
|
+
this.applyUrlStripping(session);
|
|
230
|
+
if (!session.anonymizations.includes('url_stripping'))
|
|
231
|
+
session.anonymizations.push('url_stripping');
|
|
232
|
+
}
|
|
233
|
+
if (types.includes('name_masking')) {
|
|
234
|
+
this.applyNameMasking(newObl, session);
|
|
235
|
+
if (!session.anonymizations.includes('name_masking'))
|
|
236
|
+
session.anonymizations.push('name_masking');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return newObl;
|
|
240
|
+
}
|
|
241
|
+
static applyTimestampShift(session) {
|
|
242
|
+
if (session.events.length === 0)
|
|
243
|
+
return;
|
|
244
|
+
const firstEventTime = session.events.length > 0 ? new Date(session.events[0].timestamp).getTime() : Infinity;
|
|
245
|
+
const sessionStartTime = session.started ? new Date(session.started).getTime() : Infinity;
|
|
246
|
+
const firstTimestamp = Math.min(firstEventTime, sessionStartTime);
|
|
247
|
+
if (firstTimestamp === Infinity)
|
|
248
|
+
return;
|
|
249
|
+
const targetStart = new Date('2000-01-01T00:00:00.000Z').getTime();
|
|
250
|
+
const offset = targetStart - firstTimestamp;
|
|
251
|
+
session.started = new Date(new Date(session.started).getTime() + offset).toISOString();
|
|
252
|
+
session.ended = new Date(new Date(session.ended).getTime() + offset).toISOString();
|
|
253
|
+
for (const event of session.events) {
|
|
254
|
+
event.timestamp = new Date(new Date(event.timestamp).getTime() + offset).toISOString();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
static applyGeolocationMasking(session) {
|
|
258
|
+
for (const event of session.events) {
|
|
259
|
+
delete event.geo;
|
|
260
|
+
delete event.location_id;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
static applyUrlStripping(session) {
|
|
264
|
+
for (const event of session.events) {
|
|
265
|
+
if (event.type === 'button') {
|
|
266
|
+
delete event.image_url;
|
|
267
|
+
}
|
|
268
|
+
if (event.type === 'note') {
|
|
269
|
+
delete event.author_url;
|
|
270
|
+
delete event.author_email;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
static applyNameMasking(obl, session) {
|
|
275
|
+
delete obl.user_name;
|
|
276
|
+
for (const event of session.events) {
|
|
277
|
+
if (event.type === 'note') {
|
|
278
|
+
delete event.author_name;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentence Construction Analysis
|
|
3
|
+
*
|
|
4
|
+
* Calculates the effort required to construct test sentences
|
|
5
|
+
* from the AAC board set, including spelling fallback for missing words.
|
|
6
|
+
*/
|
|
7
|
+
import { spellingEffort } from './effort';
|
|
8
|
+
export class SentenceAnalyzer {
|
|
9
|
+
/**
|
|
10
|
+
* Analyze effort to construct a set of test sentences
|
|
11
|
+
*/
|
|
12
|
+
analyzeSentences(metrics, sentences) {
|
|
13
|
+
return sentences.map((words) => this.analyzeSentence(metrics, words));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Analyze effort to construct a single sentence
|
|
17
|
+
*/
|
|
18
|
+
analyzeSentence(metrics, words) {
|
|
19
|
+
const wordEfforts = [];
|
|
20
|
+
let totalEffort = 0;
|
|
21
|
+
let typing = false;
|
|
22
|
+
const missingWords = [];
|
|
23
|
+
// Create word lookup map
|
|
24
|
+
const wordMap = new Map();
|
|
25
|
+
metrics.buttons.forEach((btn) => {
|
|
26
|
+
const existing = wordMap.get(btn.label.toLowerCase());
|
|
27
|
+
if (!existing || btn.effort < existing.effort) {
|
|
28
|
+
wordMap.set(btn.label.toLowerCase(), { effort: btn.effort });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// Calculate effort for each word
|
|
32
|
+
words.forEach((word) => {
|
|
33
|
+
const lowerWord = word.toLowerCase();
|
|
34
|
+
const found = wordMap.get(lowerWord);
|
|
35
|
+
if (found) {
|
|
36
|
+
wordEfforts.push({ word, effort: found.effort, typed: false });
|
|
37
|
+
totalEffort += found.effort;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Word not found - check for dynamic prediction fallback
|
|
41
|
+
let wordEffort = 0;
|
|
42
|
+
const isTyped = true;
|
|
43
|
+
const baseSpell = spellingEffort(word, metrics.spelling_effort_base || 10, metrics.spelling_effort_per_letter || 2.5);
|
|
44
|
+
if (metrics.has_dynamic_prediction) {
|
|
45
|
+
// Predictive fallback: Base + (limited letters) + selection
|
|
46
|
+
// We assume on average typing 40% of the word finds it in the dictionary
|
|
47
|
+
const predictiveEffort = (metrics.spelling_effort_base || 10) +
|
|
48
|
+
word.length * 0.4 * (metrics.spelling_effort_per_letter || 2.5) +
|
|
49
|
+
6.0; // Fixed selection cost from prediction bar
|
|
50
|
+
wordEffort = Math.min(baseSpell, predictiveEffort);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
wordEffort = baseSpell;
|
|
54
|
+
}
|
|
55
|
+
wordEfforts.push({ word, effort: wordEffort, typed: isTyped });
|
|
56
|
+
totalEffort += wordEffort;
|
|
57
|
+
typing = true;
|
|
58
|
+
missingWords.push(word);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
const averageEffort = totalEffort / words.length;
|
|
62
|
+
// Reconstruct sentence for display
|
|
63
|
+
const sentence = this.reconstructSentence(words);
|
|
64
|
+
return {
|
|
65
|
+
sentence,
|
|
66
|
+
words,
|
|
67
|
+
effort: averageEffort,
|
|
68
|
+
total_effort: totalEffort,
|
|
69
|
+
typing,
|
|
70
|
+
missing_words: missingWords,
|
|
71
|
+
word_efforts: wordEfforts,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reconstruct sentence from word array
|
|
76
|
+
*/
|
|
77
|
+
reconstructSentence(words) {
|
|
78
|
+
return words
|
|
79
|
+
.map((word, idx) => {
|
|
80
|
+
// Capitalize first word
|
|
81
|
+
if (idx === 0) {
|
|
82
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
return word.toLowerCase();
|
|
85
|
+
})
|
|
86
|
+
.join(' ');
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Calculate statistics across all sentences
|
|
90
|
+
*/
|
|
91
|
+
calculateStatistics(analyses) {
|
|
92
|
+
const totalSentences = analyses.length;
|
|
93
|
+
const sentencesRequiringTyping = analyses.filter((a) => a.typing).length;
|
|
94
|
+
const sentencesWithoutTyping = totalSentences - sentencesRequiringTyping;
|
|
95
|
+
const efforts = analyses.map((a) => a.effort);
|
|
96
|
+
const averageEffort = efforts.reduce((sum, e) => sum + e, 0) / efforts.length;
|
|
97
|
+
const minEffort = Math.min(...efforts);
|
|
98
|
+
const maxEffort = Math.max(...efforts);
|
|
99
|
+
// Calculate median
|
|
100
|
+
const sortedEfforts = [...efforts].sort((a, b) => a - b);
|
|
101
|
+
const medianEffort = sortedEfforts.length % 2 === 0
|
|
102
|
+
? (sortedEfforts[sortedEfforts.length / 2 - 1] + sortedEfforts[sortedEfforts.length / 2]) /
|
|
103
|
+
2
|
|
104
|
+
: sortedEfforts[Math.floor(sortedEfforts.length / 2)];
|
|
105
|
+
const totalWords = analyses.reduce((sum, a) => sum + a.words.length, 0);
|
|
106
|
+
const wordsRequiringTyping = analyses.reduce((sum, a) => sum + a.missing_words.length, 0);
|
|
107
|
+
const typingPercent = (wordsRequiringTyping / totalWords) * 100;
|
|
108
|
+
return {
|
|
109
|
+
total_sentences: totalSentences,
|
|
110
|
+
sentences_requiring_typing: sentencesRequiringTyping,
|
|
111
|
+
sentences_without_typing: sentencesWithoutTyping,
|
|
112
|
+
average_effort: averageEffort,
|
|
113
|
+
min_effort: minEffort,
|
|
114
|
+
max_effort: maxEffort,
|
|
115
|
+
median_effort: medianEffort,
|
|
116
|
+
total_words: totalWords,
|
|
117
|
+
words_requiring_typing: wordsRequiringTyping,
|
|
118
|
+
typing_percent: typingPercent,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|