opengrammar-server 2.0.644277

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.
Files changed (53) hide show
  1. package/README.npm.md +95 -0
  2. package/bin/opengrammar-server.js +111 -0
  3. package/dist/server.js +48639 -0
  4. package/package.json +80 -0
  5. package/server-node.ts +159 -0
  6. package/server.ts +15 -0
  7. package/src/analyzer.ts +542 -0
  8. package/src/dictionary.ts +1973 -0
  9. package/src/index.ts +978 -0
  10. package/src/nlp/nlp-engine.ts +17 -0
  11. package/src/nlp/tone-analyzer.ts +269 -0
  12. package/src/rephraser.ts +146 -0
  13. package/src/rules/categories/academic-writing.ts +182 -0
  14. package/src/rules/categories/adjectives-adverbs.ts +152 -0
  15. package/src/rules/categories/articles.ts +160 -0
  16. package/src/rules/categories/business-writing.ts +250 -0
  17. package/src/rules/categories/capitalization.ts +79 -0
  18. package/src/rules/categories/clarity.ts +117 -0
  19. package/src/rules/categories/common-errors.ts +601 -0
  20. package/src/rules/categories/confused-words.ts +219 -0
  21. package/src/rules/categories/conjunctions.ts +176 -0
  22. package/src/rules/categories/dangling-modifiers.ts +123 -0
  23. package/src/rules/categories/formality.ts +274 -0
  24. package/src/rules/categories/formatting-idioms.ts +323 -0
  25. package/src/rules/categories/gerund-infinitive.ts +274 -0
  26. package/src/rules/categories/grammar-advanced.ts +294 -0
  27. package/src/rules/categories/grammar.ts +286 -0
  28. package/src/rules/categories/inclusive-language.ts +280 -0
  29. package/src/rules/categories/nouns-pronouns.ts +233 -0
  30. package/src/rules/categories/prepositions-extended.ts +217 -0
  31. package/src/rules/categories/prepositions.ts +159 -0
  32. package/src/rules/categories/punctuation.ts +347 -0
  33. package/src/rules/categories/quantity-agreement.ts +200 -0
  34. package/src/rules/categories/readability.ts +293 -0
  35. package/src/rules/categories/sentence-structure.ts +100 -0
  36. package/src/rules/categories/spelling-advanced.ts +164 -0
  37. package/src/rules/categories/spelling.ts +119 -0
  38. package/src/rules/categories/style-tone.ts +511 -0
  39. package/src/rules/categories/style.ts +78 -0
  40. package/src/rules/categories/subject-verb-agreement.ts +201 -0
  41. package/src/rules/categories/tone-rules.ts +206 -0
  42. package/src/rules/categories/verb-tense.ts +582 -0
  43. package/src/rules/context-filter.ts +446 -0
  44. package/src/rules/index.ts +96 -0
  45. package/src/rules/ruleset-part1-cj-pu-sp.json +657 -0
  46. package/src/rules/ruleset-part1-np-ad-aa-pr.json +831 -0
  47. package/src/rules/ruleset-part1-ss-vt.json +907 -0
  48. package/src/rules/ruleset-part2-cw-st-nf.json +318 -0
  49. package/src/rules/ruleset-part3-aw-bw-il-rd.json +161 -0
  50. package/src/rules/types.ts +79 -0
  51. package/src/shared-types.ts +152 -0
  52. package/src/spellchecker.ts +418 -0
  53. package/tsconfig.json +25 -0
@@ -0,0 +1,542 @@
1
+ import { Groq } from 'groq-sdk';
2
+ import OpenAI from 'openai';
3
+ import { parseNLP } from './nlp/nlp-engine.js';
4
+ import { filterRulesByContext, type WritingContext } from './rules/context-filter.js';
5
+ import { CORE_RULES } from './rules/index.js';
6
+ import type { AnalysisContext, CustomRule, Issue, LLMProvider } from './shared-types.js';
7
+ import { checkSpelling, SAFE_WORDS } from './spellchecker.js';
8
+
9
+ /**
10
+ * Past participles commonly used as adjectives.
11
+ * These should NOT be flagged as passive voice.
12
+ */
13
+ const ADJECTIVE_PARTICIPLES = new Set([
14
+ 'excited',
15
+ 'interested',
16
+ 'pleased',
17
+ 'surprised',
18
+ 'tired',
19
+ 'bored',
20
+ 'confused',
21
+ 'disappointed',
22
+ 'embarrassed',
23
+ 'frightened',
24
+ 'satisfied',
25
+ 'worried',
26
+ 'amazed',
27
+ 'concerned',
28
+ 'delighted',
29
+ 'determined',
30
+ 'exhausted',
31
+ 'fascinated',
32
+ 'relaxed',
33
+ 'shocked',
34
+ 'stressed',
35
+ 'required',
36
+ 'needed',
37
+ 'expected',
38
+ 'supposed',
39
+ 'complicated',
40
+ 'dedicated',
41
+ 'educated',
42
+ 'experienced',
43
+ 'limited',
44
+ 'married',
45
+ 'organized',
46
+ 'prepared',
47
+ 'qualified',
48
+ 'related',
49
+ 'retired',
50
+ 'scared',
51
+ 'skilled',
52
+ 'talented',
53
+ 'united',
54
+ 'advanced',
55
+ 'balanced',
56
+ 'broken',
57
+ 'closed',
58
+ 'combined',
59
+ 'connected',
60
+ 'convinced',
61
+ 'crowded',
62
+ 'damaged',
63
+ 'depressed',
64
+ 'detailed',
65
+ 'developed',
66
+ 'disabled',
67
+ 'engaged',
68
+ 'established',
69
+ 'fixed',
70
+ 'focused',
71
+ 'hidden',
72
+ 'improved',
73
+ 'increased',
74
+ 'involved',
75
+ 'isolated',
76
+ 'known',
77
+ 'located',
78
+ 'mixed',
79
+ 'motivated',
80
+ 'observed',
81
+ 'opened',
82
+ 'pleased',
83
+ 'preferred',
84
+ 'published',
85
+ 'recognized',
86
+ 'reduced',
87
+ 'registered',
88
+ 'renewed',
89
+ 'repeated',
90
+ 'reserved',
91
+ 'satisfied',
92
+ 'settled',
93
+ 'shared',
94
+ 'situated',
95
+ 'specialized',
96
+ 'supposed',
97
+ 'troubled',
98
+ 'updated',
99
+ 'used',
100
+ 'valued',
101
+ 'varied',
102
+ 'worried',
103
+ ]);
104
+
105
+ export class RuleBasedAnalyzer {
106
+ private static dictionary: Set<string> = new Set();
107
+ private static customRules: CustomRule[] = [];
108
+
109
+ static analyze(
110
+ text: string,
111
+ options?: {
112
+ dictionary?: string[];
113
+ customRules?: CustomRule[];
114
+ writingContext?: WritingContext;
115
+ disabledModules?: string[];
116
+ },
117
+ ): Issue[] {
118
+ const issues: Issue[] = [];
119
+
120
+ if (options?.dictionary) {
121
+ RuleBasedAnalyzer.dictionary = new Set(options.dictionary.map((w) => w.toLowerCase()));
122
+ }
123
+
124
+ if (options?.customRules) {
125
+ RuleBasedAnalyzer.customRules = options.customRules;
126
+ }
127
+
128
+ // Dictionary-based spell checking (unless spelling module is disabled)
129
+ const isSpellingDisabled = options?.disabledModules?.some(
130
+ (m) => m.toLowerCase() === 'spelling',
131
+ );
132
+ if (!isSpellingDisabled) {
133
+ issues.push(...checkSpelling(text, RuleBasedAnalyzer.dictionary));
134
+ }
135
+
136
+ // Initialize NLP Engine for Syntax Checks
137
+ let doc: any = null;
138
+ try {
139
+ doc = parseNLP(text);
140
+ } catch (e) {
141
+ console.warn('NLP Engine parsing disabled or failed:', e);
142
+ }
143
+
144
+ // Run Modular CORE RULES (filtered by writing context and manual overrides)
145
+ const activeRules =
146
+ options?.writingContext || options?.disabledModules
147
+ ? filterRulesByContext(
148
+ CORE_RULES,
149
+ options.writingContext || 'general',
150
+ options.disabledModules,
151
+ )
152
+ : CORE_RULES;
153
+
154
+ for (const rule of activeRules) {
155
+ try {
156
+ if (rule.type === 'regex') {
157
+ issues.push(...rule.check(text));
158
+ } else if (rule.type === 'nlp' && doc) {
159
+ issues.push(...rule.check(text, doc));
160
+ }
161
+ } catch (e) {
162
+ console.error(`Rule ${rule.id} failed:`, e);
163
+ }
164
+ }
165
+
166
+ // Custom Rules (Runtime injections)
167
+ issues.push(...RuleBasedAnalyzer.checkCustomRules(text));
168
+
169
+ // Deduplicate: when multiple rules flag the same text span,
170
+ // keep the highest-priority match (grammar > spelling > clarity > style)
171
+ return RuleBasedAnalyzer.deduplicateIssues(issues);
172
+ }
173
+
174
+ /**
175
+ * Remove duplicate issues that flag the same or overlapping text spans.
176
+ * Priority: grammar errors > spelling > clarity > style suggestions.
177
+ * When two issues share the same offset+length, keep the higher priority one.
178
+ * When one issue fully contains another, keep the more specific (shorter) one.
179
+ */
180
+ private static getPriority(type: string): number {
181
+ const PRIORITY: Record<string, number> = { grammar: 4, spelling: 3, clarity: 2, style: 1 };
182
+ return PRIORITY[type] || 0;
183
+ }
184
+
185
+ private static deduplicateIssues(issues: Issue[]): Issue[] {
186
+ const sorted = [...issues].sort((a, b) => {
187
+ if (a.offset !== b.offset) return a.offset - b.offset;
188
+ return RuleBasedAnalyzer.getPriority(b.type) - RuleBasedAnalyzer.getPriority(a.type);
189
+ });
190
+
191
+ const result: Issue[] = [];
192
+ const seenSpans = new Map<string, Issue>();
193
+
194
+ for (const issue of sorted) {
195
+ const spanKey = `${issue.offset}:${issue.length}`;
196
+
197
+ if (RuleBasedAnalyzer.handleExactSpanMatch(issue, spanKey, seenSpans, result)) continue;
198
+ if (RuleBasedAnalyzer.isIdenticalSuggestionDuplicate(issue, result)) continue;
199
+ if (RuleBasedAnalyzer.handleOverlappingSameText(issue, result)) continue;
200
+
201
+ result.push(issue);
202
+ seenSpans.set(spanKey, issue);
203
+ }
204
+
205
+ return result;
206
+ }
207
+
208
+ private static handleExactSpanMatch(
209
+ issue: Issue,
210
+ spanKey: string,
211
+ seenSpans: Map<string, Issue>,
212
+ result: Issue[],
213
+ ): boolean {
214
+ if (!seenSpans.has(spanKey)) return false;
215
+
216
+ const existing = seenSpans.get(spanKey)!;
217
+ if (RuleBasedAnalyzer.getPriority(issue.type) > RuleBasedAnalyzer.getPriority(existing.type)) {
218
+ const idx = result.indexOf(existing);
219
+ if (idx >= 0) result[idx] = issue;
220
+ seenSpans.set(spanKey, issue);
221
+ }
222
+ return true;
223
+ }
224
+
225
+ private static isIdenticalSuggestionDuplicate(issue: Issue, result: Issue[]): boolean {
226
+ const origKey = issue.original.toLowerCase().trim();
227
+ const dedupKey = `${origKey}→${(issue.suggestion || '').toLowerCase().trim()}`;
228
+
229
+ for (const seen of result) {
230
+ const seenDedupKey = `${seen.original.toLowerCase().trim()}→${(seen.suggestion || '').toLowerCase().trim()}`;
231
+ if (dedupKey === seenDedupKey && Math.abs(issue.offset - seen.offset) < 3) {
232
+ return true;
233
+ }
234
+ }
235
+ return false;
236
+ }
237
+
238
+ private static handleOverlappingSameText(issue: Issue, result: Issue[]): boolean {
239
+ const origKey = issue.original.toLowerCase().trim();
240
+
241
+ for (const seen of result) {
242
+ if (
243
+ seen.original.toLowerCase().trim() === origKey &&
244
+ Math.abs(issue.offset - seen.offset) <= issue.original.length
245
+ ) {
246
+ if (RuleBasedAnalyzer.getPriority(issue.type) > RuleBasedAnalyzer.getPriority(seen.type)) {
247
+ const idx = result.indexOf(seen);
248
+ if (idx >= 0) result[idx] = issue;
249
+ }
250
+ return true;
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+
256
+ private static checkCustomRules(text: string): Issue[] {
257
+ const issues: Issue[] = [];
258
+ for (const rule of RuleBasedAnalyzer.customRules) {
259
+ try {
260
+ const regex = new RegExp(rule.pattern, 'gi');
261
+ let match: RegExpExecArray | null;
262
+ while ((match = regex.exec(text)) !== null) {
263
+ issues.push({
264
+ type: rule.type,
265
+ original: match[0],
266
+ suggestion: rule.replacement,
267
+ reason: rule.description,
268
+ offset: match.index,
269
+ length: match[0].length,
270
+ id: rule.id,
271
+ });
272
+ }
273
+ } catch (e) {
274
+ console.warn('Invalid custom rule pattern:', rule.pattern, e);
275
+ }
276
+ }
277
+ return issues;
278
+ }
279
+ }
280
+
281
+ export class LLMAnalyzer {
282
+ static async analyze(
283
+ text: string,
284
+ apiKey: string,
285
+ model: string = 'gpt-3.5-turbo',
286
+ provider: LLMProvider = 'openai',
287
+ baseUrl?: string,
288
+ context?: AnalysisContext,
289
+ ruleIssues?: Issue[],
290
+ ): Promise<Issue[]> {
291
+ try {
292
+ let issues: any[] = [];
293
+
294
+ // Use Groq SDK for Groq provider
295
+ if (provider === 'groq') {
296
+ issues = await LLMAnalyzer.analyzeWithGroq(text, apiKey, model, context, ruleIssues);
297
+ } else {
298
+ // Use OpenAI SDK for other providers (OpenAI, OpenRouter, Together, Ollama, Custom)
299
+ issues = await LLMAnalyzer.analyzeWithOpenAI(
300
+ text,
301
+ apiKey,
302
+ model,
303
+ provider,
304
+ baseUrl,
305
+ context,
306
+ ruleIssues,
307
+ );
308
+ }
309
+
310
+ return issues;
311
+ } catch (error) {
312
+ console.error(`LLM Analysis Error (${provider}):`, error);
313
+ return [];
314
+ }
315
+ }
316
+
317
+ private static async analyzeWithGroq(
318
+ text: string,
319
+ apiKey: string,
320
+ model: string,
321
+ context?: AnalysisContext,
322
+ ruleIssues?: Issue[],
323
+ ): Promise<Issue[]> {
324
+ const groq = new Groq({ apiKey });
325
+
326
+ const { systemPrompt, userPrompt } = LLMAnalyzer.createGrammarPrompts(
327
+ text,
328
+ context,
329
+ ruleIssues,
330
+ );
331
+
332
+ const chatCompletion = await groq.chat.completions.create({
333
+ messages: [
334
+ { role: 'system', content: systemPrompt },
335
+ { role: 'user', content: userPrompt },
336
+ ],
337
+ model: model,
338
+ response_format: { type: 'json_object' },
339
+ temperature: 0.2,
340
+ });
341
+
342
+ let content = chatCompletion.choices[0]?.message?.content;
343
+ if (!content) return [];
344
+
345
+ content = content.replace(/^```json\s*/, '').replace(/\s*```$/, '');
346
+ const result = JSON.parse(content);
347
+
348
+ return (result.issues || []).map((issue: any) => {
349
+ const index = text.indexOf(issue.original);
350
+ return {
351
+ ...issue,
352
+ offset: index !== -1 ? index : 0,
353
+ length: issue.original?.length || 0,
354
+ };
355
+ });
356
+ }
357
+
358
+ private static async analyzeWithOpenAI(
359
+ text: string,
360
+ apiKey: string,
361
+ model: string,
362
+ provider: LLMProvider,
363
+ baseUrl?: string,
364
+ context?: AnalysisContext,
365
+ ruleIssues?: Issue[],
366
+ ): Promise<Issue[]> {
367
+ const providerBaseUrl = baseUrl || LLMAnalyzer.getProviderBaseUrl(provider);
368
+
369
+ const openai = new OpenAI({
370
+ apiKey: apiKey || 'ollama',
371
+ baseURL: providerBaseUrl,
372
+ });
373
+
374
+ const { systemPrompt, userPrompt } = LLMAnalyzer.createGrammarPrompts(
375
+ text,
376
+ context,
377
+ ruleIssues,
378
+ );
379
+
380
+ const completion = await openai.chat.completions.create({
381
+ messages: [
382
+ { role: 'system', content: systemPrompt },
383
+ { role: 'user', content: userPrompt },
384
+ ],
385
+ model: model,
386
+ response_format: { type: 'json_object' },
387
+ temperature: 0.2,
388
+ });
389
+
390
+ let content = completion.choices[0]?.message?.content;
391
+ if (!content) return [];
392
+
393
+ content = content.replace(/^```json\s*/, '').replace(/\s*```$/, '');
394
+ const result = JSON.parse(content);
395
+
396
+ return (result.issues || []).map((issue: any) => {
397
+ const index = text.indexOf(issue.original);
398
+ return {
399
+ ...issue,
400
+ offset: index !== -1 ? index : 0,
401
+ length: issue.original?.length || 0,
402
+ };
403
+ });
404
+ }
405
+
406
+ private static getProviderBaseUrl(provider: LLMProvider): string {
407
+ const urls: Record<string, string> = {
408
+ openai: 'https://api.openai.com/v1',
409
+ openrouter: 'https://openrouter.ai/api/v1',
410
+ groq: 'https://api.groq.com/openai/v1',
411
+ together: 'https://api.together.xyz/v1',
412
+ ollama: 'http://localhost:11434/v1',
413
+ custom: '',
414
+ };
415
+ return urls[provider as string] ?? urls.openai;
416
+ }
417
+
418
+ /**
419
+ * Create structured, few-shot grammar prompts with domain awareness.
420
+ * Returns separate system and user prompts for better LLM instruction following.
421
+ */
422
+ private static createGrammarPrompts(
423
+ text: string,
424
+ context?: AnalysisContext,
425
+ ruleIssues?: Issue[],
426
+ ): { systemPrompt: string; userPrompt: string } {
427
+ // Detect writing domain from context
428
+ const domain = LLMAnalyzer.detectDomain(context);
429
+ const domainInstruction = LLMAnalyzer.getDomainInstruction(domain);
430
+
431
+ // Build the list of already-detected issues so LLM doesn't duplicate
432
+ const alreadyDetected =
433
+ ruleIssues && ruleIssues.length > 0
434
+ ? `\n\nALREADY DETECTED (do NOT report these again):\n${ruleIssues
435
+ .slice(0, 15)
436
+ .map((i) => `- "${i.original}" → "${i.suggestion}"`)
437
+ .join('\n')}`
438
+ : '';
439
+
440
+ const systemPrompt = `You are a professional copy editor and grammar expert. Your job is to find errors that automated rules might miss — contextual mistakes, awkward phrasing, unclear antecedents, and subtle grammar issues.
441
+
442
+ RULES:
443
+ 1. Report ONLY genuine errors. Do NOT flag valid informal English or stylistic choices.
444
+ 2. Every suggestion must be a concrete replacement, never vague advice like "consider rewording."
445
+ 3. Match the "original" field EXACTLY to a substring in the text.
446
+ 4. Maximum 8 issues per analysis. Prioritize: spelling > grammar > clarity > style.
447
+ 5. Do NOT repeat issues already detected by the rule engine.${alreadyDetected}
448
+ ${domainInstruction}
449
+
450
+ RETURN FORMAT: Valid JSON only.
451
+ {
452
+ "issues": [
453
+ {
454
+ "type": "grammar|spelling|clarity|style",
455
+ "original": "exact substring from text",
456
+ "suggestion": "concrete replacement",
457
+ "reason": "one-sentence explanation"
458
+ }
459
+ ]
460
+ }
461
+
462
+ EXAMPLE:
463
+ Input: "The team have decided to moves forward with there plan."
464
+ Output: {"issues":[{"type":"grammar","original":"team have","suggestion":"team has","reason":"'Team' is a collective noun treated as singular in American English."},{"type":"grammar","original":"to moves","suggestion":"to move","reason":"Infinitive verbs should use the base form."},{"type":"spelling","original":"there plan","suggestion":"their plan","reason":"'Their' (possessive) is needed here, not 'there' (location)."}]}
465
+
466
+ If there are no issues, return: {"issues": []}`;
467
+
468
+ // Build context block
469
+ const contextBlock = context
470
+ ? `\n\nCONTEXT:\n- Source: ${context.domain || 'unknown'} (${context.editorType || 'generic'})\n- Active sentence: ${context.activeSentence || 'n/a'}\n- Surrounding text: ${(context.previousText || '').slice(-100)}[CURSOR]${(context.nextText || '').slice(0, 100)}`
471
+ : '';
472
+
473
+ const userPrompt = `Analyze this text for grammar, spelling, clarity, and style issues:\n\n"""\n${text}\n"""${contextBlock}`;
474
+
475
+ return { systemPrompt, userPrompt };
476
+ }
477
+
478
+ /**
479
+ * Detect the writing domain from URL and editor type
480
+ */
481
+ private static detectDomain(context?: AnalysisContext): string {
482
+ if (!context?.domain) return 'general';
483
+ const d = context.domain.toLowerCase();
484
+ if (d.includes('mail.google') || d.includes('outlook') || d.includes('yahoo')) return 'email';
485
+ if (d.includes('docs.google') || d.includes('notion') || d.includes('overleaf'))
486
+ return 'document';
487
+ if (d.includes('github') || d.includes('stackoverflow') || d.includes('gitlab'))
488
+ return 'technical';
489
+ if (
490
+ d.includes('twitter') ||
491
+ d.includes('reddit') ||
492
+ d.includes('facebook') ||
493
+ d.includes('linkedin')
494
+ )
495
+ return 'social';
496
+ if (d.includes('slack') || d.includes('discord') || d.includes('teams')) return 'chat';
497
+ return 'general';
498
+ }
499
+
500
+ /**
501
+ * Get domain-specific instructions for the LLM
502
+ */
503
+ private static getDomainInstruction(domain: string): string {
504
+ const instructions: Record<string, string> = {
505
+ email:
506
+ '\nDOMAIN: Email. Focus on tone, professionalism, and brevity. Flag overly casual language in business emails. Ignore informal greetings.',
507
+ document:
508
+ '\nDOMAIN: Document/Essay. Focus on formal grammar, passive voice overuse, paragraph transitions, and academic clarity.',
509
+ technical:
510
+ '\nDOMAIN: Technical writing. Ignore code blocks and variable names. Check only prose. Be lenient with technical jargon.',
511
+ social:
512
+ '\nDOMAIN: Social media. Only flag clear spelling/grammar errors. Do NOT flag informal language, slang, or conversational tone.',
513
+ chat: '\nDOMAIN: Chat/messaging. Only flag obvious typos. Do NOT flag informal language or abbreviations.',
514
+ general: '',
515
+ };
516
+ return instructions[domain] || '';
517
+ }
518
+
519
+ static async getModels(provider: string, apiKey?: string, baseUrl?: string): Promise<string[]> {
520
+ try {
521
+ const providerBaseUrl = baseUrl || LLMAnalyzer.getProviderBaseUrl(provider as LLMProvider);
522
+
523
+ // For Ollama, use 'ollama' as dummy key
524
+ // For other providers, use provided key or empty string
525
+ const keyForRequest = provider === 'ollama' ? 'ollama' : apiKey || '';
526
+
527
+ const openai = new OpenAI({
528
+ apiKey: keyForRequest,
529
+ baseURL: providerBaseUrl,
530
+ });
531
+ const models = await openai.models.list();
532
+ return models.data.map((m) => m.id).slice(0, 50);
533
+ } catch (error) {
534
+ console.debug(
535
+ `Failed to fetch models for ${provider}:`,
536
+ error instanceof Error ? error.message : error,
537
+ );
538
+ // Return default models from config instead of failing
539
+ return [];
540
+ }
541
+ }
542
+ }