chaffjs 0.2.0 → 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/dist/backends/anthropic.d.ts +24 -0
- package/dist/backends/anthropic.d.ts.map +1 -0
- package/dist/backends/anthropic.js +44 -0
- package/dist/backends/anthropic.js.map +1 -0
- package/dist/backends/openai.d.ts +29 -0
- package/dist/backends/openai.d.ts.map +1 -0
- package/dist/backends/openai.js +33 -0
- package/dist/backends/openai.js.map +1 -0
- package/dist/backends/types.d.ts +32 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/types.js +18 -0
- package/dist/backends/types.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +5 -2
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +62 -16
- package/dist/commands/test.js.map +1 -1
- package/dist/config/load.d.ts +8 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +16 -3
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/agentless-passive.d.ts +3 -0
- package/dist/detectors/agentless-passive.d.ts.map +1 -0
- package/dist/detectors/agentless-passive.js +31 -0
- package/dist/detectors/agentless-passive.js.map +1 -0
- package/dist/detectors/char-shape.d.ts +12 -0
- package/dist/detectors/char-shape.d.ts.map +1 -0
- package/dist/detectors/char-shape.js +39 -0
- package/dist/detectors/char-shape.js.map +1 -0
- package/dist/detectors/en-shape.d.ts +19 -0
- package/dist/detectors/en-shape.d.ts.map +1 -0
- package/dist/detectors/en-shape.js +176 -0
- package/dist/detectors/en-shape.js.map +1 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +37 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lexicon.d.ts +15 -0
- package/dist/detectors/lexicon.d.ts.map +1 -0
- package/dist/detectors/lexicon.js +109 -0
- package/dist/detectors/lexicon.js.map +1 -0
- package/dist/detectors/repeated-head.d.ts.map +1 -1
- package/dist/detectors/repeated-head.js +17 -5
- package/dist/detectors/repeated-head.js.map +1 -1
- package/dist/detectors/sentence-ending.d.ts +3 -0
- package/dist/detectors/sentence-ending.d.ts.map +1 -0
- package/dist/detectors/sentence-ending.js +34 -0
- package/dist/detectors/sentence-ending.js.map +1 -0
- package/dist/detectors/signals.d.ts +6 -0
- package/dist/detectors/signals.d.ts.map +1 -0
- package/dist/detectors/signals.js +183 -0
- package/dist/detectors/signals.js.map +1 -0
- package/dist/detectors/structure.d.ts +24 -0
- package/dist/detectors/structure.d.ts.map +1 -0
- package/dist/detectors/structure.js +111 -0
- package/dist/detectors/structure.js.map +1 -0
- package/dist/detectors/team.d.ts +17 -0
- package/dist/detectors/team.d.ts.map +1 -0
- package/dist/detectors/team.js +87 -0
- package/dist/detectors/team.js.map +1 -0
- package/dist/detectors/token-shape.d.ts +4 -0
- package/dist/detectors/token-shape.d.ts.map +1 -0
- package/dist/detectors/token-shape.js +62 -0
- package/dist/detectors/token-shape.js.map +1 -0
- package/dist/document.d.ts +11 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +85 -10
- package/dist/document.js.map +1 -1
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +20 -0
- package/dist/env.js.map +1 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +9 -6
- package/dist/init.js.map +1 -1
- package/dist/judge.d.ts +14 -16
- package/dist/judge.d.ts.map +1 -1
- package/dist/judge.js +0 -0
- package/dist/judge.js.map +1 -1
- package/dist/levels.d.ts +1 -9
- package/dist/levels.d.ts.map +1 -1
- package/dist/levels.js +15 -3
- package/dist/levels.js.map +1 -1
- package/dist/look-at.d.ts +18 -0
- package/dist/look-at.d.ts.map +1 -0
- package/dist/look-at.js +43 -0
- package/dist/look-at.js.map +1 -0
- package/dist/plugin.d.ts +47 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/render/explain.d.ts +1 -1
- package/dist/render/explain.d.ts.map +1 -1
- package/dist/render/explain.js +12 -4
- package/dist/render/explain.js.map +1 -1
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +13 -5
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/semantic.d.ts +2 -1
- package/dist/render/semantic.d.ts.map +1 -1
- package/dist/render/semantic.js +35 -0
- package/dist/render/semantic.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +27 -1
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.d.ts +22 -1
- package/dist/run-semantic.d.ts.map +1 -1
- package/dist/run-semantic.js +21 -10
- package/dist/run-semantic.js.map +1 -1
- package/dist/run.d.ts +6 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +34 -2
- package/dist/run.js.map +1 -1
- package/dist/sentence-shape.d.ts +6 -0
- package/dist/sentence-shape.d.ts.map +1 -0
- package/dist/sentence-shape.js +15 -0
- package/dist/sentence-shape.js.map +1 -0
- package/package.json +4 -3
- package/rules/adverb-overuse.yaml +28 -0
- package/rules/agentless-passive.yaml +28 -0
- package/rules/ai-tell.yaml +27 -0
- package/rules/concrete-evidence-density.yaml +25 -0
- package/rules/cushion-phrase-density.yaml +26 -0
- package/rules/double-keigo.yaml +27 -0
- package/rules/emoji-density.yaml +26 -0
- package/rules/excessive-hedging.yaml +26 -0
- package/rules/expletive-construction.yaml +27 -0
- package/rules/hiragana-fukushi.yaml +27 -0
- package/rules/internal-jargon.yaml +27 -0
- package/rules/max-kanji-continuous.yaml +26 -0
- package/rules/max-paragraph-length.yaml +25 -0
- package/rules/max-sentence-length.yaml +22 -0
- package/rules/ngram-repetition.yaml +25 -0
- package/rules/no-doubled-joshi.yaml +28 -0
- package/rules/no-mixed-desumasu.yaml +29 -0
- package/rules/no-nakaguro-parallel.yaml +27 -0
- package/rules/oxford-comma-consistency.yaml +26 -0
- package/rules/paragraph-length-variance.yaml +25 -0
- package/rules/preamble-length.yaml +25 -0
- package/rules/proper-noun-density.yaml +26 -0
- package/rules/repeated-conjunction.yaml +26 -0
- package/rules/required-sections.yaml +26 -0
- package/rules/rule-of-three.yaml +25 -0
- package/rules/sasete-itadaku.yaml +27 -0
- package/rules/section-length-uniformity.yaml +25 -0
- package/rules/sentence-initial-conjunction-run.yaml +27 -0
- package/rules/taigen-dome-in-prose.yaml +27 -0
- package/rules/title-case-consistency.yaml +26 -0
- package/rules/undefined-acronym.yaml +25 -0
- package/rules/unqualified-superlative.yaml +26 -0
- package/src/backends/anthropic.ts +61 -0
- package/src/backends/openai.ts +46 -0
- package/src/backends/types.ts +38 -0
- package/src/cli.ts +9 -5
- package/src/commands/eval.ts +5 -2
- package/src/commands/test.ts +74 -16
- package/src/config/load.ts +22 -3
- package/src/detectors/agentless-passive.ts +34 -0
- package/src/detectors/char-shape.ts +47 -0
- package/src/detectors/en-shape.ts +187 -0
- package/src/detectors/index.ts +37 -0
- package/src/detectors/lexicon.ts +124 -0
- package/src/detectors/repeated-head.ts +17 -6
- package/src/detectors/sentence-ending.ts +37 -0
- package/src/detectors/signals.ts +199 -0
- package/src/detectors/structure.ts +119 -0
- package/src/detectors/team.ts +90 -0
- package/src/detectors/token-shape.ts +81 -0
- package/src/document.ts +95 -11
- package/src/env.ts +20 -0
- package/src/init.ts +9 -6
- package/src/judge.ts +0 -0
- package/src/levels.ts +16 -4
- package/src/look-at.ts +53 -0
- package/src/plugin.ts +48 -4
- package/src/render/explain.ts +12 -4
- package/src/render/rules-json.ts +16 -5
- package/src/render/semantic.ts +38 -1
- package/src/rule-load.ts +28 -1
- package/src/run-semantic.ts +32 -11
- package/src/run.ts +36 -3
- package/src/sentence-shape.ts +22 -0
|
@@ -1,23 +1,34 @@
|
|
|
1
|
-
import type { Detector, Finding, Sentence } from "../plugin.ts";
|
|
1
|
+
import type { Detector, Finding, LengthUnit, Sentence } from "../plugin.ts";
|
|
2
2
|
|
|
3
3
|
const HEAD_CHARS = 6;
|
|
4
|
+
const HEAD_WORDS = 3;
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
/**
|
|
7
|
+
* 書き出しの「同じさ」を測る単位は言語で違う。日本語は文字、英語は語。
|
|
8
|
+
* 文字で切ると "We continued" が "Wecont" になり、引用がそのまま読み手に出る。
|
|
9
|
+
*/
|
|
10
|
+
const headOf = (sentence: Sentence, unit: LengthUnit): string => {
|
|
11
|
+
const text = sentence.text.trim();
|
|
12
|
+
if (unit === "char") return text.replace(/\s+/gu, "").slice(0, HEAD_CHARS);
|
|
13
|
+
return text.split(/\s+/u).slice(0, HEAD_WORDS).join(" ");
|
|
14
|
+
};
|
|
6
15
|
|
|
7
16
|
type Run = { readonly head: string; readonly members: readonly Sentence[] };
|
|
8
17
|
|
|
9
|
-
const runsOf = (sentences: readonly Sentence[]): Run[] =>
|
|
18
|
+
const runsOf = (sentences: readonly Sentence[], unit: LengthUnit): Run[] =>
|
|
10
19
|
sentences.reduce<Run[]>((acc, sentence) => {
|
|
11
|
-
const head = headOf(sentence);
|
|
20
|
+
const head = headOf(sentence, unit);
|
|
12
21
|
const last = acc.at(-1);
|
|
13
|
-
|
|
22
|
+
// 短すぎる書き出しは「同じ」と言えない。単位ぶん揃って初めて連なりと見る。
|
|
23
|
+
const full = unit === "char" ? head.length === HEAD_CHARS : head.split(" ").length === HEAD_WORDS;
|
|
24
|
+
if (last !== undefined && last.head === head && full) {
|
|
14
25
|
return [...acc.slice(0, -1), { head, members: [...last.members, sentence] }];
|
|
15
26
|
}
|
|
16
27
|
return [...acc, { head, members: [sentence] }];
|
|
17
28
|
}, []);
|
|
18
29
|
|
|
19
30
|
export const repeatedHead: Detector = (doc, options): Finding[] =>
|
|
20
|
-
runsOf(doc.sentences)
|
|
31
|
+
runsOf(doc.sentences, doc.lengthUnit)
|
|
21
32
|
.filter((run) => run.members.length > options.limit)
|
|
22
33
|
.map((run) => ({
|
|
23
34
|
rule: "repeated-sentence-head",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { hasPredicate, isClosed } from "../sentence-shape.ts";
|
|
2
|
+
import type { Detector, Finding, Sentence } from "../plugin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 文末の調子が混ざっているかを見る。日本語の「ですます / である」がこれ。
|
|
6
|
+
*
|
|
7
|
+
* どちらが正しいかは決めない。決めると、方針の違う書き手にそのまま無視される。
|
|
8
|
+
* 見るのは文書の中での一貫性だけで、少数派のほうを指摘する。spec §12.3 の方針を日本語にも。
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* 述語を持たないものは文として数えない。見出しの下の `MaaSサービス` のような
|
|
12
|
+
* 名前だけの行がそのまま「である調」の少数派になり、実文書の誤検知はすべてこれだった。
|
|
13
|
+
*/
|
|
14
|
+
const isSentence = (sentence: Sentence): boolean => isClosed(sentence) && hasPredicate(sentence);
|
|
15
|
+
|
|
16
|
+
const isPolite = (sentence: Sentence, polite: readonly string[]): boolean =>
|
|
17
|
+
(sentence.tokens ?? []).some((token) => polite.includes(token.lemma ?? token.surface));
|
|
18
|
+
|
|
19
|
+
export const sentenceEnding: Detector = (doc, options): Finding[] => {
|
|
20
|
+
const polite = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
21
|
+
const judged = doc.sentences.filter(isSentence).map((sentence) => ({ sentence, polite: isPolite(sentence, polite) }));
|
|
22
|
+
const counts = { polite: judged.filter((entry) => entry.polite).length, plain: judged.length - judged.filter((entry) => entry.polite).length };
|
|
23
|
+
const minorityIsPolite = counts.polite <= counts.plain;
|
|
24
|
+
const few = minorityIsPolite ? counts.polite : counts.plain;
|
|
25
|
+
// 片方しか無ければ一貫している。少数派が閾値を超えて多ければ、混在ではなく別の文体。
|
|
26
|
+
if (few === 0 || few > options.limit) return [];
|
|
27
|
+
return judged
|
|
28
|
+
.filter((entry) => entry.polite === minorityIsPolite)
|
|
29
|
+
.map(({ sentence }) => ({
|
|
30
|
+
rule: "no-mixed-desumasu",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
line: 0,
|
|
33
|
+
column: 0,
|
|
34
|
+
quote: sentence.text.trim(),
|
|
35
|
+
values: { count: few, limit: options.limit, offset: sentence.span.start },
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { proseText } from "../measure.ts";
|
|
2
|
+
import { wordsOf } from "./structure.ts";
|
|
3
|
+
import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
|
|
4
|
+
|
|
5
|
+
const PER = 1000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* これより短い文書では密度が暴れる。1 個で「1000 あたり 100」になる。
|
|
9
|
+
* 単位は言語で違うので、床も分ける。英語の 200 語と日本語の 200 文字では長さが桁で違う。
|
|
10
|
+
*/
|
|
11
|
+
const FLOOR = { word: 200, char: 500 };
|
|
12
|
+
|
|
13
|
+
const bodyOf = (doc: ProseDocument): string => doc.sentences.map(proseText).join(" ");
|
|
14
|
+
|
|
15
|
+
type Hit = { readonly sentence: Sentence; readonly offset: number };
|
|
16
|
+
|
|
17
|
+
const findIn = (doc: ProseDocument, pattern: RegExp): Hit[] =>
|
|
18
|
+
doc.sentences.flatMap((sentence) => [...sentence.text.matchAll(pattern)].map((match) => ({ sentence, offset: sentence.span.start + (match.index ?? 0) })));
|
|
19
|
+
|
|
20
|
+
const density = (doc: ProseDocument, count: number): number => {
|
|
21
|
+
const length = wordsOf(doc);
|
|
22
|
+
return length === 0 ? 0 : Math.round((count / length) * PER);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 絵文字と装飾記号。1 つなら親しみだが、並ぶと文字そのものが読めなくなる。
|
|
27
|
+
* 件数ではなく密度で見る。長い文書ほど当たるのを避けるため(bold-density と同じ)。
|
|
28
|
+
*/
|
|
29
|
+
const EMOJI = /\p{Extended_Pictographic}|[←-⇿☀-➿]/gu;
|
|
30
|
+
|
|
31
|
+
export const emojiDensity: Detector = (doc, options): Finding[] => {
|
|
32
|
+
const hits = findIn(doc, EMOJI);
|
|
33
|
+
const first = hits[0];
|
|
34
|
+
const rate = density(doc, hits.length);
|
|
35
|
+
if (wordsOf(doc) < FLOOR[doc.lengthUnit] || first === undefined || rate <= options.limit) return [];
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
rule: "emoji-density",
|
|
39
|
+
severity: "info",
|
|
40
|
+
line: 0,
|
|
41
|
+
column: 0,
|
|
42
|
+
quote: first.sentence.text.trim(),
|
|
43
|
+
values: { count: hits.length, density: rate, limit: options.limit, offset: first.offset },
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 同じ言い回しの繰り返し。character n-gram で見るので、語の区切りが要らない。
|
|
50
|
+
* spec §10 が n-gram を選んだのはこのためで、新しい言語でそのまま動く。
|
|
51
|
+
*/
|
|
52
|
+
const GRAM = 8;
|
|
53
|
+
|
|
54
|
+
/** 短い n-gram は偶然一致する。8 文字は「〜することができます」のような言い回しの長さ。 */
|
|
55
|
+
const countGrams = (text: string): Map<string, number> => {
|
|
56
|
+
const counts = new Map<string, number>();
|
|
57
|
+
Array.from({ length: Math.max(0, text.length - GRAM + 1) }).forEach((_, index) => {
|
|
58
|
+
const gram = text.slice(index, index + GRAM);
|
|
59
|
+
counts.set(gram, (counts.get(gram) ?? 0) + 1);
|
|
60
|
+
});
|
|
61
|
+
return counts;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 名前ではなく言い回しだけを数える。実文書で測ったら、上位は
|
|
66
|
+
* 「AGENTS.m」「シンギュラリティ」のような固有名詞と識別子だった。名前は繰り返して当たり前。
|
|
67
|
+
*
|
|
68
|
+
* 言い回しは、その言語の「つなぎの материал」を含む。日本語ならひらがな、英語なら語の切れ目。
|
|
69
|
+
* どちらを見るかは adapter が宣言する単位で決める。ひらがなを持たない char 単位の言語が
|
|
70
|
+
* 来たら、この見分けは効かなくなる(そのときは adapter 側に判定を移す)。
|
|
71
|
+
*/
|
|
72
|
+
const CONNECTIVE = { char: /[ぁ-ゖ]/u, word: / /u };
|
|
73
|
+
|
|
74
|
+
const isPhrasing = (gram: string, unit: ProseDocument["lengthUnit"]): boolean => CONNECTIVE[unit].test(gram);
|
|
75
|
+
|
|
76
|
+
/** 文をまたいで数えない。文の終わりと次の文の始まりが繋がると、固有名詞が言い回しに見える。 */
|
|
77
|
+
const gramsOf = (doc: ProseDocument): Map<string, number> => {
|
|
78
|
+
const counts = new Map<string, number>();
|
|
79
|
+
doc.sentences.forEach((sentence) => {
|
|
80
|
+
const text = doc.lengthUnit === "char" ? proseText(sentence).replace(/\s+/gu, "") : proseText(sentence);
|
|
81
|
+
countGrams(text).forEach((count, gram) => counts.set(gram, (counts.get(gram) ?? 0) + count));
|
|
82
|
+
});
|
|
83
|
+
return counts;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const ngramRepetition: Detector = (doc, options): Finding[] => {
|
|
87
|
+
if (wordsOf(doc) < FLOOR[doc.lengthUnit]) return [];
|
|
88
|
+
const worst = [...gramsOf(doc).entries()]
|
|
89
|
+
.filter(([gram]) => isPhrasing(gram, doc.lengthUnit))
|
|
90
|
+
.reduce<{ gram: string; count: number }>((best, [gram, count]) => (count > best.count ? { gram, count } : best), { gram: "", count: 0 });
|
|
91
|
+
if (worst.count <= options.limit) return [];
|
|
92
|
+
const at = doc.sentences.find((sentence) => sentence.text.includes(worst.gram));
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
rule: "ngram-repetition",
|
|
96
|
+
severity: "info",
|
|
97
|
+
line: 0,
|
|
98
|
+
column: 0,
|
|
99
|
+
quote: at?.text.trim() ?? worst.gram,
|
|
100
|
+
values: { word: worst.gram, count: worst.count, limit: options.limit, offset: at?.span.start ?? 0 },
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 略語が初出で展開されているか。「CI」だけでは、読む人によって指すものが違う。
|
|
107
|
+
* 展開は「略語の直前か直後の括弧」か「括弧の中の略語」で書かれる。
|
|
108
|
+
*/
|
|
109
|
+
const ACRONYM = /\b[A-Z]{2,6}\b/gu;
|
|
110
|
+
|
|
111
|
+
/** 読み手が説明なしで通じると見なしてよい語。展開すると逆に読みにくい。 */
|
|
112
|
+
const COMMON = new Set([
|
|
113
|
+
"OK",
|
|
114
|
+
"NG",
|
|
115
|
+
"URL",
|
|
116
|
+
"API",
|
|
117
|
+
"CSS",
|
|
118
|
+
"HTML",
|
|
119
|
+
"JSON",
|
|
120
|
+
"YAML",
|
|
121
|
+
"HTTP",
|
|
122
|
+
"HTTPS",
|
|
123
|
+
"PDF",
|
|
124
|
+
"CPU",
|
|
125
|
+
"GPU",
|
|
126
|
+
"RAM",
|
|
127
|
+
"USB",
|
|
128
|
+
"AI",
|
|
129
|
+
"ID",
|
|
130
|
+
"FAQ",
|
|
131
|
+
"PR",
|
|
132
|
+
"OS",
|
|
133
|
+
"CLI",
|
|
134
|
+
"UI",
|
|
135
|
+
"UX",
|
|
136
|
+
"SQL",
|
|
137
|
+
"CSV",
|
|
138
|
+
"XML",
|
|
139
|
+
"TODO",
|
|
140
|
+
"NOTE",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 展開は略語の**すぐ隣**にあるときだけ認める。
|
|
145
|
+
* 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
|
|
146
|
+
*/
|
|
147
|
+
const AFTER = /^\s*[((]/u;
|
|
148
|
+
const BEFORE = /[))]\s*$/u;
|
|
149
|
+
|
|
150
|
+
const isExpanded = (body: string, acronym: string): boolean => {
|
|
151
|
+
const at = body.indexOf(acronym);
|
|
152
|
+
if (at === -1) return false;
|
|
153
|
+
return AFTER.test(body.slice(at + acronym.length, at + acronym.length + 3)) || BEFORE.test(body.slice(Math.max(0, at - 3), at));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const undefinedAcronym: Detector = (doc, options): Finding[] => {
|
|
157
|
+
const body = bodyOf(doc);
|
|
158
|
+
const seen = new Map<string, Hit>();
|
|
159
|
+
findIn(doc, ACRONYM).forEach((hit) => {
|
|
160
|
+
const text = /^[A-Z]{2,6}/u.exec(doc.source.slice(hit.offset, hit.offset + 6))?.[0] ?? "";
|
|
161
|
+
if (text.length > 0 && !COMMON.has(text) && !seen.has(text)) seen.set(text, hit);
|
|
162
|
+
});
|
|
163
|
+
const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym));
|
|
164
|
+
if (bare.length < options.limit) return [];
|
|
165
|
+
return bare.map(([acronym, hit]) => ({
|
|
166
|
+
rule: "undefined-acronym",
|
|
167
|
+
severity: "info",
|
|
168
|
+
line: 0,
|
|
169
|
+
column: 0,
|
|
170
|
+
quote: hit.sentence.text.trim(),
|
|
171
|
+
values: { word: acronym, count: bare.length, limit: options.limit, offset: hit.offset },
|
|
172
|
+
}));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 具体物の密度。数値・コード・リンク・引用が 1 つも無い節は、
|
|
177
|
+
* 読み終えても何も持ち帰れない。**少ないほうを指摘する**ので、他の密度 rule と向きが逆。
|
|
178
|
+
*/
|
|
179
|
+
const CONCRETE = /\d|`|https?:\/\//u;
|
|
180
|
+
|
|
181
|
+
export const concreteEvidence: Detector = (doc, options): Finding[] => {
|
|
182
|
+
const sections = doc.sections.filter((section) => section.sentences.length >= 3);
|
|
183
|
+
const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)));
|
|
184
|
+
if (empty.length < options.limit) return [];
|
|
185
|
+
return empty.map((section) => ({
|
|
186
|
+
rule: "concrete-evidence-density",
|
|
187
|
+
severity: "info",
|
|
188
|
+
line: 0,
|
|
189
|
+
column: 0,
|
|
190
|
+
quote: section.heading.length > 0 ? section.heading : (section.sentences[0]?.text.trim() ?? ""),
|
|
191
|
+
values: {
|
|
192
|
+
word: section.heading.length > 0 ? section.heading : "この節",
|
|
193
|
+
count: empty.length,
|
|
194
|
+
total: sections.length,
|
|
195
|
+
limit: options.limit,
|
|
196
|
+
offset: section.span.start,
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
199
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { charLength, lengthOf } from "../measure.ts";
|
|
2
|
+
import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section } from "../plugin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ばらつきは変動係数(標準偏差 ÷ 平均)で測る。
|
|
6
|
+
*
|
|
7
|
+
* 標準偏差そのものを見ると、長い文書ほど大きくなって閾値が効かない。
|
|
8
|
+
* 平均で割ると長さに依らなくなり、「揃いすぎ」と「ばらつきすぎ」を同じ尺度で言える。
|
|
9
|
+
*/
|
|
10
|
+
export const coefficientOfVariation = (values: readonly number[]): number | undefined => {
|
|
11
|
+
if (values.length < 2) return undefined;
|
|
12
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
13
|
+
if (mean === 0) return undefined;
|
|
14
|
+
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
|
|
15
|
+
return Math.sqrt(variance) / mean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** 変動係数は 0..1 では読めないので、パーセントの整数にして閾値と突き合わせる。 */
|
|
19
|
+
const PERCENT = 100;
|
|
20
|
+
|
|
21
|
+
const asPercent = (value: number): number => Math.round(value * PERCENT);
|
|
22
|
+
|
|
23
|
+
const uniformity = (doc: ProseDocument, values: readonly number[], span: { start: number }, limit: number, rule: string): Finding[] => {
|
|
24
|
+
const cv = coefficientOfVariation(values);
|
|
25
|
+
if (cv === undefined || asPercent(cv) >= limit) return [];
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
rule,
|
|
29
|
+
severity: "info",
|
|
30
|
+
line: 0,
|
|
31
|
+
column: 0,
|
|
32
|
+
quote: doc.source.slice(span.start, span.start + 80).trim(),
|
|
33
|
+
values: { count: values.length, variance: asPercent(cv), limit, offset: span.start },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 1 段落に文を詰めすぎると、読み手はどこで息継ぎしていいか分からなくなる。 */
|
|
39
|
+
export const paragraphLength: Detector = (doc, options): Finding[] =>
|
|
40
|
+
doc.paragraphs
|
|
41
|
+
.filter((paragraph) => paragraph.sentences.length > options.limit)
|
|
42
|
+
.map((paragraph) => ({
|
|
43
|
+
rule: "max-paragraph-length",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
line: 0,
|
|
46
|
+
column: 0,
|
|
47
|
+
quote: doc.source.slice(paragraph.span.start, paragraph.span.start + 80).trim(),
|
|
48
|
+
values: { count: paragraph.sentences.length, limit: options.limit, offset: paragraph.span.start },
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const charsOf = (paragraph: Paragraph): number => paragraph.sentences.reduce((sum, sentence) => sum + charLength(sentence), 0);
|
|
52
|
+
|
|
53
|
+
/** 段落の長さが揃いすぎている。人が書くと、言いたいことの重さで段落の長さは揺れる。 */
|
|
54
|
+
export const paragraphVariance: Detector = (doc, options): Finding[] => {
|
|
55
|
+
const lengths = doc.paragraphs.map(charsOf).filter((length) => length > 0);
|
|
56
|
+
const first = doc.paragraphs[0];
|
|
57
|
+
return first === undefined ? [] : uniformity(doc, lengths, first.span, options.limit, "paragraph-length-variance");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const sectionChars = (section: Section): number => section.sentences.reduce((sum, sentence) => sum + charLength(sentence), 0);
|
|
61
|
+
|
|
62
|
+
/** 節の長さが揃いすぎている。見出しごとに機械的に埋めた文書で起きる。 */
|
|
63
|
+
export const sectionUniformity: Detector = (doc, options): Finding[] => {
|
|
64
|
+
const lengths = doc.sections.map(sectionChars).filter((length) => length > 0);
|
|
65
|
+
const first = doc.sections[0];
|
|
66
|
+
return first === undefined ? [] : uniformity(doc, lengths, first.span, options.limit, "section-length-uniformity");
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 3 項目の箇条書きばかりになっている。
|
|
71
|
+
* 3 つ並べると収まりがよいので、言いたいことが 2 つでも 4 つでも 3 つにされる。
|
|
72
|
+
*/
|
|
73
|
+
const isThree = (list: BulletList): boolean => list.items.length === 3;
|
|
74
|
+
|
|
75
|
+
export const ruleOfThree: Detector = (doc, options): Finding[] => {
|
|
76
|
+
const lists = doc.lists;
|
|
77
|
+
const threes = lists.filter(isThree);
|
|
78
|
+
const share = lists.length === 0 ? 0 : Math.round((threes.length / lists.length) * PERCENT);
|
|
79
|
+
const first = threes[0];
|
|
80
|
+
if (lists.length < 3 || first === undefined || share <= options.limit) return [];
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
rule: "rule-of-three",
|
|
84
|
+
severity: "info",
|
|
85
|
+
line: 0,
|
|
86
|
+
column: 0,
|
|
87
|
+
quote: doc.source.slice(first.span.start, first.span.start + 80).trim(),
|
|
88
|
+
values: { count: threes.length, total: lists.length, share, limit: options.limit, offset: first.span.start },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 本題に入るまでが長い。業務文書では、読み手は結論を探しに来ている。
|
|
95
|
+
*
|
|
96
|
+
* 「本題」は最初の中見出し(深さ 2 以上)とする。表題(深さ 1)の直後から数えると、
|
|
97
|
+
* 表題しか無い文書で全文が前置きになる。中見出しが無い文書では何も言わない。
|
|
98
|
+
*/
|
|
99
|
+
export const preambleLength: Detector = (doc, options): Finding[] => {
|
|
100
|
+
const body = doc.sections.find((section) => section.depth >= 2);
|
|
101
|
+
if (body === undefined) return [];
|
|
102
|
+
const before = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
|
|
103
|
+
if (before.length <= options.limit) return [];
|
|
104
|
+
const first = before[0];
|
|
105
|
+
return first === undefined
|
|
106
|
+
? []
|
|
107
|
+
: [
|
|
108
|
+
{
|
|
109
|
+
rule: "preamble-length",
|
|
110
|
+
severity: "info",
|
|
111
|
+
line: 0,
|
|
112
|
+
column: 0,
|
|
113
|
+
quote: doc.source.slice(first.span.start, first.span.start + 80).trim(),
|
|
114
|
+
values: { count: before.length, limit: options.limit, offset: first.span.start },
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const wordsOf = (doc: ProseDocument): number => doc.sentences.reduce((sum, sentence) => sum + lengthOf(sentence, doc.lengthUnit), 0);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { proseText } from "../measure.ts";
|
|
2
|
+
import { wordsOf } from "./structure.ts";
|
|
3
|
+
import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* チームの言葉。社内でしか通じない語を、チームが chaff.yaml に自分で並べる。
|
|
7
|
+
*
|
|
8
|
+
* 何が社内用語かは組織ごとに違うので、chaff は語を持たない。並べる場所だけを用意する。
|
|
9
|
+
* 語彙表が空なら何も言わない(「用語を登録してください」とも言わない。押しつけない)。
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 表層でも原形でも当てる。利用者は辞書形で書く(「握る」)が、本文は活用している(「握った」)。
|
|
13
|
+
* 品詞解析があれば原形で当たり、無くても表層で当たる。要求はしない。
|
|
14
|
+
*/
|
|
15
|
+
const matches = (sentence: Sentence, word: string): boolean => sentence.text.includes(word) || (sentence.tokens ?? []).some((token) => token.lemma === word);
|
|
16
|
+
|
|
17
|
+
export const internalJargon: Detector = (doc, options): Finding[] => {
|
|
18
|
+
const words = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
19
|
+
if (words.length === 0) return [];
|
|
20
|
+
const hits = doc.sentences.flatMap((sentence) => {
|
|
21
|
+
const matched = words.find((word) => matches(sentence, word));
|
|
22
|
+
return matched === undefined ? [] : [{ sentence, matched }];
|
|
23
|
+
});
|
|
24
|
+
if (hits.length < options.limit) return [];
|
|
25
|
+
return hits.map((hit) => ({
|
|
26
|
+
rule: "internal-jargon",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
line: 0,
|
|
29
|
+
column: 0,
|
|
30
|
+
quote: hit.sentence.text.trim(),
|
|
31
|
+
values: { matched: hit.matched, count: hits.length, limit: options.limit, offset: hit.sentence.span.start },
|
|
32
|
+
}));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const has = (headings: readonly string[], wanted: string): boolean => headings.some((heading) => heading.includes(wanted));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* この種類の文書に無いと困る見出し。チームが chaff.yaml で決める。
|
|
39
|
+
*
|
|
40
|
+
* 「提案書にはリスクの節がある」のような取り決めは、組織ごとに違う。
|
|
41
|
+
* chaff は必須の見出しを持たず、**書かれた取り決めを守れているかだけ**を見る。
|
|
42
|
+
*/
|
|
43
|
+
export const requiredSections: Detector = (doc, options): Finding[] => {
|
|
44
|
+
const wanted = doc.requiredSections;
|
|
45
|
+
if (wanted.length === 0) return [];
|
|
46
|
+
const headings = doc.sections.map((section) => section.heading);
|
|
47
|
+
const missing = wanted.filter((name) => !has(headings, name));
|
|
48
|
+
if (missing.length < options.limit) return [];
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
rule: "required-sections",
|
|
52
|
+
severity: "error",
|
|
53
|
+
line: 0,
|
|
54
|
+
column: 0,
|
|
55
|
+
quote: headings.filter((heading) => heading.length > 0).join(" / "),
|
|
56
|
+
values: { word: missing.join("、"), count: missing.length, limit: options.limit, offset: 0 },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const PER = 1000;
|
|
62
|
+
|
|
63
|
+
/** 密度を見る rule は短い文書を測らない。単位は言語で違うので床も分ける。 */
|
|
64
|
+
const FLOOR = { word: 200, char: 500 };
|
|
65
|
+
|
|
66
|
+
const isProperNoun = (token: Token): boolean => token.pos === "PROPN";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 固有名詞の密度。製品名・社名・人名が続くと、知らない読者は文の骨組みを見失う。
|
|
70
|
+
*
|
|
71
|
+
* spec は未知語率としていたが、**品詞解析があれば PROPN を数えるだけで足りる**。
|
|
72
|
+
* 辞書を別に持つ必要はない。
|
|
73
|
+
*/
|
|
74
|
+
export const properNounDensity: Detector = (doc, options): Finding[] => {
|
|
75
|
+
const hits = doc.sentences.flatMap((sentence) => (sentence.tokens ?? []).filter(isProperNoun).map((token) => ({ sentence, token })));
|
|
76
|
+
const length = wordsOf(doc);
|
|
77
|
+
const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
|
|
78
|
+
const first = hits[0];
|
|
79
|
+
if (length < FLOOR[doc.lengthUnit] || first === undefined || rate <= options.limit) return [];
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
rule: "proper-noun-density",
|
|
83
|
+
severity: "info",
|
|
84
|
+
line: 0,
|
|
85
|
+
column: 0,
|
|
86
|
+
quote: proseText(first.sentence),
|
|
87
|
+
values: { count: hits.length, density: rate, limit: options.limit, offset: first.token.span.start },
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { hasParticle, isClosed, lastContent } from "../sentence-shape.ts";
|
|
2
|
+
import type { Detector, Finding, Span, Token } from "../plugin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 文末が名詞で終わる(体言止め)。箇条書きでは普通だが、本文では文が途中で切れて読める。
|
|
6
|
+
* 実文書で測ったら、指摘の 9 割が箇条書きの項目だった。箇条書きは除く。
|
|
7
|
+
*/
|
|
8
|
+
const NOMINAL = new Set(["NOUN", "PROPN"]);
|
|
9
|
+
|
|
10
|
+
/** 非自立名詞は単独では何も指さない。「回るのか。」の「の」を体言止めと読まない。 */
|
|
11
|
+
const isDependent = (token: Token): boolean => token.features?.["NounType"] === "Dependent";
|
|
12
|
+
|
|
13
|
+
const inside = (span: Span, spans: readonly Span[]): boolean => spans.some((other) => span.start >= other.start && span.start < other.end);
|
|
14
|
+
|
|
15
|
+
export const nounEnding: Detector = (doc, options): Finding[] => {
|
|
16
|
+
const ended = doc.sentences.filter((sentence) => {
|
|
17
|
+
if (inside(sentence.span, doc.listSpans) || !isClosed(sentence) || !hasParticle(sentence)) return false;
|
|
18
|
+
const last = lastContent(sentence);
|
|
19
|
+
return last !== undefined && NOMINAL.has(last.pos) && !isDependent(last);
|
|
20
|
+
});
|
|
21
|
+
const first = ended[0];
|
|
22
|
+
// 文書全体の数についての指摘なので、1 文ずつ出さない。13 件並べても直す順番が分からない。
|
|
23
|
+
if (first === undefined || ended.length <= options.limit) return [];
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
rule: "taigen-dome-in-prose",
|
|
27
|
+
severity: "info",
|
|
28
|
+
line: 0,
|
|
29
|
+
column: 0,
|
|
30
|
+
quote: first.text.trim(),
|
|
31
|
+
values: { count: ended.length, limit: options.limit, offset: first.span.start },
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 名詞を名詞に繋ぐ助詞が続けて出る。「弊社の新製品の販売の計画」は係りかたが読めなくなる典型。
|
|
38
|
+
*
|
|
39
|
+
* 見るのは語彙表にある助詞だけ。助詞一般を数えると、「A も B も C も」の並列と
|
|
40
|
+
* 「コピーして持っていって」の連用が当たる。どちらも何重に続いても読めるので rule が合わない。
|
|
41
|
+
*
|
|
42
|
+
* 数えるのは**節の中**だけ。読点で切らないと「サービスの運営や、ドキュメントの作成」のような
|
|
43
|
+
* 並列まで 1 つの連なりとして数える。並列は必ず読点で区切られる。
|
|
44
|
+
*/
|
|
45
|
+
type Run = { readonly surface: string; readonly tokens: readonly Token[] };
|
|
46
|
+
|
|
47
|
+
const extend = (runs: readonly Run[], token: Token): Run[] => {
|
|
48
|
+
const last = runs.at(-1);
|
|
49
|
+
if (last !== undefined && last.surface === token.surface) return [...runs.slice(0, -1), { surface: last.surface, tokens: [...last.tokens, token] }];
|
|
50
|
+
return [...runs, { surface: token.surface, tokens: [token] }];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PARTICLE = new Set(["ADP", "SCONJ", "PART", "CCONJ"]);
|
|
54
|
+
|
|
55
|
+
const BREAK: Run = { surface: "", tokens: [] };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 名詞は連なりの中身なので飛ばす。読点と、語彙表に無い助詞は連なりを切る。
|
|
59
|
+
* 「弊社のAはBの」を 2 回続いたと数えないため。「は」でいちど句が閉じている。
|
|
60
|
+
*/
|
|
61
|
+
const runsOf = (tokens: readonly Token[], nesting: readonly string[]): Run[] =>
|
|
62
|
+
tokens.reduce<Run[]>((acc, token) => {
|
|
63
|
+
if (nesting.includes(token.surface)) return extend(acc, token);
|
|
64
|
+
return token.pos === "PUNCT" || PARTICLE.has(token.pos) ? [...acc, BREAK] : acc;
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
export const doubledParticle: Detector = (doc, options): Finding[] => {
|
|
68
|
+
const nesting = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
69
|
+
return doc.sentences.flatMap((sentence) =>
|
|
70
|
+
runsOf(sentence.tokens ?? [], nesting)
|
|
71
|
+
.filter((run) => run.tokens.length > options.limit)
|
|
72
|
+
.map((run) => ({
|
|
73
|
+
rule: "no-doubled-joshi",
|
|
74
|
+
severity: "warning" as const,
|
|
75
|
+
line: 0,
|
|
76
|
+
column: 0,
|
|
77
|
+
quote: sentence.text.trim(),
|
|
78
|
+
values: { word: run.surface, count: run.tokens.length, limit: options.limit, offset: run.tokens[0]?.span.start ?? sentence.span.start },
|
|
79
|
+
})),
|
|
80
|
+
);
|
|
81
|
+
};
|