Swadhin Biswas

[email protected]
2026-03-28 08:26:53 +0600 committed NaN years ago
7add3b3
commit 7add3b3
5 files changed
+497 -5
▼opengrammar/backend/bench.ts
+171-0
1
+import { RuleBasedAnalyzer } from './src/analyzer.js';
2
+import { CORE_RULES } from './src/rules/index.js';
3
+
4
+/**
5
+ * ╔══════════════════════════════════════════════════╗
6
+ * ║ OpenGrammar Performance Benchmark ║
7
+ * ╚══════════════════════════════════════════════════╝
8
+ */
9
+
10
+// Realistic English text with intentional errors sprinkled in
11
+const BASE_TEXT = `The development team have been working extremely hard on the new product launch.
12
+Everyone are excited about the upcoming release, and the chairman said it will effect millions of users worldwide.
13
+We should of started earlier, but due to the fact that we had limited resources, the project was delayed.
14
+The team made a decision to utilize a new framework that would facilitate better performance.
15
+Some people say this approach is more better than the previous one. In my opinion, I think that we need to
16
+touch base with the stakeholders and circle back on the implementation details. For all intensive purposes,
17
+the system is working good. The ATM machine at the PIN number location needs a sneak peak review.
18
+She sings beautiful and plays good. The childs are gonna love this. Lemme know if you have questions btw.
19
+The fireman and policeman discussed the manpower needed. We must ameliorate the situation and ascertain the facts.
20
+Studies show that this is not uncommon. The elderly need support. I am writing to inform you that we need help.
21
+Please advise on the next steps. Its a mute point whether we should of went ahead with this plan.
22
+The weather will effect my mood. I will loose my keys if I am not careful about this.
23
+Notwithstanding the aforementioned issues, the project is approximately on schedule.
24
+The results demonstrate that our paradigm has proliferated and exacerbated the existing dichotomy.`;
25
+
26
+function generateText(targetWords: number): string {
27
+ const baseWords = BASE_TEXT.split(/\s+/).length;
28
+ const repeats = Math.ceil(targetWords / baseWords);
29
+ let text = '';
30
+ for (let i = 0; i < repeats; i++) {
31
+ text += BASE_TEXT + '\n\n';
32
+ }
33
+ // Trim to target word count
34
+ const words = text.split(/\s+/);
35
+ return words.slice(0, targetWords).join(' ');
36
+}
37
+
38
+function formatMs(ms: number): string {
39
+ if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
40
+ if (ms < 1000) return `${ms.toFixed(0)}ms`;
41
+ return `${(ms / 1000).toFixed(2)}s`;
42
+}
43
+
44
+function formatBytes(bytes: number): string {
45
+ if (bytes < 1024) return `${bytes}B`;
46
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
47
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
48
+}
49
+
50
+// Performance targets from the plan
51
+const TARGETS: Record<number, number> = {
52
+ 100: 50,
53
+ 500: 100,
54
+ 1000: 200,
55
+ 5000: 500,
56
+ 10000: 1000,
57
+};
58
+
59
+console.log(`\n╔══════════════════════════════════════════════════╗`);
60
+console.log(`║ OpenGrammar Performance Benchmark ║`);
61
+console.log(`╠══════════════════════════════════════════════════╣`);
62
+console.log(`║ Rules loaded: ${CORE_RULES.length.toString().padStart(4)} ║`);
63
+console.log(`╚══════════════════════════════════════════════════╝\n`);
64
+
65
+const sizes = [100, 500, 1000, 5000, 10000];
66
+const results: Array<{
67
+ words: number;
68
+ chars: number;
69
+ issues: number;
70
+ timeMs: number;
71
+ target: number;
72
+ passed: boolean;
73
+ rulesPerMs: number;
74
+}> = [];
75
+
76
+// Warm up (first run compiles regexes, loads dictionary, etc.)
77
+console.log('Warming up...');
78
+RuleBasedAnalyzer.analyze('This is a warm up sentence with gonna and buyed.');
79
+console.log('Warm-up complete.\n');
80
+
81
+for (const wordCount of sizes) {
82
+ const text = generateText(wordCount);
83
+ const actualWords = text.split(/\s+/).length;
84
+ const chars = text.length;
85
+
86
+ // Run 3 times, take the median
87
+ const times: number[] = [];
88
+ let issues = 0;
89
+
90
+ for (let run = 0; run < 3; run++) {
91
+ const start = performance.now();
92
+ const result = RuleBasedAnalyzer.analyze(text);
93
+ const elapsed = performance.now() - start;
94
+ times.push(elapsed);
95
+ issues = result.length;
96
+ }
97
+
98
+ times.sort((a, b) => a - b);
99
+ const medianMs = times[1]!; // median of 3 runs
100
+ const target = TARGETS[wordCount] || 1000;
101
+ const passed = medianMs <= target;
102
+
103
+ results.push({
104
+ words: actualWords,
105
+ chars,
106
+ issues,
107
+ timeMs: medianMs,
108
+ target,
109
+ passed,
110
+ rulesPerMs: CORE_RULES.length / medianMs,
111
+ });
112
+
113
+ const status = passed ? '✅ PASS' : '❌ FAIL';
114
+ console.log(`${status} ${actualWords.toString().padStart(6)} words | ${formatBytes(chars).padStart(8)} | ${formatMs(medianMs).padStart(8)} (target: ${formatMs(target)}) | ${issues} issues`);
115
+}
116
+
117
+// Summary table
118
+console.log(`\n${'─'.repeat(80)}`);
119
+console.log(`${'Words'.padStart(8)} | ${'Chars'.padStart(8)} | ${'Time'.padStart(8)} | ${'Target'.padStart(8)} | ${'Status'.padStart(8)} | Issues | Rules/ms`);
120
+console.log(`${'─'.repeat(80)}`);
121
+for (const r of results) {
122
+ console.log(
123
+ `${r.words.toString().padStart(8)} | ${formatBytes(r.chars).padStart(8)} | ${formatMs(r.timeMs).padStart(8)} | ${formatMs(r.target).padStart(8)} | ${(r.passed ? '✅ PASS' : '❌ FAIL').padStart(8)} | ${r.issues.toString().padStart(6)} | ${r.rulesPerMs.toFixed(1)}`
124
+ );
125
+}
126
+console.log(`${'─'.repeat(80)}`);
127
+
128
+const allPassed = results.every(r => r.passed);
129
+console.log(`\n${allPassed ? '🎉 ALL BENCHMARKS PASSED!' : '⚠️ SOME BENCHMARKS FAILED — optimization needed'}\n`);
130
+
131
+// Breakdown: measure individual component times for the largest document
132
+console.log('─── Component Breakdown (10K words) ───');
133
+const bigText = generateText(10000);
134
+
135
+// 1. Spellcheck only
136
+const spellStart = performance.now();
137
+const { checkSpelling } = await import('./src/spellchecker.js');
138
+checkSpelling(bigText);
139
+const spellTime = performance.now() - spellStart;
140
+console.log(` Spellchecker: ${formatMs(spellTime)}`);
141
+
142
+// 2. NLP parse only
143
+const nlpStart = performance.now();
144
+try {
145
+ const { NLPEngine } = await import('./src/nlp/nlp-engine.js');
146
+ NLPEngine.parse(bigText);
147
+} catch {}
148
+const nlpTime = performance.now() - nlpStart;
149
+console.log(` NLP Parse: ${formatMs(nlpTime)}`);
150
+
151
+// 3. Rules only (no spellcheck, no NLP)
152
+const rulesStart = performance.now();
153
+for (const rule of CORE_RULES) {
154
+ try {
155
+ if (rule.type === 'regex') rule.check(bigText);
156
+ } catch {}
157
+}
158
+const rulesTime = performance.now() - rulesStart;
159
+console.log(` Regex Rules: ${formatMs(rulesTime)}`);
160
+
161
+// 4. Deduplication (estimate)
162
+const allIssues = RuleBasedAnalyzer.analyze(bigText);
163
+const dedupStart = performance.now();
164
+// Re-run to measure dedup overhead
165
+RuleBasedAnalyzer.analyze(bigText);
166
+const totalTime = performance.now() - dedupStart;
167
+const dedupEstimate = totalTime - rulesTime - spellTime;
168
+console.log(` Dedup + Other: ${formatMs(Math.max(0, dedupEstimate))}`);
169
+console.log(` Total: ${formatMs(totalTime)}`);
170
+console.log(` Issues found: ${allIssues.length}`);
171
+console.log('');
0
172
▼opengrammar/backend/src/analyzer.ts
+8-3
4
4
import { checkSpelling, SAFE_WORDS } from './spellchecker.js';
5
5
import { NLPEngine } from './nlp/nlp-engine.js';
6
6
import { CORE_RULES } from './rules/index.js';
7
+import { filterRulesByContext, type WritingContext } from './rules/context-filter.js';
7
8
8
9
/**
9
10
* Past participles commonly used as adjectives.
34
35
private static dictionary: Set<string> = new Set();
35
36
private static customRules: CustomRule[] = [];
36
37
37
- static analyze(text: string, options?: { dictionary?: string[]; customRules?: CustomRule[] }): Issue[] {
38
+ static analyze(text: string, options?: { dictionary?: string[]; customRules?: CustomRule[]; writingContext?: WritingContext }): Issue[] {
38
39
const issues: Issue[] = [];
39
40
40
41
if (options?.dictionary) {
56
57
console.warn('NLP Engine parsing disabled or failed:', e);
57
58
}
58
59
59
- // Run Modular CORE RULES
60
- for (const rule of CORE_RULES) {
60
+ // Run Modular CORE RULES (filtered by writing context)
61
+ const activeRules = options?.writingContext
62
+ ? filterRulesByContext(CORE_RULES, options.writingContext)
63
+ : CORE_RULES;
64
+
65
+ for (const rule of activeRules) {
61
66
try {
62
67
if (rule.type === 'regex') {
63
68
issues.push(...rule.check(text));
64
69
▼opengrammar/backend/src/index.ts
+6-2
12
12
Issue,
13
13
} from './shared-types.js';
14
14
import { PROVIDERS } from './shared-types.js';
15
+import { detectWritingContext } from './rules/context-filter.js';
15
16
16
17
const app = new Hono();
17
18
98
99
return c.json({ error: 'Text exceeds maximum length of 50,000 characters' }, 413);
99
100
}
100
101
101
- // Run rule-based analysis (always)
102
- const ruleIssues = RuleBasedAnalyzer.analyze(text);
102
+ // Detect writing context for smart rule filtering
103
+ const writingContext = detectWritingContext(context?.domain);
104
+
105
+ // Run rule-based analysis with context-aware filtering
106
+ const ruleIssues = RuleBasedAnalyzer.analyze(text, { writingContext });
103
107
let issues = enrichIssues(ruleIssues, 'rule', text, context);
104
108
105
109
// Run LLM analysis if API key provided or using local Ollama
106
110
▼opengrammar/backend/src/rules/context-filter.ts
+221-0
1
+import type { Rule } from './types.js';
2
+
3
+/**
4
+ * ╔══════════════════════════════════════════════════════════════╗
5
+ * ║ Context-Aware Rule Filtering ║
6
+ * ║ Auto-detect document type and enable/disable rule modules ║
7
+ * ╚══════════════════════════════════════════════════════════════╝
8
+ *
9
+ * In casual chat, users don't want 814 rules nagging about "gonna".
10
+ * In academic papers, they want weasel word detection.
11
+ * This module maps writing context → active rule module prefixes.
12
+ */
13
+
14
+export type WritingContext =
15
+ | 'email' // Gmail, Outlook, Yahoo Mail
16
+ | 'document' // Google Docs, Notion, Overleaf, Word
17
+ | 'technical' // GitHub, StackOverflow, GitLab
18
+ | 'social' // Twitter/X, Reddit, Facebook, LinkedIn
19
+ | 'chat' // Slack, Discord, Teams, WhatsApp Web
20
+ | 'academic' // Academic papers (detected by content or domain like Overleaf)
21
+ | 'general'; // Default — full rule set
22
+
23
+/**
24
+ * Rule module prefixes — these are the first segment of each rule ID
25
+ * or the export name pattern from each category file.
26
+ * We use rule ID prefixes to classify which module a rule belongs to.
27
+ */
28
+const MODULE_PREFIXES: Record<string, string[]> = {
29
+ // Grammar core
30
+ 'grammar': ['SVA_', 'VT_', 'SS_', 'PU_', 'buyed', 'drived', 'catched', 'finded', 'goed', 'runned', 'swimmed', 'thinked', 'writed', 'speaked', 'breaked', 'choosed', 'drinked', 'eated', 'falled', 'feeled', 'flyed', 'growed', 'hided', 'holded', 'keeped', 'knowed', 'leaved', 'meaned', 'maked', 'payed', 'putted', 'readed', 'sayed', 'seed', 'selled', 'sended', 'singed', 'sitted', 'sleeped', 'spended', 'standed', 'stealed', 'taked', 'teached', 'telled', 'understanded', 'weared', 'winned', 'GR_'],
31
+ // Spelling
32
+ 'spelling': ['SP_', 'CW_'],
33
+ // Sentence structure
34
+ 'sentence-structure': ['SS_'],
35
+ // Verb tense
36
+ 'verb-tense': ['VT_', 'have_went', 'have_broke', 'have_ate', 'should_have_', 'could_have_', 'would_have_', 'must_have_', 'might_have_', 'may_have_'],
37
+ // Adjectives & adverbs
38
+ 'adjectives-adverbs': ['AA_'],
39
+ // Nouns & pronouns
40
+ 'nouns-pronouns': ['NP_'],
41
+ // Prepositions
42
+ 'prepositions': ['PR_'],
43
+ // Conjunctions / wordy
44
+ 'conjunctions': ['CJ_'],
45
+ // Punctuation
46
+ 'punctuation': ['PU_', 'dont', 'doesnt', 'didnt', 'cant', 'wont', 'isnt', 'arent', 'wasnt', 'werent', 'shouldnt', 'couldnt', 'wouldnt', 'hasnt', 'havent', 'hadnt', 'its_vs_', 'HYPH_'],
47
+ // Confused words
48
+ 'confused-words': ['CW_'],
49
+ // Style & tone
50
+ 'style-tone': ['ST_'],
51
+ // Formality
52
+ 'formality': ['FR_'],
53
+ // Formatting & idioms
54
+ 'formatting-idioms': ['IE_', 'NF_'],
55
+ // Capitalization
56
+ 'capitalization': ['CAP_'],
57
+ // Academic writing
58
+ 'academic-writing': ['AW_'],
59
+ // Business writing
60
+ 'business-writing': ['BW_'],
61
+ // Inclusive language
62
+ 'inclusive-language': ['IL_'],
63
+ // Readability
64
+ 'readability': ['RD_'],
65
+ // Clarity
66
+ 'clarity': ['CLR_'],
67
+ // Style base
68
+ 'style': ['STY_'],
69
+ // Grammar advanced
70
+ 'grammar-advanced': ['GR_'],
71
+ // Spelling advanced
72
+ 'spelling-advanced': ['intents-purposes', 'its-vs-its', 'their-vs-there', 'your-vs-youre', 'who-vs-whom'],
73
+};
74
+
75
+/**
76
+ * Which module groups are ENABLED per writing context.
77
+ * Modules not listed are DISABLED for that context.
78
+ */
79
+const CONTEXT_MODULES: Record<WritingContext, Set<string>> = {
80
+ chat: new Set([
81
+ 'grammar', 'spelling', 'confused-words', 'punctuation', 'spelling-advanced',
82
+ ]),
83
+
84
+ social: new Set([
85
+ 'grammar', 'spelling', 'confused-words', 'punctuation',
86
+ 'nouns-pronouns', 'verb-tense', 'spelling-advanced',
87
+ ]),
88
+
89
+ email: new Set([
90
+ 'grammar', 'spelling', 'confused-words', 'punctuation',
91
+ 'sentence-structure', 'verb-tense', 'nouns-pronouns',
92
+ 'adjectives-adverbs', 'prepositions', 'capitalization',
93
+ 'business-writing', 'formality', 'clarity',
94
+ 'style-tone', 'spelling-advanced', 'grammar-advanced',
95
+ 'formatting-idioms',
96
+ ]),
97
+
98
+ technical: new Set([
99
+ 'grammar', 'spelling', 'confused-words', 'punctuation',
100
+ 'sentence-structure', 'verb-tense', 'nouns-pronouns',
101
+ 'prepositions', 'capitalization', 'readability',
102
+ 'clarity', 'spelling-advanced', 'grammar-advanced',
103
+ ]),
104
+
105
+ document: new Set([
106
+ // Full check — all modules
107
+ ...Object.keys(MODULE_PREFIXES),
108
+ ]),
109
+
110
+ academic: new Set([
111
+ 'grammar', 'spelling', 'confused-words', 'punctuation',
112
+ 'sentence-structure', 'verb-tense', 'nouns-pronouns',
113
+ 'adjectives-adverbs', 'prepositions', 'conjunctions',
114
+ 'capitalization', 'academic-writing', 'readability',
115
+ 'style-tone', 'clarity', 'inclusive-language',
116
+ 'formatting-idioms', 'spelling-advanced', 'grammar-advanced',
117
+ ]),
118
+
119
+ general: new Set([
120
+ // Everything enabled
121
+ ...Object.keys(MODULE_PREFIXES),
122
+ ]),
123
+};
124
+
125
+/**
126
+ * Determine the module a rule belongs to based on its ID prefix.
127
+ */
128
+function getRuleModule(ruleId: string): string {
129
+ for (const [moduleName, prefixes] of Object.entries(MODULE_PREFIXES)) {
130
+ for (const prefix of prefixes) {
131
+ if (ruleId.startsWith(prefix)) {
132
+ return moduleName;
133
+ }
134
+ }
135
+ }
136
+ // Fallback: rules without a recognized prefix are always included
137
+ return 'general';
138
+}
139
+
140
+/**
141
+ * Filter rules based on writing context.
142
+ * Returns only the rules that are relevant for the given context.
143
+ */
144
+export function filterRulesByContext(rules: Rule[], context: WritingContext): Rule[] {
145
+ if (context === 'general' || context === 'document') {
146
+ return rules; // No filtering — all rules active
147
+ }
148
+
149
+ const enabledModules = CONTEXT_MODULES[context];
150
+ if (!enabledModules) return rules;
151
+
152
+ return rules.filter(rule => {
153
+ const module = getRuleModule(rule.id);
154
+ return module === 'general' || enabledModules.has(module);
155
+ });
156
+}
157
+
158
+/**
159
+ * Detect writing context from domain URL.
160
+ * This mirrors the detection in index.ts but returns our WritingContext type.
161
+ */
162
+export function detectWritingContext(domain?: string): WritingContext {
163
+ if (!domain) return 'general';
164
+ const d = domain.toLowerCase();
165
+
166
+ // Chat
167
+ if (d.includes('slack') || d.includes('discord') || d.includes('teams') ||
168
+ d.includes('whatsapp') || d.includes('telegram') || d.includes('messenger')) {
169
+ return 'chat';
170
+ }
171
+
172
+ // Social
173
+ if (d.includes('twitter') || d.includes('x.com') || d.includes('reddit') ||
174
+ d.includes('facebook') || d.includes('linkedin') || d.includes('instagram') ||
175
+ d.includes('threads') || d.includes('mastodon') || d.includes('bluesky')) {
176
+ return 'social';
177
+ }
178
+
179
+ // Email
180
+ if (d.includes('mail.google') || d.includes('outlook') || d.includes('yahoo') ||
181
+ d.includes('proton') || d.includes('zoho') || d.includes('fastmail')) {
182
+ return 'email';
183
+ }
184
+
185
+ // Academic
186
+ if (d.includes('overleaf') || d.includes('arxiv') || d.includes('scholar.google') ||
187
+ d.includes('academia.edu') || d.includes('researchgate')) {
188
+ return 'academic';
189
+ }
190
+
191
+ // Technical
192
+ if (d.includes('github') || d.includes('gitlab') || d.includes('stackoverflow') ||
193
+ d.includes('stackexchange') || d.includes('bitbucket') || d.includes('codepen') ||
194
+ d.includes('replit') || d.includes('jsfiddle') || d.includes('codesandbox')) {
195
+ return 'technical';
196
+ }
197
+
198
+ // Document
199
+ if (d.includes('docs.google') || d.includes('notion') || d.includes('coda.io') ||
200
+ d.includes('dropbox') || d.includes('quip') || d.includes('confluence') ||
201
+ d.includes('sharepoint') || d.includes('office.com') || d.includes('word')) {
202
+ return 'document';
203
+ }
204
+
205
+ return 'general';
206
+}
207
+
208
+/**
209
+ * Get a summary of how many rules are active per context.
210
+ * Useful for debugging and the API response.
211
+ */
212
+export function getContextSummary(rules: Rule[]): Record<WritingContext, number> {
213
+ const contexts: WritingContext[] = ['chat', 'social', 'email', 'technical', 'document', 'academic', 'general'];
214
+ const summary: Record<string, number> = {};
215
+
216
+ for (const ctx of contexts) {
217
+ summary[ctx] = filterRulesByContext(rules, ctx).length;
218
+ }
219
+
220
+ return summary as Record<WritingContext, number>;
221
+}
0
222
▼opengrammar/backend/tests/engine.test.ts
+91-0
536
536
expectNoIssue('She ran into the room.', 'ran into');
537
537
});
538
538
});
539
+
540
+// ══════════════════════════════════════════════════
541
+// Context-Aware Rule Filtering
542
+// ══════════════════════════════════════════════════
543
+describe('Context-Aware Filtering', () => {
544
+ const testText = 'Gonna go btw. He buyed a car. The chairman spoke. I think that this is true.';
545
+
546
+ test('general context catches everything', () => {
547
+ const issues = RuleBasedAnalyzer.analyze(testText, { writingContext: 'general' });
548
+ // Should catch: gonna, btw, buyed, chairman, "I think that"
549
+ expect(issues.length).toBeGreaterThanOrEqual(4);
550
+ });
551
+
552
+ test('chat context suppresses formality rules', () => {
553
+ const issues = RuleBasedAnalyzer.analyze(testText, { writingContext: 'chat' });
554
+ // Chat should still catch grammar errors (buyed → bought)
555
+ const grammarIssues = issues.filter(i => i.original.toLowerCase() === 'buyed');
556
+ expect(grammarIssues.length).toBeGreaterThanOrEqual(1);
557
+ // But should NOT flag "gonna" or "btw" (too informal for chat nagging)
558
+ const gonnaIssue = issues.find(i => i.original.toLowerCase() === 'gonna');
559
+ expect(gonnaIssue).toBeUndefined();
560
+ });
561
+
562
+ test('email context includes business rules', () => {
563
+ const emailText = 'I am writing to inform you that we need to facilitate the process.';
564
+ const issues = RuleBasedAnalyzer.analyze(emailText, { writingContext: 'email' });
565
+ // Should flag business writing issues
566
+ expect(issues.length).toBeGreaterThanOrEqual(1);
567
+ });
568
+
569
+ test('academic context includes weasel word detection', () => {
570
+ const academicText = 'Some people say this is obvious. Studies show it works.';
571
+ const issues = RuleBasedAnalyzer.analyze(academicText, { writingContext: 'academic' });
572
+ const weaselIssue = issues.find(i => i.original.toLowerCase().includes('some people'));
573
+ expect(weaselIssue).toBeDefined();
574
+ });
575
+
576
+ test('grammar errors are caught in ALL contexts', () => {
577
+ const contexts: Array<'chat' | 'social' | 'email' | 'technical' | 'general'> = [
578
+ 'chat', 'social', 'email', 'technical', 'general',
579
+ ];
580
+ for (const ctx of contexts) {
581
+ const issues = RuleBasedAnalyzer.analyze('He buyed a car.', { writingContext: ctx });
582
+ const buyedIssue = issues.find(i => i.original.toLowerCase() === 'buyed');
583
+ expect(buyedIssue).toBeDefined();
584
+ }
585
+ });
586
+
587
+ test('fewer rules active in chat vs general', () => {
588
+ const chatIssues = RuleBasedAnalyzer.analyze(testText, { writingContext: 'chat' });
589
+ const generalIssues = RuleBasedAnalyzer.analyze(testText, { writingContext: 'general' });
590
+ expect(chatIssues.length).toBeLessThan(generalIssues.length);
591
+ });
592
+});
593
+
594
+// ══════════════════════════════════════════════════
595
+// Context Detection
596
+// ══════════════════════════════════════════════════
597
+describe('Context Detection', () => {
598
+ // Import directly for unit testing
599
+ const { detectWritingContext } = require('../src/rules/context-filter.js');
600
+
601
+ test('detects chat context', () => {
602
+ expect(detectWritingContext('app.slack.com')).toBe('chat');
603
+ expect(detectWritingContext('discord.com')).toBe('chat');
604
+ });
605
+
606
+ test('detects email context', () => {
607
+ expect(detectWritingContext('mail.google.com')).toBe('email');
608
+ expect(detectWritingContext('outlook.live.com')).toBe('email');
609
+ });
610
+
611
+ test('detects social context', () => {
612
+ expect(detectWritingContext('twitter.com')).toBe('social');
613
+ expect(detectWritingContext('reddit.com')).toBe('social');
614
+ });
615
+
616
+ test('detects technical context', () => {
617
+ expect(detectWritingContext('github.com')).toBe('technical');
618
+ expect(detectWritingContext('stackoverflow.com')).toBe('technical');
619
+ });
620
+
621
+ test('detects academic context', () => {
622
+ expect(detectWritingContext('overleaf.com')).toBe('academic');
623
+ });
624
+
625
+ test('returns general for unknown domains', () => {
626
+ expect(detectWritingContext('random-site.com')).toBe('general');
627
+ expect(detectWritingContext(undefined)).toBe('general');
628
+ });
629
+});
539
630