@surbee/cipher 0.1.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.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Content-based checks
3
+ *
4
+ * Analyzes response content for quality and patterns.
5
+ * All checks in this file are offline (no API required).
6
+ */
7
+
8
+ import type { CheckResult, ResponseInput, BehavioralMetrics } from '../types';
9
+
10
+ /**
11
+ * Common gibberish patterns
12
+ */
13
+ const GIBBERISH_PATTERNS = [
14
+ /^[a-z]{1,3}$/i, // Single letters or very short
15
+ /(.)\1{4,}/, // Repeated characters (aaaaa)
16
+ /^[^aeiou]{5,}$/i, // No vowels in 5+ chars
17
+ /^(asdf|qwerty|zxcv|wasd)/i, // Keyboard mashing
18
+ /^[0-9]+$/, // Only numbers
19
+ /^(.+?)\1{2,}$/, // Repeated patterns (abcabcabc)
20
+ ];
21
+
22
+ /**
23
+ * Low effort indicators
24
+ */
25
+ const LOW_EFFORT_RESPONSES = [
26
+ 'n/a',
27
+ 'na',
28
+ 'none',
29
+ 'nothing',
30
+ 'idk',
31
+ 'i dont know',
32
+ "i don't know",
33
+ 'no comment',
34
+ 'no',
35
+ 'yes',
36
+ 'ok',
37
+ 'okay',
38
+ 'good',
39
+ 'fine',
40
+ 'whatever',
41
+ 'asdf',
42
+ 'test',
43
+ '.',
44
+ '-',
45
+ '...',
46
+ ];
47
+
48
+ /**
49
+ * Check: Minimal Effort
50
+ *
51
+ * Detects very short or low-quality text responses.
52
+ */
53
+ export function checkMinimalEffort(responses: ResponseInput[]): CheckResult {
54
+ const textResponses = responses.filter(
55
+ r => r.questionType === 'text' || (!r.questionType && r.answer.length > 0)
56
+ );
57
+
58
+ if (textResponses.length === 0) {
59
+ return {
60
+ checkId: 'minimal_effort',
61
+ passed: true,
62
+ score: 0,
63
+ details: 'No text responses to analyze',
64
+ };
65
+ }
66
+
67
+ let lowEffortCount = 0;
68
+ let totalLength = 0;
69
+
70
+ for (const response of textResponses) {
71
+ const answer = response.answer.toLowerCase().trim();
72
+ totalLength += answer.length;
73
+
74
+ // Check for known low-effort responses
75
+ if (LOW_EFFORT_RESPONSES.includes(answer)) {
76
+ lowEffortCount++;
77
+ continue;
78
+ }
79
+
80
+ // Check for very short responses (< 10 chars for open text)
81
+ if (answer.length < 10) {
82
+ lowEffortCount++;
83
+ continue;
84
+ }
85
+
86
+ // Check for gibberish patterns
87
+ for (const pattern of GIBBERISH_PATTERNS) {
88
+ if (pattern.test(answer)) {
89
+ lowEffortCount++;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+
95
+ const lowEffortRatio = lowEffortCount / textResponses.length;
96
+ const avgLength = totalLength / textResponses.length;
97
+
98
+ if (lowEffortRatio > 0.7) {
99
+ return {
100
+ checkId: 'minimal_effort',
101
+ passed: false,
102
+ score: 1.0,
103
+ details: 'Most responses are low effort or gibberish',
104
+ data: { lowEffortRatio, avgLength, lowEffortCount },
105
+ };
106
+ }
107
+
108
+ if (lowEffortRatio > 0.4) {
109
+ return {
110
+ checkId: 'minimal_effort',
111
+ passed: true,
112
+ score: 0.6,
113
+ details: 'Many responses appear low effort',
114
+ data: { lowEffortRatio, avgLength },
115
+ };
116
+ }
117
+
118
+ if (avgLength < 20) {
119
+ return {
120
+ checkId: 'minimal_effort',
121
+ passed: true,
122
+ score: 0.4,
123
+ details: 'Responses are quite short on average',
124
+ data: { avgLength },
125
+ };
126
+ }
127
+
128
+ return {
129
+ checkId: 'minimal_effort',
130
+ passed: true,
131
+ score: 0,
132
+ data: { lowEffortRatio, avgLength },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Check: Straight-Lining
138
+ *
139
+ * Detects selecting the same option repeatedly in multiple choice questions.
140
+ */
141
+ export function checkStraightLining(responses: ResponseInput[]): CheckResult {
142
+ const scaleResponses = responses.filter(
143
+ r => r.questionType === 'rating' ||
144
+ r.questionType === 'scale' ||
145
+ r.questionType === 'multiple_choice'
146
+ );
147
+
148
+ if (scaleResponses.length < 4) {
149
+ return {
150
+ checkId: 'straight_line_answers',
151
+ passed: true,
152
+ score: 0,
153
+ details: 'Not enough scale/choice questions',
154
+ };
155
+ }
156
+
157
+ // Count consecutive identical answers
158
+ let maxConsecutive = 1;
159
+ let currentConsecutive = 1;
160
+
161
+ for (let i = 1; i < scaleResponses.length; i++) {
162
+ if (scaleResponses[i].answer === scaleResponses[i - 1].answer) {
163
+ currentConsecutive++;
164
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
165
+ } else {
166
+ currentConsecutive = 1;
167
+ }
168
+ }
169
+
170
+ // Also check for all same answer
171
+ const answers = scaleResponses.map(r => r.answer);
172
+ const uniqueAnswers = new Set(answers);
173
+ const uniqueRatio = uniqueAnswers.size / scaleResponses.length;
174
+
175
+ if (uniqueRatio < 0.15) {
176
+ return {
177
+ checkId: 'straight_line_answers',
178
+ passed: false,
179
+ score: 1.0,
180
+ details: 'Nearly all answers are identical',
181
+ data: { uniqueRatio, uniqueAnswers: uniqueAnswers.size, total: scaleResponses.length },
182
+ };
183
+ }
184
+
185
+ if (maxConsecutive >= 5 || uniqueRatio < 0.25) {
186
+ return {
187
+ checkId: 'straight_line_answers',
188
+ passed: false,
189
+ score: 0.8,
190
+ details: 'Strong straight-lining pattern detected',
191
+ data: { maxConsecutive, uniqueRatio },
192
+ };
193
+ }
194
+
195
+ if (maxConsecutive >= 4) {
196
+ return {
197
+ checkId: 'straight_line_answers',
198
+ passed: true,
199
+ score: 0.4,
200
+ details: 'Some consecutive identical answers',
201
+ data: { maxConsecutive },
202
+ };
203
+ }
204
+
205
+ return {
206
+ checkId: 'straight_line_answers',
207
+ passed: true,
208
+ score: 0,
209
+ data: { maxConsecutive, uniqueRatio },
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Check: Excessive Tab Switching
215
+ *
216
+ * Detects frequent tab/window changes which might indicate:
217
+ * - Looking up answers
218
+ * - Using AI to generate responses
219
+ * - Distracted/inattentive respondent
220
+ */
221
+ export function checkExcessiveTabSwitching(metrics?: BehavioralMetrics): CheckResult {
222
+ if (!metrics) {
223
+ return {
224
+ checkId: 'excessive_tab_switching',
225
+ passed: true,
226
+ score: 0,
227
+ details: 'No behavioral data',
228
+ };
229
+ }
230
+
231
+ const { tabSwitchCount, duration } = metrics;
232
+ const durationMinutes = (duration || 1) / 60000;
233
+
234
+ // Calculate switches per minute
235
+ const switchesPerMinute = tabSwitchCount / durationMinutes;
236
+
237
+ if (switchesPerMinute > 5) {
238
+ return {
239
+ checkId: 'excessive_tab_switching',
240
+ passed: false,
241
+ score: 0.8,
242
+ details: 'Excessive tab switching detected',
243
+ data: { switchesPerMinute, tabSwitchCount },
244
+ };
245
+ }
246
+
247
+ if (switchesPerMinute > 2) {
248
+ return {
249
+ checkId: 'excessive_tab_switching',
250
+ passed: true,
251
+ score: 0.4,
252
+ details: 'Above average tab switching',
253
+ data: { switchesPerMinute },
254
+ };
255
+ }
256
+
257
+ return {
258
+ checkId: 'excessive_tab_switching',
259
+ passed: true,
260
+ score: 0,
261
+ data: { switchesPerMinute },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Check: Window Focus Loss
267
+ *
268
+ * Detects extended periods where the survey was not in focus.
269
+ */
270
+ export function checkWindowFocusLoss(metrics?: BehavioralMetrics): CheckResult {
271
+ if (!metrics) {
272
+ return {
273
+ checkId: 'window_focus_loss',
274
+ passed: true,
275
+ score: 0,
276
+ details: 'No behavioral data',
277
+ };
278
+ }
279
+
280
+ const { totalBlurDuration, duration } = metrics;
281
+ const blurRatio = totalBlurDuration / (duration || 1);
282
+
283
+ if (blurRatio > 0.6) {
284
+ return {
285
+ checkId: 'window_focus_loss',
286
+ passed: false,
287
+ score: 0.8,
288
+ details: 'Majority of time spent away from survey',
289
+ data: { blurRatio, totalBlurDuration },
290
+ };
291
+ }
292
+
293
+ if (blurRatio > 0.3) {
294
+ return {
295
+ checkId: 'window_focus_loss',
296
+ passed: true,
297
+ score: 0.4,
298
+ details: 'Significant time away from survey',
299
+ data: { blurRatio },
300
+ };
301
+ }
302
+
303
+ return {
304
+ checkId: 'window_focus_loss',
305
+ passed: true,
306
+ score: 0,
307
+ data: { blurRatio },
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Check for duplicate/copy-paste patterns across responses
313
+ */
314
+ export function checkDuplicateResponses(responses: ResponseInput[]): CheckResult {
315
+ const textResponses = responses
316
+ .filter(r => r.answer.length > 20)
317
+ .map(r => r.answer.toLowerCase().trim());
318
+
319
+ if (textResponses.length < 2) {
320
+ return {
321
+ checkId: 'minimal_effort', // Use existing check ID
322
+ passed: true,
323
+ score: 0,
324
+ };
325
+ }
326
+
327
+ // Check for identical or near-identical responses
328
+ let duplicateCount = 0;
329
+ for (let i = 0; i < textResponses.length; i++) {
330
+ for (let j = i + 1; j < textResponses.length; j++) {
331
+ if (textResponses[i] === textResponses[j]) {
332
+ duplicateCount++;
333
+ }
334
+ }
335
+ }
336
+
337
+ if (duplicateCount > 0) {
338
+ const possiblePairs = (textResponses.length * (textResponses.length - 1)) / 2;
339
+ const duplicateRatio = duplicateCount / possiblePairs;
340
+
341
+ if (duplicateRatio > 0.3) {
342
+ return {
343
+ checkId: 'minimal_effort',
344
+ passed: false,
345
+ score: 0.7,
346
+ details: 'Multiple identical responses detected',
347
+ data: { duplicateCount, duplicateRatio },
348
+ };
349
+ }
350
+ }
351
+
352
+ return {
353
+ checkId: 'minimal_effort',
354
+ passed: true,
355
+ score: 0,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Run all content checks
361
+ */
362
+ export function runContentChecks(
363
+ responses: ResponseInput[],
364
+ metrics?: BehavioralMetrics
365
+ ): CheckResult[] {
366
+ return [
367
+ checkMinimalEffort(responses),
368
+ checkStraightLining(responses),
369
+ checkExcessiveTabSwitching(metrics),
370
+ checkWindowFocusLoss(metrics),
371
+ ];
372
+ }