chaffjs 0.10.0 → 0.11.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/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +2 -0
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -1
- package/dist/cli.js.map +1 -1
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +34 -2
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/detectors/en-shape.d.ts.map +1 -1
- package/dist/detectors/en-shape.js +110 -9
- package/dist/detectors/en-shape.js.map +1 -1
- package/dist/detectors/gram-place.d.ts +14 -0
- package/dist/detectors/gram-place.d.ts.map +1 -0
- package/dist/detectors/gram-place.js +39 -0
- package/dist/detectors/gram-place.js.map +1 -0
- package/dist/detectors/lexicon-match.d.ts +9 -0
- package/dist/detectors/lexicon-match.d.ts.map +1 -0
- package/dist/detectors/lexicon-match.js +40 -0
- package/dist/detectors/lexicon-match.js.map +1 -0
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +13 -20
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/phrase-match.d.ts.map +1 -1
- package/dist/detectors/phrase-match.js +7 -6
- package/dist/detectors/phrase-match.js.map +1 -1
- package/dist/detectors/repeated-head.d.ts.map +1 -1
- package/dist/detectors/repeated-head.js +15 -5
- package/dist/detectors/repeated-head.js.map +1 -1
- package/dist/detectors/sentence-ending.d.ts.map +1 -1
- package/dist/detectors/sentence-ending.js +7 -3
- package/dist/detectors/sentence-ending.js.map +1 -1
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +162 -22
- package/dist/detectors/signals.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +22 -9
- package/dist/document.js.map +1 -1
- package/dist/lexicon-tokens.d.ts +4 -0
- package/dist/lexicon-tokens.d.ts.map +1 -0
- package/dist/lexicon-tokens.js +23 -0
- package/dist/lexicon-tokens.js.map +1 -0
- package/dist/page-furniture.d.ts +10 -0
- package/dist/page-furniture.d.ts.map +1 -0
- package/dist/page-furniture.js +35 -0
- package/dist/page-furniture.js.map +1 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/render/compact.d.ts.map +1 -1
- package/dist/render/compact.js +11 -2
- package/dist/render/compact.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +1 -0
- package/dist/rule-load.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +5 -3
- package/dist/run.js.map +1 -1
- package/dist/sentence-shape.d.ts +6 -0
- package/dist/sentence-shape.d.ts.map +1 -1
- package/dist/sentence-shape.js +15 -0
- package/dist/sentence-shape.js.map +1 -1
- package/dist/structure/bare-numbers.d.ts +14 -0
- package/dist/structure/bare-numbers.d.ts.map +1 -0
- package/dist/structure/bare-numbers.js +53 -0
- package/dist/structure/bare-numbers.js.map +1 -0
- package/dist/structure/build.d.ts +2 -0
- package/dist/structure/build.d.ts.map +1 -1
- package/dist/structure/build.js +6 -3
- package/dist/structure/build.js.map +1 -1
- package/dist/structure/issues.d.ts.map +1 -1
- package/dist/structure/issues.js +11 -2
- package/dist/structure/issues.js.map +1 -1
- package/dist/structure/of.d.ts.map +1 -1
- package/dist/structure/of.js +4 -2
- package/dist/structure/of.js.map +1 -1
- package/dist/structure/total.d.ts +1 -0
- package/dist/structure/total.d.ts.map +1 -1
- package/dist/structure/total.js +37 -13
- package/dist/structure/total.js.map +1 -1
- package/dist/structure/universal.d.ts +2 -2
- package/dist/structure/universal.d.ts.map +1 -1
- package/dist/structure/universal.js +13 -4
- package/dist/structure/universal.js.map +1 -1
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +17 -2
- package/dist/version.js.map +1 -1
- package/package.json +3 -3
- package/rules/ai-tell.yaml +1 -0
- package/rules/closing-cliche.yaml +1 -0
- package/rules/concrete-evidence-density.yaml +2 -0
- package/rules/cushion-phrase-density.yaml +1 -0
- package/rules/double-keigo.yaml +1 -0
- package/rules/empty-intensifier.yaml +1 -0
- package/rules/excessive-hedging.yaml +1 -0
- package/rules/hiragana-fukushi.yaml +1 -0
- package/rules/max-kanji-continuous.yaml +2 -0
- package/rules/ngram-repetition.yaml +2 -0
- package/rules/padded-intro.yaml +1 -0
- package/rules/repeated-conjunction.yaml +1 -0
- package/rules/sasete-itadaku.yaml +1 -0
- package/rules/unqualified-superlative.yaml +1 -0
- package/src/cli-text.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/detectors/char-shape.ts +38 -3
- package/src/detectors/en-shape.ts +138 -7
- package/src/detectors/gram-place.ts +41 -0
- package/src/detectors/lexicon-match.ts +44 -0
- package/src/detectors/lexicon.ts +15 -22
- package/src/detectors/phrase-match.ts +8 -7
- package/src/detectors/repeated-head.ts +21 -7
- package/src/detectors/sentence-ending.ts +7 -3
- package/src/detectors/signals.ts +188 -21
- package/src/document.ts +25 -9
- package/src/lexicon-tokens.ts +28 -0
- package/src/page-furniture.ts +45 -0
- package/src/plugin.ts +9 -1
- package/src/render/compact.ts +12 -2
- package/src/rule-load.ts +1 -0
- package/src/run.ts +6 -3
- package/src/sentence-shape.ts +16 -0
- package/src/structure/bare-numbers.ts +78 -0
- package/src/structure/build.ts +9 -3
- package/src/structure/issues.ts +16 -2
- package/src/structure/of.ts +4 -2
- package/src/structure/total.ts +45 -19
- package/src/structure/universal.ts +14 -3
- package/src/version.ts +21 -3
package/rules/double-keigo.yaml
CHANGED
package/rules/padded-intro.yaml
CHANGED
package/src/cli-text.ts
CHANGED
|
@@ -12,6 +12,7 @@ const USAGE_JA = `chaff — 文章の読みにくいところを見つけます
|
|
|
12
12
|
chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
|
|
13
13
|
判定役は chaff.yaml の ai_backend で選びます
|
|
14
14
|
chaff init chaff.yaml を作る
|
|
15
|
+
chaff --version chaffjs と言語パッケージの版
|
|
15
16
|
chaff eval <dir> 手元の文書で閾値を測り直す
|
|
16
17
|
chaff explain <rule> そのルールの意図と根拠を読む
|
|
17
18
|
chaff genres ジャンルの一覧
|
|
@@ -45,6 +46,7 @@ const USAGE_EN = `chaff — finds what makes writing hard to read. It never rewr
|
|
|
45
46
|
chaff test <file|dir>... also run the checks that read meaning (needs an API key)
|
|
46
47
|
the judge is chosen by ai_backend in chaff.yaml
|
|
47
48
|
chaff init create chaff.yaml
|
|
49
|
+
chaff --version the version of chaffjs and its language packages
|
|
48
50
|
chaff eval <dir> re-measure the limits on your own documents
|
|
49
51
|
chaff explain <rule> read what a rule is for and why
|
|
50
52
|
chaff genres list the genres
|
package/src/cli.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { renderExplain } from "./render/explain.ts";
|
|
|
23
23
|
import { renderFriendly } from "./render/friendly.ts";
|
|
24
24
|
import { rulesJson } from "./render/rules-json.ts";
|
|
25
25
|
import { renderSarif } from "./render/sarif.ts";
|
|
26
|
-
import { VERSION } from "./version.ts";
|
|
26
|
+
import { VERSION, VERSION_LINES } from "./version.ts";
|
|
27
27
|
import { runTree, treeTargets, type TreeContext } from "./commands/tree.ts";
|
|
28
28
|
import { citeTargets, runCite } from "./commands/cite.ts";
|
|
29
29
|
import { runSkill } from "./commands/skill.ts";
|
|
@@ -328,6 +328,10 @@ export const main = async (argv: readonly string[]): Promise<number> => {
|
|
|
328
328
|
console.log(hostText(readConfig()).usage);
|
|
329
329
|
return first === undefined ? 1 : 0;
|
|
330
330
|
}
|
|
331
|
+
if (first === "--version" || first === "-v") {
|
|
332
|
+
console.log(VERSION_LINES.join("\n"));
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
331
335
|
const cliGenre = flag(argv, "--genre");
|
|
332
336
|
if (cliGenre !== undefined && !GENRES.includes(cliGenre)) {
|
|
333
337
|
console.error(hostText(readConfig()).unknownGenre(cliGenre));
|
|
@@ -1,15 +1,50 @@
|
|
|
1
|
-
import type { Detector, DocumentProfile, Finding, Sentence } from "../plugin.ts";
|
|
1
|
+
import type { Detector, DocumentProfile, Finding, Sentence, Token } from "../plugin.ts";
|
|
2
|
+
import { compacted, placeOf } from "./gram-place.ts";
|
|
2
3
|
import { maskAddresses } from "../address-chain.ts";
|
|
3
4
|
import { proseText } from "../measure.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* 文字の並びを見る検出。漢字の連なりが住所かどうかだけは、形態素解析の地名と数で決める。
|
|
7
8
|
* 覆った箇所は空白になっているので、proseText で潰してから数える。
|
|
8
9
|
*/
|
|
9
10
|
const KANJI_RUN = /[一-鿿]+/gu;
|
|
10
11
|
|
|
12
|
+
/** 地名(Geo)と、地名に付く単位(GeoUnit: 都・県・市・区・町)。 */
|
|
13
|
+
const isGeoName = (token: Token | undefined): boolean => token?.features?.["NameType"] === "Geo";
|
|
14
|
+
const isGeo = (token: Token | undefined): boolean => isGeoName(token) || token?.features?.["NameType"] === "GeoUnit";
|
|
15
|
+
const isNumber = (token: Token | undefined): boolean => token?.features?.["NumType"] === "Card";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 住所の語: 地名と地名の単位(NameType=Geo / GeoUnit)、数(NumType=Card)、数のすぐ後ろの助数詞(丁目、NounType=Class)。
|
|
19
|
+
* 辞書に無い町名(新橋)は、地名の後ろで数の前に来るときだけ住所の一部とする。
|
|
20
|
+
*/
|
|
21
|
+
const isPlacePart = (tokens: readonly Token[], index: number): boolean =>
|
|
22
|
+
isGeo(tokens[index]) ||
|
|
23
|
+
isNumber(tokens[index]) ||
|
|
24
|
+
(isNumber(tokens[index - 1]) && tokens[index]?.features?.["NounType"] === "Class") ||
|
|
25
|
+
(isGeo(tokens[index - 1]) && isNumber(tokens[index + 1]));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 漢字の連なりが住所か(東京都港区新橋二丁目)。形態素解析が地名と読んだ語で始まり、住所の語だけでできている。
|
|
29
|
+
* 住所は決まった形で、ひらがなを挟んで書けない。組織名(日本経済団体連合会)は地名の後ろに普通の語が続くので住所ではない。品詞が無ければ判定しない。
|
|
30
|
+
*/
|
|
31
|
+
const isPlaceName = (sentence: Sentence, run: string): boolean => {
|
|
32
|
+
const tokens = sentence.tokens;
|
|
33
|
+
const place = tokens === undefined ? undefined : placeOf(compacted(sentence.text, "word"), run);
|
|
34
|
+
if (tokens === undefined || place === undefined) return false;
|
|
35
|
+
const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
|
|
36
|
+
const covering = tokens.flatMap((token, index) => (token.span.start < end && start < token.span.end ? [index] : []));
|
|
37
|
+
const first = covering[0];
|
|
38
|
+
// 地名が単位を挟まずに続けば(東京大阪名古屋福岡)、住所ではなく地名の並び。
|
|
39
|
+
const listed = covering.some((index) => isGeoName(tokens[index]) && isGeoName(tokens[index + 1]));
|
|
40
|
+
return first !== undefined && isGeo(tokens[first]) && !listed && covering.every((index) => isPlacePart(tokens, index));
|
|
41
|
+
};
|
|
42
|
+
|
|
11
43
|
const longestKanji = (sentence: Sentence, profile: DocumentProfile | undefined): string =>
|
|
12
|
-
[...maskAddresses(proseText(sentence), profile).matchAll(KANJI_RUN)]
|
|
44
|
+
[...maskAddresses(proseText(sentence), profile).matchAll(KANJI_RUN)]
|
|
45
|
+
.map((match) => match[0])
|
|
46
|
+
.filter((run) => !isPlaceName(sentence, run))
|
|
47
|
+
.reduce((longest, run) => (run.length > longest.length ? run : longest), "");
|
|
13
48
|
|
|
14
49
|
/**
|
|
15
50
|
* 漢字が続くと、どこで語が切れるのか読み手が探すことになる。
|
|
@@ -150,6 +150,139 @@ export const titleCaseMix: Detector = (doc, options): Finding[] => {
|
|
|
150
150
|
|
|
151
151
|
const LIST_CONJUNCTION = new Set(["and", "or"]);
|
|
152
152
|
|
|
153
|
+
const isComma = (token: Token): boolean => token.surface === ",";
|
|
154
|
+
|
|
155
|
+
const commaBefore = (tokens: readonly Token[], at: number): boolean => tokens[at - 1]?.surface === ",";
|
|
156
|
+
|
|
157
|
+
/** 並列はこれをまたがない。セミコロンの前後は別の節。 */
|
|
158
|
+
const CLAUSE_BREAK = new Set([";", ":", "—"]);
|
|
159
|
+
|
|
160
|
+
/** 各 token の前で閉じていない括弧の数。括弧の中の読点(external users (e.g., guests), and ...)は外の並列を切らない。 */
|
|
161
|
+
const PAREN_STEP: Readonly<Record<string, number>> = { "(": 1, ")": -1 };
|
|
162
|
+
|
|
163
|
+
const depthsOf = (tokens: readonly Token[]): number[] =>
|
|
164
|
+
tokens.reduce<{ depths: number[]; open: number }>(
|
|
165
|
+
(acc, token) => ({ depths: [...acc.depths, acc.open], open: Math.max(0, acc.open + (PAREN_STEP[token.surface] ?? 0)) }),
|
|
166
|
+
{ depths: [], open: 0 },
|
|
167
|
+
).depths;
|
|
168
|
+
|
|
169
|
+
type Clause = { readonly tokens: readonly Token[]; readonly depths: readonly number[] };
|
|
170
|
+
|
|
171
|
+
/** 冠詞や引用符を飛ばした、項目の頭の品詞。the parser と an exporter と samples を同じ形と見る。 */
|
|
172
|
+
const NOMINAL = new Set(["NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
|
|
173
|
+
|
|
174
|
+
const isContent = (token: Token): boolean => token.pos !== "DET" && token.pos !== "PUNCT" && token.pos !== "X";
|
|
175
|
+
|
|
176
|
+
const shapeOf = (item: readonly Token[]): string | undefined => {
|
|
177
|
+
const head = item.find(isContent);
|
|
178
|
+
if (head === undefined) return undefined;
|
|
179
|
+
return NOMINAL.has(head.pos) ? "NOMINAL" : head.pos;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const VERBAL = new Set(["VERB", "AUX"]);
|
|
183
|
+
|
|
184
|
+
/** 主語と述語のある項目。the team fixed the bug / these are crucial。gets us more は述語だけ。 */
|
|
185
|
+
const isClause = (item: readonly Token[]): boolean => {
|
|
186
|
+
const first = item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
|
|
187
|
+
if (first === undefined || !(first.pos === "DET" || NOMINAL.has(first.pos))) return false;
|
|
188
|
+
return item.some((token) => token !== first && VERBAL.has(token.pos));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** 節の頭から and / or の手前までを、同じ深さの読点で項目に切る。Oxford comma の読点のあとは空なので項目にならない。 */
|
|
192
|
+
const itemsBefore = (clause: Clause, at: number): Token[][] => {
|
|
193
|
+
const level = clause.depths[at] ?? 0;
|
|
194
|
+
const start = clause.tokens.slice(0, at).findLastIndex((token) => CLAUSE_BREAK.has(token.surface)) + 1;
|
|
195
|
+
return clause.tokens
|
|
196
|
+
.slice(start, at)
|
|
197
|
+
.reduce<Token[][]>(
|
|
198
|
+
(items, token, offset) => {
|
|
199
|
+
if (isComma(token) && clause.depths[start + offset] === level) return [...items, []];
|
|
200
|
+
return [...items.slice(0, -1), [...(items.at(-1) ?? []), token]];
|
|
201
|
+
},
|
|
202
|
+
[[]],
|
|
203
|
+
)
|
|
204
|
+
.filter((item) => item.length > 0);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** and / or の後ろの項目。次の読点か節の切れ目まで。 */
|
|
208
|
+
const itemAfter = (tokens: readonly Token[], at: number): Token[] => {
|
|
209
|
+
const end = tokens.findIndex((token, index) => index > at && (isComma(token) || CLAUSE_BREAK.has(token.surface)));
|
|
210
|
+
return tokens.slice(at + 1, end === -1 ? undefined : end);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 導入の句(After the review, / If it fails, / Finally, / To test it, / Based on the review,)は並列の項目ではない。
|
|
215
|
+
* 節の最初の項目が前置詞・接続詞・副詞・to・過去分詞で始まり、次の項目と頭の形が違えば外す。
|
|
216
|
+
* Quickly, quietly and carefully は残る。For managers, engineers and designers, も外れるが、前置詞のあとの名詞を
|
|
217
|
+
* 項目と読むと、Over this period, the subcommittees and the full committee ... を並びと誤る。こちらのほうが多い。
|
|
218
|
+
*/
|
|
219
|
+
const LEAD_POS = new Set(["ADP", "SCONJ", "ADV", "PART"]);
|
|
220
|
+
|
|
221
|
+
const isParticiple = (token: Token): boolean => token.features?.["VerbForm"] === "Part";
|
|
222
|
+
|
|
223
|
+
const openingOf = (item: readonly Token[]): Token | undefined => item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
|
|
224
|
+
|
|
225
|
+
const openingKind = (item: readonly Token[]): string | undefined => {
|
|
226
|
+
const opening = openingOf(item);
|
|
227
|
+
return opening !== undefined && isParticiple(opening) ? "PARTICIPLE" : shapeOf(item);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const withoutLead = (items: readonly Token[][]): readonly Token[][] => {
|
|
231
|
+
const [first, second] = items;
|
|
232
|
+
const opening = first === undefined ? undefined : openingOf(first);
|
|
233
|
+
if (first === undefined || second === undefined || opening === undefined) return items;
|
|
234
|
+
if (!LEAD_POS.has(opening.pos) && !isParticiple(opening)) return items;
|
|
235
|
+
return openingKind(first) === openingKind(second) ? items : items.slice(1);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 形容詞のあとの読点は、名詞の前で形容詞を重ねているだけのことが多い(the long, winding bridge)。
|
|
240
|
+
* 次の項目が名詞で終わるならつなげ直す。形容詞そのものの並び(quick, cheap and reliable)は切ったまま。
|
|
241
|
+
*/
|
|
242
|
+
const lastContent = (item: readonly Token[]): Token | undefined => item.findLast(isContent);
|
|
243
|
+
|
|
244
|
+
const joinAdjectives = (items: readonly Token[][]): Token[][] =>
|
|
245
|
+
items.reduce<Token[][]>((joined, item) => {
|
|
246
|
+
const previous = joined.at(-1);
|
|
247
|
+
const stacked = previous !== undefined && lastContent(previous)?.pos === "ADJ" && lastContent(item)?.pos !== "ADJ";
|
|
248
|
+
return stacked ? [...joined.slice(0, -1), [...previous, ...item]] : [...joined, item];
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 節を並べるなら、どの項目も節。Additionally, others can learn, and the mistake is rarer. の
|
|
253
|
+
* Additionally は項目ではなく、and の前の読点は節をつなぐ読点。名詞の並びは最初の項目に前置き
|
|
254
|
+
* (We shipped the parser)を抱えるので、この確かめは節の並びだけにする。
|
|
255
|
+
* and の後ろだけが節なのは主語の並び(The parser, the renderer and the exporter shipped.)なので外さない。
|
|
256
|
+
*/
|
|
257
|
+
const clausesAgree = (items: readonly Token[][], after: readonly Token[]): boolean => {
|
|
258
|
+
const last = items.at(-1);
|
|
259
|
+
if (last === undefined || !isClause(last)) return true;
|
|
260
|
+
return isClause(after) && items.every(isClause);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 動詞で始まる項目を並べるなら、どの項目にも動詞がある。The scope, in contrast, is larger, and covers ... の
|
|
265
|
+
* The scope は項目ではなく、後ろの述語の主語。
|
|
266
|
+
*/
|
|
267
|
+
const hasVerb = (item: readonly Token[]): boolean => item.some((token) => VERBAL.has(token.pos));
|
|
268
|
+
|
|
269
|
+
const predicatesAgree = (items: readonly Token[][], shape: string): boolean => !VERBAL.has(shape) || items.every(hasVerb);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 読点で区切った項目が and / or の前に 2 つ以上あり、最後の項目と and の後ろが同じ形のときだけ並列。
|
|
273
|
+
* 導入の読点(After the review, the team fixed the bug and shipped it.)や、節をつなぐ読点
|
|
274
|
+
* (We tested it, and the team shipped it.)は、Oxford comma を打つかどうかの選択を見せない。
|
|
275
|
+
*/
|
|
276
|
+
const listAt = (clause: Clause, at: number): boolean | undefined => {
|
|
277
|
+
const items = withoutLead(joinAdjectives(itemsBefore(clause, at)));
|
|
278
|
+
const after = itemAfter(clause.tokens, at);
|
|
279
|
+
const last = items.at(-1);
|
|
280
|
+
const shape = last === undefined ? undefined : shapeOf(last);
|
|
281
|
+
if (items.length < 2 || shape === undefined || shape !== shapeOf(after)) return undefined;
|
|
282
|
+
if (!clausesAgree(items, after) || !predicatesAgree(items, shape)) return undefined;
|
|
283
|
+
return commaBefore(clause.tokens, at);
|
|
284
|
+
};
|
|
285
|
+
|
|
153
286
|
/**
|
|
154
287
|
* 3 つ以上の並列の最後の and / or の前に読点を打つか。Oxford comma。
|
|
155
288
|
*
|
|
@@ -157,13 +290,11 @@ const LIST_CONJUNCTION = new Set(["and", "or"]);
|
|
|
157
290
|
* 見るのは 1 つの文書で揃っているかだけ。spec §12.3。
|
|
158
291
|
*/
|
|
159
292
|
const oxfordIn = (tokens: readonly Token[]): boolean | undefined => {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (commas === 0) return undefined;
|
|
166
|
-
return before.at(-1)?.surface === ",";
|
|
293
|
+
const clause = { tokens, depths: depthsOf(tokens) };
|
|
294
|
+
return tokens.reduce<boolean | undefined>(
|
|
295
|
+
(found, token, at) => found ?? (at > 0 && LIST_CONJUNCTION.has(token.surface.toLowerCase()) ? listAt(clause, at) : undefined),
|
|
296
|
+
undefined,
|
|
297
|
+
);
|
|
167
298
|
};
|
|
168
299
|
|
|
169
300
|
export const oxfordComma: Detector = (doc, options): Finding[] => {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LengthUnit, Span } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** 数えるために詰めた文と、詰めた文の各文字が元の文のどこにあったか。 */
|
|
4
|
+
export type Compacted = { readonly text: string; readonly offsets: readonly number[] };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* n-gram を数えるときと同じ詰め方。word 単位は空白の並びを 1 つにして前後を削り、char 単位は空白を全部除く。
|
|
8
|
+
* 語句が元の文のどこにあるかを戻せるよう、残した文字ごとに元の位置を持つ。
|
|
9
|
+
*/
|
|
10
|
+
export const compacted = (text: string, unit: LengthUnit): Compacted => {
|
|
11
|
+
const chars: string[] = [];
|
|
12
|
+
const offsets: number[] = [];
|
|
13
|
+
const state = { pendingSpace: -1, at: 0 };
|
|
14
|
+
Array.from(text).forEach((char) => {
|
|
15
|
+
const at = state.at;
|
|
16
|
+
state.at += char.length;
|
|
17
|
+
if (/\s/u.test(char)) {
|
|
18
|
+
if (unit === "word" && chars.length > 0 && state.pendingSpace === -1) state.pendingSpace = at;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (state.pendingSpace !== -1) {
|
|
22
|
+
chars.push(" ");
|
|
23
|
+
offsets.push(state.pendingSpace);
|
|
24
|
+
state.pendingSpace = -1;
|
|
25
|
+
}
|
|
26
|
+
chars.push(char);
|
|
27
|
+
// 位置は UTF-16 の単位で持つ。placeOf は indexOf の位置で引くので、絵文字(2 単位)には 2 つ置く。
|
|
28
|
+
Array.from({ length: char.length }, (_, unit) => offsets.push(at + unit));
|
|
29
|
+
});
|
|
30
|
+
return { text: chars.join(""), offsets };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 詰めた文の中の gram の最初の出現を、元の文の範囲に戻す。無ければ undefined。 */
|
|
34
|
+
export const placeOf = (source: Compacted, gram: string): Span | undefined => {
|
|
35
|
+
const at = source.text.indexOf(gram);
|
|
36
|
+
if (at === -1) return undefined;
|
|
37
|
+
const start = source.offsets[at];
|
|
38
|
+
const last = source.offsets[at + gram.length - 1];
|
|
39
|
+
if (start === undefined || last === undefined) return undefined;
|
|
40
|
+
return { start, end: last + 1 };
|
|
41
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LexiconEntry, Sentence, Token } from "../plugin.ts";
|
|
2
|
+
import { proseText } from "../measure.ts";
|
|
3
|
+
|
|
4
|
+
/** 活用する品詞。"best" を "good" と同じ語にはしない。 */
|
|
5
|
+
const INFLECTING = new Set(["VERB", "AUX"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 語彙表が原形で書いた語(いただく)だけが、活用した形(いただきます・いただいた)に当たる。
|
|
9
|
+
* 活用した形で書いた語(could・かもしれません)はその形だけ。"could" は "can" と言っていることが違う。
|
|
10
|
+
*/
|
|
11
|
+
const sameWord = (written: Token, entry: Token): boolean => {
|
|
12
|
+
const surface = entry.surface.toLowerCase();
|
|
13
|
+
if (written.surface.toLowerCase() === surface) return true;
|
|
14
|
+
const inflects = INFLECTING.has(written.pos) && INFLECTING.has(entry.pos);
|
|
15
|
+
return inflects && entry.lemma?.toLowerCase() === surface && written.lemma?.toLowerCase() === surface;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const runsAt = (tokens: readonly Token[], entry: readonly Token[], start: number): boolean =>
|
|
19
|
+
entry.every((word, offset) => {
|
|
20
|
+
const written = tokens[start + offset];
|
|
21
|
+
return written !== undefined && sameWord(written, word);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 語彙の語が文の中にあるか。品詞が付いていれば語の並びで照らし、活用する語は原形で比べる(「させていただく」が「させていただきました」に当たる)。
|
|
26
|
+
* 語の途中には当たらない(「また」は「またいで」に当たらない)。品詞が無ければ、空白をまとめた文字列で照らす。
|
|
27
|
+
*/
|
|
28
|
+
export const entryIn = (sentence: Sentence, entry: LexiconEntry): boolean => {
|
|
29
|
+
const tokens = sentence.tokens;
|
|
30
|
+
const words = entry.tokens;
|
|
31
|
+
if (tokens === undefined || words === undefined || words.length === 0) return proseText(sentence).toLowerCase().includes(entry.pattern.toLowerCase());
|
|
32
|
+
return tokens.some((_token, start) => runsAt(tokens, words, start));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const LEADING_MARK = new Set(["PUNCT", "SYM"]);
|
|
36
|
+
|
|
37
|
+
/** 語彙の語で文が始まるか。文頭の記号は飛ばす。品詞が無ければ文字列の前方一致。 */
|
|
38
|
+
export const entryOpens = (sentence: Sentence, entry: LexiconEntry): boolean => {
|
|
39
|
+
const tokens = sentence.tokens;
|
|
40
|
+
const words = entry.tokens;
|
|
41
|
+
if (tokens === undefined || words === undefined || words.length === 0) return proseText(sentence).toLowerCase().startsWith(entry.pattern.toLowerCase());
|
|
42
|
+
const first = tokens.findIndex((token) => !LEADING_MARK.has(token.pos));
|
|
43
|
+
return runsAt(tokens, words, first);
|
|
44
|
+
};
|
package/src/detectors/lexicon.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { joinWords } from "./word-list.ts";
|
|
2
|
-
import { proseText } from "../measure.ts";
|
|
3
2
|
import { wordsOf } from "./structure.ts";
|
|
4
|
-
import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
|
|
3
|
+
import type { Detector, Finding, Lexicon, ProseDocument, Sentence } from "../plugin.ts";
|
|
4
|
+
import { entryIn, entryOpens } from "./lexicon-match.ts";
|
|
5
5
|
|
|
6
6
|
const PER = 1000;
|
|
7
7
|
|
|
@@ -10,11 +10,8 @@ const FLOOR = { word: 200, char: 500 };
|
|
|
10
10
|
|
|
11
11
|
type Hit = { readonly sentence: Sentence; readonly matched: string };
|
|
12
12
|
|
|
13
|
-
const hitsFor = (doc: ProseDocument,
|
|
14
|
-
doc.sentences.flatMap((sentence) => {
|
|
15
|
-
const text = sentence.text.toLowerCase();
|
|
16
|
-
return patterns.filter((pattern) => text.includes(pattern.toLowerCase())).map((matched) => ({ sentence, matched }));
|
|
17
|
-
});
|
|
13
|
+
const hitsFor = (doc: ProseDocument, lexicon: Lexicon): Hit[] =>
|
|
14
|
+
doc.sentences.flatMap((sentence) => lexicon.filter((entry) => entryIn(sentence, entry)).map((entry) => ({ sentence, matched: entry.pattern })));
|
|
18
15
|
|
|
19
16
|
/**
|
|
20
17
|
* 単位長あたりの出現率。件数で数えると長い文書ほど当たる(bold-density と同じ)。
|
|
@@ -23,8 +20,7 @@ const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
|
|
|
23
20
|
const densityRule =
|
|
24
21
|
(rule: string): Detector =>
|
|
25
22
|
(doc, options): Finding[] => {
|
|
26
|
-
const
|
|
27
|
-
const hits = hitsFor(doc, patterns);
|
|
23
|
+
const hits = hitsFor(doc, options.lexicon ?? []);
|
|
28
24
|
const length = wordsOf(doc);
|
|
29
25
|
const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
|
|
30
26
|
const first = hits[0];
|
|
@@ -49,8 +45,7 @@ export const cushionDensity = densityRule("cushion-phrase-density");
|
|
|
49
45
|
const QUALIFIER = /\d|より|に比べ|のうち|among|than|compared|based on|according/iu;
|
|
50
46
|
|
|
51
47
|
export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
|
|
52
|
-
const
|
|
53
|
-
const bare = hitsFor(doc, patterns).filter((hit) => !QUALIFIER.test(hit.sentence.text));
|
|
48
|
+
const bare = hitsFor(doc, options.lexicon ?? []).filter((hit) => !QUALIFIER.test(hit.sentence.text));
|
|
54
49
|
if (bare.length < options.limit) return [];
|
|
55
50
|
return bare.map((hit) => ({
|
|
56
51
|
rule: "unqualified-superlative",
|
|
@@ -62,10 +57,10 @@ export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
|
|
|
62
57
|
}));
|
|
63
58
|
};
|
|
64
59
|
|
|
65
|
-
const openerOf = (doc: ProseDocument,
|
|
60
|
+
const openerOf = (doc: ProseDocument, lexicon: Lexicon): (string | undefined)[] =>
|
|
66
61
|
doc.paragraphs.map((paragraph) => {
|
|
67
|
-
const head = paragraph.sentences[0]
|
|
68
|
-
return
|
|
62
|
+
const head = paragraph.sentences[0];
|
|
63
|
+
return head === undefined ? undefined : lexicon.find((entry) => entryOpens(head, entry))?.pattern;
|
|
69
64
|
});
|
|
70
65
|
|
|
71
66
|
/**
|
|
@@ -73,8 +68,7 @@ const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | un
|
|
|
73
68
|
* 前の段落の付け足しに見えて、話がどこへ向かっているのか分からなくなる。
|
|
74
69
|
*/
|
|
75
70
|
export const repeatedConjunction: Detector = (doc, options): Finding[] => {
|
|
76
|
-
const
|
|
77
|
-
const openers = openerOf(doc, patterns);
|
|
71
|
+
const openers = openerOf(doc, options.lexicon ?? []);
|
|
78
72
|
const runs = openers.reduce<{ runs: number[][]; current: number[] }>(
|
|
79
73
|
(acc, opener, index) => {
|
|
80
74
|
if (opener === undefined) return { runs: [...acc.runs, acc.current], current: [] };
|
|
@@ -106,11 +100,10 @@ export const repeatedConjunction: Detector = (doc, options): Finding[] => {
|
|
|
106
100
|
* 単独で断じない。§20.2 の複合シグナルの入口で、これだけで「AI が書いた」とは言わない。
|
|
107
101
|
*/
|
|
108
102
|
export const aiTell: Detector = (doc, options): Finding[] => {
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const first = doc.sentences.find((sentence) => found.some(([pattern]) => sentence.text.toLowerCase().includes(pattern)));
|
|
103
|
+
const entries = [...new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry])).values()];
|
|
104
|
+
const found = entries.filter((entry) => doc.sentences.some((sentence) => entryIn(sentence, entry)));
|
|
105
|
+
const score = Math.round(found.reduce((sum, entry) => sum + (entry.weight ?? 1), 0) * 10);
|
|
106
|
+
const first = doc.sentences.find((sentence) => found.some((entry) => entryIn(sentence, entry)));
|
|
114
107
|
if (first === undefined || score <= options.limit) return [];
|
|
115
108
|
return [
|
|
116
109
|
{
|
|
@@ -121,7 +114,7 @@ export const aiTell: Detector = (doc, options): Finding[] => {
|
|
|
121
114
|
quote: first.text.trim(),
|
|
122
115
|
values: {
|
|
123
116
|
word: joinWords(
|
|
124
|
-
found.map((
|
|
117
|
+
found.map((entry) => entry.pattern.toLowerCase()),
|
|
125
118
|
doc.language,
|
|
126
119
|
),
|
|
127
120
|
count: found.length,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
|
|
1
|
+
import type { Detector, Finding, Lexicon, ProseDocument, Sentence } from "../plugin.ts";
|
|
2
|
+
import { entryIn } from "./lexicon-match.ts";
|
|
2
3
|
|
|
3
4
|
/** 冒頭は先頭 2 段落ぶん。「どの記事にも当てはまる書き出し」は冒頭にあるときだけ問題。 */
|
|
4
5
|
const OPENING_SENTENCES = 4;
|
|
@@ -15,10 +16,10 @@ const inScope = (doc: ProseDocument, where: string | undefined): readonly Senten
|
|
|
15
16
|
|
|
16
17
|
type Hit = { readonly sentence: Sentence; readonly matched: string };
|
|
17
18
|
|
|
18
|
-
const hitsIn = (sentences: readonly Sentence[],
|
|
19
|
+
const hitsIn = (sentences: readonly Sentence[], lexicon: Lexicon): Hit[] =>
|
|
19
20
|
sentences.flatMap((sentence) => {
|
|
20
|
-
const matched =
|
|
21
|
-
return matched === undefined ? [] : [{ sentence, matched }];
|
|
21
|
+
const matched = lexicon.find((entry) => entryIn(sentence, entry));
|
|
22
|
+
return matched === undefined ? [] : [{ sentence, matched: matched.pattern }];
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -26,9 +27,9 @@ const hitsIn = (sentences: readonly Sentence[], patterns: readonly string[]): Hi
|
|
|
26
27
|
* これが L2 の全体像で、新しい言語は語彙表を書くだけで動く。
|
|
27
28
|
*/
|
|
28
29
|
export const phraseMatch: Detector = (doc, options): Finding[] => {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
const hits = hitsIn(inScope(doc, options.where),
|
|
30
|
+
const lexicon = options.lexicon ?? [];
|
|
31
|
+
if (lexicon.length === 0) return [];
|
|
32
|
+
const hits = hitsIn(inScope(doc, options.where), lexicon);
|
|
32
33
|
if (hits.length < options.limit) return [];
|
|
33
34
|
return hits.map((hit) => ({
|
|
34
35
|
rule: "",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Detector, Finding, LengthUnit, Sentence } from "../plugin.ts";
|
|
1
|
+
import type { Detector, Finding, LengthUnit, Sentence, Span } from "../plugin.ts";
|
|
2
2
|
|
|
3
3
|
const HEAD_CHARS = 6;
|
|
4
4
|
const HEAD_WORDS = 3;
|
|
@@ -13,22 +13,36 @@ const headOf = (sentence: Sentence, unit: LengthUnit): string => {
|
|
|
13
13
|
return text.split(/\s+/u).slice(0, HEAD_WORDS).join(" ");
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/** 文の入れ物。文の始まりを含む、いちばん内側の箇条書きの項目の始まり。地の文なら -1。 */
|
|
17
|
+
const containerOf = (sentence: Sentence, lists: readonly Span[]): number =>
|
|
18
|
+
lists.filter((item) => item.start <= sentence.span.start && sentence.span.start < item.end).reduce((innermost, item) => Math.max(innermost, item.start), -1);
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
type Run = { readonly head: string; readonly container: number; readonly members: readonly Sentence[] };
|
|
21
|
+
|
|
22
|
+
/** 前の文と今の文のあいだで、箇条書きの項目が始まっているか。文を持たない項目(画像だけの - )も連なりを切る。 */
|
|
23
|
+
const listBetween = (previous: Sentence | undefined, sentence: Sentence, lists: readonly Span[]): boolean =>
|
|
24
|
+
previous !== undefined && lists.some((item) => item.start >= previous.span.end && item.start < sentence.span.start);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 連なりは同じ入れ物の中でだけ続く。箇条書きの項目どうしは同じ形で並べるのが書き方そのもの(「1. SRE に関する…」「2. SRE に関する…」)で、
|
|
28
|
+
* 項目をまたぐと切る。一つの項目の中の段落や、地の文の中では、これまでどおり数える。箇条書きの前後の地の文も、箇条書きで切れる。
|
|
29
|
+
*/
|
|
30
|
+
const runsOf = (sentences: readonly Sentence[], unit: LengthUnit, lists: readonly Span[]): Run[] =>
|
|
19
31
|
sentences.reduce<Run[]>((acc, sentence) => {
|
|
20
32
|
const head = headOf(sentence, unit);
|
|
33
|
+
const container = containerOf(sentence, lists);
|
|
21
34
|
const last = acc.at(-1);
|
|
22
35
|
// 短すぎる書き出しは「同じ」と言えない。単位ぶん揃って初めて連なりと見る。
|
|
23
36
|
const full = unit === "char" ? head.length === HEAD_CHARS : head.split(" ").length === HEAD_WORDS;
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
const joins = last !== undefined && last.head === head && last.container === container && !listBetween(last.members.at(-1), sentence, lists);
|
|
38
|
+
if (last !== undefined && joins && full) {
|
|
39
|
+
return [...acc.slice(0, -1), { head, container, members: [...last.members, sentence] }];
|
|
26
40
|
}
|
|
27
|
-
return [...acc, { head, members: [sentence] }];
|
|
41
|
+
return [...acc, { head, container, members: [sentence] }];
|
|
28
42
|
}, []);
|
|
29
43
|
|
|
30
44
|
export const repeatedHead: Detector = (doc, options): Finding[] =>
|
|
31
|
-
runsOf(doc.sentences, doc.lengthUnit)
|
|
45
|
+
runsOf(doc.sentences, doc.lengthUnit, doc.listSpans)
|
|
32
46
|
.filter((run) => run.members.length > options.limit)
|
|
33
47
|
.map((run) => ({
|
|
34
48
|
rule: "repeated-sentence-head",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasPredicate, isClosed } from "../sentence-shape.ts";
|
|
1
|
+
import { endingTokens, hasPredicate, isClosed } from "../sentence-shape.ts";
|
|
2
2
|
import type { Detector, Finding, Sentence } from "../plugin.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -11,10 +11,14 @@ import type { Detector, Finding, Sentence } from "../plugin.ts";
|
|
|
11
11
|
* 述語を持たないものは文として数えない。見出しの下の `MaaSサービス` のような
|
|
12
12
|
* 名前だけの行がそのまま「である調」の少数派になり、実文書の誤検知はすべてこれだった。
|
|
13
13
|
*/
|
|
14
|
-
const isSentence = (sentence: Sentence): boolean => isClosed(sentence) && hasPredicate(sentence);
|
|
14
|
+
const isSentence = (sentence: Sentence): boolean => isClosed(sentence) && hasPredicate(sentence) && endingTokens(sentence).length > 0;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 文末の語だけを見る。文の途中の引用(「ご覧ください」という表現を使う。)は文末の調子ではない。
|
|
18
|
+
* 書いた形でも原形でも当てる。「ください」の原形は「くださる」で、原形だけを見ると丁寧な文末を見落とす。
|
|
19
|
+
*/
|
|
16
20
|
const isPolite = (sentence: Sentence, polite: readonly string[]): boolean =>
|
|
17
|
-
(sentence
|
|
21
|
+
endingTokens(sentence).some((token) => polite.includes(token.surface) || (token.lemma !== undefined && polite.includes(token.lemma)));
|
|
18
22
|
|
|
19
23
|
export const sentenceEnding: Detector = (doc, options): Finding[] => {
|
|
20
24
|
const polite = (options.lexicon ?? []).map((entry) => entry.pattern);
|