reedach-server 0.3.2 → 0.3.3

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 (2) hide show
  1. package/package.json +3 -3
  2. package/src/morph.mjs +45 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reedach-server",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Сервер Reedach: орфография (hunspell), морфология (Az.js + OpenCorpora) и подсказки по стилю для русского текста — библиотекой или по HTTP",
5
5
  "license": "MIT",
6
6
  "author": "Dima Faller <dima@dimafaller.ru>",
@@ -41,10 +41,10 @@
41
41
  "az": "0.2.3",
42
42
  "dictionary-en": "4.0.0",
43
43
  "dictionary-ru": "3.0.0",
44
- "reedach": "0.3.2"
44
+ "reedach": "0.3.3"
45
45
  },
46
46
  "peerDependencies": {
47
- "reedach-widget": "^0.3.2"
47
+ "reedach-widget": "^0.3.3"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "reedach-widget": {
package/src/morph.mjs CHANGED
@@ -23,11 +23,53 @@ const RULES={
23
23
  'adjective-pile':{category:'adjective',label:'Прилагательное',message:'Несколько прилагательных подряд перегружают фразу.',fix:'Оставьте одно, самое точное, или замените фактом.'},
24
24
  'start-repeat':{category:'repetition',label:'Повтор',message:'Третье предложение подряд начинается с того же слова: текст звучит монотонно.',fix:'Начните одно из предложений иначе или объедините их.'},
25
25
  'lemma-repeat':{category:'repetition',label:'Повтор',message:'Одно и то же слово дважды в предложении.',fix:'Замените повтор местоимением или перестройте фразу.'},
26
+ 'ne-adjective':{category:'mistake',label:'Частая ошибка',message:'Не с прилагательным пишется слитно, если нет противопоставления.',fix:'Пишите слитно: несложные сайты. Раздельно — с противопоставлением: не сложные, а простые.'},
27
+ 'instrumental-case':{category:'mistake',label:'Частая ошибка',message:'После этого глагола нужен творительный падеж: заниматься чем?',fix:'Поставьте слово в творительный падеж: занимался настройкой сетей.'},
28
+ 'typo-real-word':{category:'mistake',label:'Частая ошибка',message:'Похоже на опечатку: слово есть в словаре, но здесь не к месту.',fix:'Проверьте слово: возможно, имелось в виду мой.'},
26
29
  };
27
30
  const WEAK=new Set(['осуществлять','осуществить','производить','произвести','проводить','провести','совершать','совершить','оказывать','оказать','выполнять','выполнить','обеспечивать','обеспечить','предоставлять','предоставить','принимать','принять','давать','дать']);
28
31
  const CHAIN_HEAD=new Set(['хотеть','мочь','начать','начинать','стать','пытаться','попытаться','собираться','решить','планировать']);
29
32
  const SKIP_REPEAT=new Set(['быть','мочь','это','этот','который','свой','весь','такой','тот','один','год','раз','так','как','что','когда','если','также','тоже','только','очень','уже','еще','ещё','друг','другой','какой','какой-то','каждый','самый','себя','чуть']);
30
33
  const NOMINAL=/(?:ние|тие|ство|ция|ка)$/;
34
+ // Verbs that take the instrumental: «занимался настройкой», not «занимался настройка».
35
+ const INSTRUMENTAL=new Set(['заниматься','увлекаться','интересоваться','пользоваться','руководить','управлять','владеть','гордиться','торговать','обладать','командовать','восхищаться','любоваться','дорожить']);
36
+ // Before these «не» negates the whole phrase or the predicate: «вовсе не сложные», «я не большой любитель».
37
+ const NE_KEEP=new Set(['еще','ещё','уже','пока','вовсе','отнюдь','далеко','совсем','ничуть','нисколько','ни','это','был','была','было','были','будет','будут','быть','является','являются','стал','стала','стали','оказался','оказалась','оказались']);
38
+ // Every parse of a word, for agreement checks; the best one alone often hides the right case.
39
+ const allCache=new Map();
40
+ function all(word){const key=word.toLowerCase();let hit=allCache.get(key);if(!hit){try{hit=key.length<=MAX_WORD?Az.Morph(key):[];}catch{hit=[];}if(allCache.size>50000)allCache.clear();allCache.set(key,hit);}return hit;}
41
+ // «не сложные сайты»: «не», a qualitative adjective and a noun it agrees with, no contrast around («а не сложные»).
42
+ function neAdjective(words,k,gap){
43
+ const [ne,adj,noun]=words.slice(k,k+3);
44
+ if(!noun||ne.w.toLowerCase()!=='не'||!/^\s+$/.test(gap(ne,adj))||!/^\s+$/.test(gap(adj,noun)))return false;
45
+ const prev=words[k-1];if(prev&&(NE_KEEP.has(prev.w.toLowerCase())||/^(?:а|но)$/i.test(prev.w)||prev.pos==='NPRO'))return false;
46
+ if(words.slice(k+3).some(w=>/^(?:а|но|зато|не|ни)$/i.test(w.w))||words.slice(Math.max(0,k-4),k).some(w=>/^(?:не|ни)$/i.test(w.w)))return false;
47
+ // Dictionary words only: a guess for «исключаетвозможного» or a typo is not an adjective.
48
+ const as=all(adj.w).filter(p=>p.parser==='Dictionary'&&p.tag.POS==='ADJF'&&p.tag.Qual&&!p.tag.Apro&&!p.tag.Anum&&!p.tag.Supr),ns=noun.w.length>1&&noun.pos==='NOUN'?all(noun.w).filter(p=>p.parser==='Dictionary'&&p.tag.POS==='NOUN'):[];
49
+ const cases=as.flatMap(a=>ns.filter(n=>a.tag.CAse===n.tag.CAse&&a.tag.NMbr===n.tag.NMbr&&(a.tag.NMbr==='plur'||a.tag.GNdr===n.tag.GNdr||n.tag['Ms-f'])).map(n=>n.tag.CAse));
50
+ if(!cases.length)return false;
51
+ // In the nominative or accusative with no verb before it in the clause the phrase may be a predicate: «заявления — не пустые слова».
52
+ let c=k;while(c>0&&!/[,;:—–-]/.test(gap(words[c-1],words[c])))c--;
53
+ return cases.some(x=>x!=='nomn'&&x!=='accs')||words.slice(c,k).some(w=>['VERB','INFN','GRND'].includes(w.pos));
54
+ }
55
+ // «занимался настройка»: a noun in the nominative right after a verb that takes the instrumental, when the noun
56
+ // cannot be its subject («в зале занимаются дети» is right). Gives the noun in the instrumental, or null.
57
+ function instrumental(words,k,gap){
58
+ const verb=words[k],noun=words[k+1];
59
+ if(!noun||!(verb.pos==='VERB'&&INSTRUMENTAL.has(verb.lemma))||!/^\s+$/.test(gap(verb,noun)))return null;
60
+ if(words.slice(0,k).some(w=>w.cas==='ablt'))return null;
61
+ const ps=all(noun.w),n=ps.find(p=>p.tag.POS==='NOUN'&&p.tag.CAse==='nomn');
62
+ if(!n||ps.some(p=>p.tag.POS!=='NOUN'||p.parser!=='Dictionary')||ps.some(p=>p.tag.CAse==='ablt'||p.tag['Ms-f']))return null;
63
+ const v=all(verb.w).find(p=>p.tag.POS==='VERB')?.tag;if(!v)return null;
64
+ // A personal pronoun before the verb in the same clause is its subject already: «она занималась музыка».
65
+ let c=k;while(c>0&&!/[,;:—–-]/.test(gap(words[c-1],words[c])))c--;
66
+ const before=words.slice(c,k).some(w=>w.pos==='NPRO'&&w.cas==='nomn'&&/^(?:я|ты|он|она|оно|мы|вы|они)$/i.test(w.w));
67
+ const subject=!before&&v.MOod!=='impr'&&!['1per','2per'].includes(v.PErs)&&(v.NMbr==='plur'?n.tag.NMbr==='plur':n.tag.NMbr==='sing'&&(!v.GNdr||v.GNdr===n.tag.GNdr));
68
+ if(subject)return null;
69
+ try{return n.inflect(['ablt',n.tag.NMbr])?.word||null;}catch{return null;}
70
+ }
71
+ // «изменило пой подход»: the imperative of «петь» cannot follow a preposition or a verb (but «давай пой») and stand before a noun.
72
+ const poiTypo=(words,k,gap)=>{const [prev,w,next]=[words[k-1],words[k],words[k+1]];return !!(prev&&next&&w.w.toLowerCase()==='пой'&&/^\s+$/.test(gap(prev,w))&&/^\s+$/.test(gap(w,next))&&next.pos==='NOUN'&&(prev.pos==='PREP'||prev.pos==='INFN'||prev.pos==='VERB'&&!all(prev.w).some(p=>p.tag.MOod==='impr')));};
31
73
  const sentences=text=>[...text.matchAll(/[^.!?…]+(?:[.!?…]+|$)/g)].map(m=>({start:m.index,text:m[0]})).filter(s=>s.text.trim());
32
74
  // blocks: [{text, kind}] → issues [{block,start,end,id,category,label,message,fix}]
33
75
  export async function morphCheck(blocks){
@@ -44,6 +86,9 @@ export async function morphCheck(blocks){
44
86
  if(words[0].pos==='GRND'){const comma=s.text.indexOf(',',words[0].end-s.start);add(i,words[0].start,comma>0?s.start+comma:words[0].end,'gerund-start');}
45
87
  const prtf=words.filter(w=>w.pos==='PRTF');if(prtf.length>=2)add(i,prtf[1].start,prtf[1].end,'participle-stack');
46
88
  words.forEach((w,k)=>{
89
+ if(neAdjective(words,k,gap))issues.push({block:i,start:w.start,end:words[k+1].end,id:'ne-adjective',...RULES['ne-adjective'],replace:['не'+words[k+1].w.toLowerCase()]});
90
+ const ablt=instrumental(words,k,gap);if(ablt)issues.push({block:i,start:words[k+1].start,end:words[k+1].end,id:'instrumental-case',...RULES['instrumental-case'],replace:[ablt]});
91
+ if(poiTypo(words,k,gap))issues.push({block:i,start:w.start,end:w.end,id:'typo-real-word',...RULES['typo-real-word'],replace:['мой']});
47
92
  if(w.pos==='PRTS'&&w.score>=0.5&&!/^(?:был|была|было|были|будет|будут)$/i.test(words[k-1]?.w||''))add(i,w.start,w.end,'short-passive');
48
93
  if(w.pos==='VERB'&&WEAK.has(w.lemma)){const n=words.slice(k+1,k+3).find(x=>x.pos==='NOUN');if(n&&NOMINAL.test(n.lemma)&&n.lemma!=='участие'||n&&w.lemma.startsWith('при')&&n.lemma==='участие')add(i,w.start,n.end,'weak-verb-noun');}
49
94
  });