reedach-server 0.3.3 → 0.4.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.
package/README.md CHANGED
@@ -92,7 +92,9 @@ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=h
92
92
  нажмёт на слово.
93
93
 
94
94
  У подсказок по стилю может быть поле `replace` — варианты замены, уже поставленные в форму
95
- найденного слова: «релевантных» → `["подходящих", "нужных", "уместных"]`.
95
+ найденного слова: «релевантных» → `["подходящих", "нужных", "уместных"]`, «оказываем
96
+ содействие» → `["помогаем"]`, «скриншоты» → `["снимки экрана"]`. Поле `edit` — готовая
97
+ правка шире фрагмента или «Убрать»; как её применить, написано в README пакета `reedach`.
96
98
 
97
99
  **`POST /v1/suggest`** — `{"words": ["превет"]}` → `{"suggestions": {"превет": ["привет", …]}}`,
98
100
  до 20 слов за раз.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reedach-server",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
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.3"
44
+ "reedach": "0.4.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "reedach-widget": "^0.3.3"
47
+ "reedach-widget": "^0.4.0"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "reedach-widget": {
package/src/check.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import {checkStyle,sourceBlocks,textBlocks,mergeIssues,RULES,BUILTIN,CATEGORIES} from 'reedach';
2
- import {morphCheck,MORPH_RULES,inflectReplacements} from './morph.mjs';
2
+ import {morphCheck,MORPH_RULES,inflectReplacements,formReplacements} from './morph.mjs';
3
3
  import {words,known,misspelled,Suggester} from './spell.mjs';
4
4
  // One check = spelling (hunspell) + morphology (Az.js) + dictionary style hints (reedach core), merged into one list.
5
5
  export const CHECKS=['spelling','morph','style'];
@@ -28,6 +28,7 @@ export async function check(input,{suggester:sg=suggester(),misspelled:ms=misspe
28
28
  checks.has('morph')?morphCheck(blocks).catch(()=>[]):[],
29
29
  ]);
30
30
  const local=checks.has('style')?await inflectReplacements(checkStyle(blocks),blocks).catch(()=>checkStyle(blocks)):[];
31
+ await formReplacements([...local,...morph],blocks).catch(()=>{});
31
32
  const issues=mergeIssues(blocks,{spelling,style:morph,local}).filter(it=>!disable.has(it.id)&&!disable.has(it.category));
32
33
  return {issues:blocksInput?issues.sort((a,b)=>a.block-b.block||a.start-b.start):issues.map(({block,...it})=>it)};
33
34
  }
package/src/forms.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // The first variant of a hint in the found word's form (moved from the Yokey keyboard, forms.js): the core gives
2
+ // these hints as an advice — variants in the dictionary form («оказываем содействие» → «Сказать проще: помогать») or
3
+ // variants that are phrases («скриншоты» → «снимок экрана»). Here the variant takes the found word's form on the same
4
+ // morphology as inflectReplacements: «помогаем», «снимки экрана». It goes to `replace` of a hint that has none.
5
+ const L='A-Za-zА-Яа-яЁё';
6
+ const norm=s=>s.toLowerCase().replace(/ё/g,'е');
7
+ const FAMILY={INFN:'verb',VERB:'verb',ADJF:'adj',NOUN:'noun'};
8
+ // [rule, key, variants, flags]. The key as in the rules: words, * for any ending. Of the variants the first of the found
9
+ // word's part of speech (and aspect, for a verb) is taken; $2 is the second word found, as written. Flags: gent - a
10
+ // word in the genitive after the fragment depends on its noun and would need another case after the verb («производим
11
+ // оплату заказа» is not «оплачиваем заказа»): no variant then; w2 - the form is the second word's; fixed - the variant
12
+ // as it is, for the found form written in the key (words the dictionary does not know). A noun in the singular is
13
+ // replaced only by one of its gender: the predicate and the adjectives around agree with it («коллаба удалась» is not
14
+ // «совместный проект удалась»), so «коллаба», «коллаборация» stay an advice.
15
+ // The phrases of bureaucratese-turns come as weak-verb-noun too (the server's hint replaces the dictionary's there).
16
+ const FORMS=[
17
+ ['bureaucratese-turns','надлежащ*',['правильный']],
18
+ ['bureaucratese-turns','приобре*',['купить','покупать','покупка']],
19
+ ['bureaucratese-turns','реализ* продукци*',['продать','продавать'],'gent'],
20
+ ['bureaucratese-turns','реализац* товар*',['продажа $2']],
21
+ ['bureaucratese-turns','производ* оплат*',['оплатить','оплачивать'],'gent'],
22
+ ['bureaucratese-turns','производ* замер*',['замерить','замерять'],'gent'],
23
+ ['bureaucratese-turns','производ* расчет*',['рассчитать','рассчитывать'],'gent'],
24
+ ['bureaucratese-turns','оказыва* содействие',['помогать']],
25
+ ['bureaucratese-turns','оказыва* влияние',['влиять']],
26
+ ['bureaucratese-turns','предоставля* возможность',['позволять'],'gent'],
27
+ ['bureaucratese-turns','предоставля* скидк*',['давать $2']],
28
+ ['bureaucratese-turns','довод* до сведения',['сообщать'],'gent'],
29
+ ['bureaucratese-turns','информиру* вас',['сообщать']],
30
+ ['bureaucratese-turns','ввод* в эксплуатацию',['запуск','запустить','запускать']],
31
+ ['stamp-newspaper-idioms','финальн* аккорд*',['финал'],'w2'],
32
+ ['stamp-newspaper-idioms','тернист* пут*',['трудный путь'],'w2'],
33
+ ['bureaucratese-latinisms','синерги*',['совместная польза']],
34
+ ['borrowing-business','контент-план*',['план публикаций']],
35
+ ['borrowing-business','тимлид*',['руководитель группы']],
36
+ ['borrowing-business','роадмап*',['дорожная карта']],
37
+ ['borrowing-business','хард скилл*',['профессиональные навыки'],'w2'],
38
+ ['borrowing-business','софт скилл*',['гибкие навыки'],'w2'],
39
+ ['borrowing-business','багфикс*',['исправление ошибки']],
40
+ ['borrowing-business','скриншот*',['снимок экрана']],
41
+ ['borrowing-business','скрин',['снимок экрана']],
42
+ ['borrowing-business','дашборд*',['панель показателей']],
43
+ ['borrowing-business','вишлист*',['список желаний']],
44
+ ['borrowing-business','онбординг*',['вхождение в должность']],
45
+ ['borrowing-business','бэклог*',['список задач']],
46
+ ['borrowing-business','скоуп*',['объем работ']],
47
+ ['borrowing-business','стейкхолдер*',['заинтересованная сторона']],
48
+ ['borrowing-business','чекпоинт*',['контрольная точка']],
49
+ ['borrowing-business','хедхантинг*',['переманивание специалистов']],
50
+ ['borrowing-business','хедхантер*',['охотник за кадрами']],
51
+ ['borrowing-business','брейншторм*',['мозговой штурм']],
52
+ ['borrowing-business','брейнсторм*',['мозговой штурм']],
53
+ ['borrowing-business','лидген*',['привлечение клиентов']],
54
+ ['borrowing-business','рефералк*',['приглашение друга']],
55
+ ['borrowing-business','реферал*',['приглашенный пользователь']],
56
+ ['borrowing-business','экшен-план*',['план действий']],
57
+ ['borrowing-business','вейтлист*',['лист ожидания']],
58
+ ['slang-register','мастхэв*',['обязательная вещь']],
59
+ ['slang-register','коллаб',['совместный проект'],'fixed'],
60
+ ['slang-register','коллабом',['совместным проектом'],'fixed'],
61
+ ].map(([rule,key,variants,flag])=>({rule,key,variants,flag,re:new RegExp('^'+key.split(' ').map(t=>t.endsWith('*')?t.slice(0,-1)+'['+L+'-]*':t).join('\\s+')+'$')}));
62
+
63
+ const best=(Az,word)=>{try{return word.length<=40?Az.Morph(word).filter(p=>p.parser==='Dictionary'||p.parser==='PrefixKnown'):[];}catch{return [];}};
64
+ const want=(tag,keys)=>keys.map(k=>tag[k]).filter(Boolean);
65
+
66
+ // The variant [v] in the form of [p] (a parse of the found word): its first word of p's family is inflected, the
67
+ // adjectives before it (a noun's) agree with it, the rest stays. null: it does not fit or does not inflect.
68
+ function inflected(Az,p,v){
69
+ const fam=FAMILY[p.tag.POS],words=v.split(' ');
70
+ for(let h=0;h<words.length;h++){
71
+ // «покупать» is a perfective too («покупать детей»): the parse of the found word's aspect first
72
+ const ts=best(Az,words[h]).filter(x=>FAMILY[x.tag.POS]===fam),t=ts.find(x=>x.tag.ASpc===p.tag.ASpc)||ts[0];if(!t)continue;
73
+ if(fam==='verb'&&p.tag.ASpc&&t.tag.ASpc&&p.tag.ASpc!==t.tag.ASpc)return null;
74
+ let head,num;
75
+ if(p.tag.POS==='INFN')head=t.normalize()?.word;
76
+ else if(fam==='verb')head=t.inflect([...want(p.tag,['NMbr','TEns','PErs','GNdr','MOod','INvl']),'VERB'])?.word;
77
+ else if(fam==='adj')head=t.inflect(want(p.tag,['NMbr','CAse','GNdr','ANim']))?.word;
78
+ // num: the number the head got (the found word's, or its own when it has no such form)
79
+ else{const r=t.inflect(want(p.tag,['NMbr','CAse']));head=(r||t.inflect(want(p.tag,['CAse'])))?.word;num=r?p.tag.NMbr:t.tag.NMbr;}
80
+ if(fam==='noun'&&p.tag.NMbr!=='plur'&&p.tag.GNdr&&t.tag.GNdr&&p.tag.GNdr!==t.tag.GNdr)return null;
81
+ // the accusative of another animacy is another form («увидели стейкхолдеров» is not «заинтересованных сторон»)
82
+ if(fam==='noun'&&(p.tag.CAse==='accs'||p.tag.CAse==='gent')&&p.tag.ANim&&t.tag.ANim&&p.tag.ANim!==t.tag.ANim)return null;
83
+ if(!head)return null;
84
+ const out=words.slice();out[h]=head;
85
+ if(fam==='noun'){
86
+ for(let a=0;a<h;a++){
87
+ const at=best(Az,words[a]).find(x=>x.tag.POS==='ADJF');if(!at)return null;
88
+ const g=[num,p.tag.CAse];if(num!=='plur'&&t.tag.GNdr)g.push(t.tag.GNdr);if(p.tag.CAse==='accs'&&t.tag.ANim)g.push(t.tag.ANim);
89
+ const f=at.inflect(g.filter(Boolean))?.word;if(!f)return null;out[a]=f;
90
+ }
91
+ }
92
+ return out.join(' ');
93
+ }
94
+ return null;
95
+ }
96
+
97
+ const GENITIVE=new Set(['NOUN','ADJF','PRTF','NPRO','NUMR']);
98
+ const PARTICLES=new Set(['только','именно','лишь','даже','еще','уже','всего','ровно','почти','как','хотя','бы']);
99
+ // the first word after the fragment but particles and adverbs («только одного выбора», «именно этого заказа»)
100
+ function genitiveAfter(Az,text,end){
101
+ const re=new RegExp('^\\s+(['+L+']+)');
102
+ for(let rest=text.slice(end),m;(m=re.exec(rest));rest=rest.slice(m[0].length)){
103
+ const w=m[1].toLowerCase(),ps=best(Az,w);
104
+ if(PARTICLES.has(w)||ps.length&&ps.every(p=>p.tag.POS==='PRCL'||p.tag.POS==='ADVB'||p.tag.POS==='CONJ'))continue;
105
+ return ps.some(p=>GENITIVE.has(p.tag.POS)&&(p.tag.CAse==='gent'||p.tag.CAse==='gen2'));
106
+ }
107
+ return false;
108
+ }
109
+
110
+ // The gender of a noun the dictionary does not know, by its ending (borrowings: «дашборд», «рефералка»).
111
+ const guessed=w=>/[ая]$/.test(w)?'femn':/[оеи]$/.test(w)?'neut':'masc';
112
+ const headGender=(Az,v)=>{for(const w of v.split(' ')){const t=best(Az,w).find(x=>x.tag.POS==='NOUN');if(t)return t.tag.GNdr;}return null;};
113
+
114
+ // issues (block offsets) and their blocks; Az: Az.js initialized (morph.mjs) or the keyboard's AOT shim.
115
+ // as the core's ready edits: the fragment maps onto the source one to one, no Markdown or gap (a line break, code) inside
116
+ const plain=(b,s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&!(b.gaps||[]).some(j=>j>=s&&j<e)&&!b.text.slice(s,e).includes('\n');
117
+ // w2: the parses of the noun that agree with the adjective before it («тернистые пути» is the plural, not «пути» of
118
+ // the singular), all of them when none does
119
+ function agreeing(Az,parses,adjective){
120
+ const adj=best(Az,adjective).filter(x=>x.tag.POS==='ADJF'),fit=parses.filter(p=>adj.some(a=>a.tag.CAse===p.tag.CAse&&a.tag.NMbr===p.tag.NMbr));
121
+ return fit.length?fit:parses;
122
+ }
123
+ export const formWanted=it=>!it.replace&&!it.edit&&(it.id==='weak-verb-noun'||FORMS.some(f=>f.rule===it.id));
124
+ export function formReplacements(issues,blocks,Az){
125
+ for(const it of issues.filter(formWanted)){
126
+ const b=blocks[it.block],text=b?.text;if(typeof text!=='string'||!plain(b,it.start,it.end))continue;
127
+ const found=text.slice(it.start,it.end),words=found.split(/\s+/),low=norm(found).split(/\s+/).join(' ');
128
+ const f=FORMS.find(x=>(x.rule===it.id||it.id==='weak-verb-noun'&&x.rule==='bureaucratese-turns')&&x.re.test(low));if(!f)continue;
129
+ if(f.flag==='gent'&&genitiveAfter(Az,text,it.end))continue;
130
+ const variants=f.variants.map(v=>v.replace(/\$(\d)/g,(_,n)=>words[n-1]||''));
131
+ if(f.flag==='fixed'){it.replace=[variants[0]];continue;}
132
+ const carrier=norm(words[f.flag==='w2'?1:0]||'');
133
+ let form=null;
134
+ const parses=f.flag==='w2'?agreeing(Az,best(Az,carrier),norm(words[0])):best(Az,carrier);
135
+ for(const p of parses){
136
+ if(!FAMILY[p.tag.POS])continue;
137
+ for(const v of variants)if((form=inflected(Az,p,v)))break;
138
+ if(form)break;
139
+ }
140
+ // a word the dictionary does not know, written as in the key: the variant as it is (its dictionary form)
141
+ if(!form&&!best(Az,carrier).length&&!/\s/.test(f.key)&&carrier===f.key.replace('*','')&&headGender(Az,variants[0])===guessed(carrier))
142
+ form=variants[0];
143
+ if(form)it.replace=[form];
144
+ }
145
+ return issues;
146
+ }
package/src/morph.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import {createRequire} from 'node:module';
2
2
  import path from 'node:path';
3
+ import {formReplacements as forms,formWanted} from './forms.mjs';
3
4
  // Style hints that need parts of speech: Az.js with the OpenCorpora dictionaries, loaded once on first use.
4
5
  const require=createRequire(import.meta.url),Az=require('az'),DICTS=path.join(path.dirname(require.resolve('az/package.json')),'dicts');
5
6
  let ready=null;
@@ -133,3 +134,10 @@ export async function inflectReplacements(issues,blocks){
133
134
  for(const it of todo){const found=(blocks[it.block]?.text.slice(it.start,it.end)||'').split(/[\s-]+/).pop();if(/^[А-Яа-яЁё]+$/.test(found))it.replace=it.variants.map(w=>inflect(found,w));}
134
135
  return issues;
135
136
  }
137
+ // Hints from checkStyle and morphCheck on the same blocks that have no replacement: the variant of the hint in the
138
+ // found word's form (forms.mjs) becomes the replacement.
139
+ export async function formReplacements(issues,blocks){
140
+ if(!issues.some(formWanted))return issues;
141
+ await init();
142
+ return forms(issues,blocks,Az);
143
+ }