7add3b3
commit 7add3b3
5 files changed
+497 -5
Showing 5 changed files+497-5
▼opengrammar/backend/bench.ts
+171-0
| @@ -0,0 +1,171 @@ | ||
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,6 +4,7 @@ import { Groq } from 'groq-sdk'; | ||
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,7 +35,7 @@ export class RuleBasedAnalyzer { | ||
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,8 +57,12 @@ export class RuleBasedAnalyzer { | ||
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,6 +12,7 @@ import type { | ||
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,8 +99,11 @@ app.post('/analyze', async (c) => { | ||
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
| @@ -0,0 +1,221 @@ | ||
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,3 +536,94 @@ describe('False Positive Guards', () => { | ||
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 | |