chaffjs 0.10.0 → 0.12.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/date-stamp.d.ts +14 -0
- package/dist/date-stamp.d.ts.map +1 -0
- package/dist/date-stamp.js +40 -0
- package/dist/date-stamp.js.map +1 -0
- package/dist/declared-lists.d.ts +4 -0
- package/dist/declared-lists.d.ts.map +1 -0
- package/dist/declared-lists.js +3 -0
- package/dist/declared-lists.js.map +1 -0
- package/dist/detectors/acronym-context.d.ts +13 -0
- package/dist/detectors/acronym-context.d.ts.map +1 -0
- package/dist/detectors/acronym-context.js +42 -0
- package/dist/detectors/acronym-context.js.map +1 -0
- package/dist/detectors/acronym-expansion.d.ts +9 -0
- package/dist/detectors/acronym-expansion.d.ts.map +1 -0
- package/dist/detectors/acronym-expansion.js +67 -0
- package/dist/detectors/acronym-expansion.js.map +1 -0
- 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/doubled-word.d.ts +27 -0
- package/dist/detectors/doubled-word.d.ts.map +1 -0
- package/dist/detectors/doubled-word.js +94 -0
- package/dist/detectors/doubled-word.js.map +1 -0
- 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/heading-echo.d.ts.map +1 -1
- package/dist/detectors/heading-echo.js +22 -12
- package/dist/detectors/heading-echo.js.map +1 -1
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +2 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lead-in.d.ts +8 -0
- package/dist/detectors/lead-in.d.ts.map +1 -0
- package/dist/detectors/lead-in.js +13 -0
- package/dist/detectors/lead-in.js.map +1 -0
- package/dist/detectors/lexicon-match.d.ts +15 -0
- package/dist/detectors/lexicon-match.d.ts.map +1 -0
- package/dist/detectors/lexicon-match.js +48 -0
- package/dist/detectors/lexicon-match.js.map +1 -0
- package/dist/detectors/lexicon.d.ts +5 -0
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +32 -22
- 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 +97 -59
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/structure.d.ts.map +1 -1
- package/dist/detectors/structure.js +14 -1
- package/dist/detectors/structure.js.map +1 -1
- package/dist/detectors/superlative-scope.d.ts +16 -0
- package/dist/detectors/superlative-scope.d.ts.map +1 -0
- package/dist/detectors/superlative-scope.js +40 -0
- package/dist/detectors/superlative-scope.js.map +1 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +38 -19
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts.map +1 -1
- package/dist/eval.js +3 -2
- package/dist/eval.js.map +1 -1
- package/dist/heading-title.d.ts +6 -0
- package/dist/heading-title.d.ts.map +1 -0
- package/dist/heading-title.js +8 -0
- package/dist/heading-title.js.map +1 -0
- package/dist/in-page-nav.d.ts +19 -0
- package/dist/in-page-nav.d.ts.map +1 -0
- package/dist/in-page-nav.js +44 -0
- package/dist/in-page-nav.js.map +1 -0
- 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/orthography.d.ts +1 -0
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +4 -3
- package/dist/orthography.js.map +1 -1
- 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 +8 -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 +2 -0
- package/dist/rule-load.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +10 -7
- 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/doubled-word.yaml +27 -0
- package/rules/empty-intensifier.yaml +1 -0
- package/rules/excessive-hedging.yaml +1 -0
- package/rules/hiragana-fukushi.yaml +1 -0
- package/rules/latin-spacing.yaml +2 -2
- 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/undefined-acronym.yaml +1 -0
- package/rules/unqualified-superlative.yaml +2 -0
- package/src/cli-text.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/date-stamp.ts +65 -0
- package/src/declared-lists.ts +5 -0
- package/src/detectors/acronym-context.ts +53 -0
- package/src/detectors/acronym-expansion.ts +81 -0
- package/src/detectors/char-shape.ts +38 -3
- package/src/detectors/doubled-word.ts +128 -0
- package/src/detectors/en-shape.ts +138 -7
- package/src/detectors/gram-place.ts +41 -0
- package/src/detectors/heading-echo.ts +13 -3
- package/src/detectors/index.ts +2 -0
- package/src/detectors/lead-in.ts +14 -0
- package/src/detectors/lexicon-match.ts +54 -0
- package/src/detectors/lexicon.ts +44 -25
- 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 +122 -60
- package/src/detectors/structure.ts +21 -2
- package/src/detectors/superlative-scope.ts +66 -0
- package/src/document.ts +43 -19
- package/src/eval.ts +2 -1
- package/src/heading-title.ts +8 -0
- package/src/in-page-nav.ts +67 -0
- package/src/lexicon-tokens.ts +28 -0
- package/src/orthography.ts +4 -3
- package/src/page-furniture.ts +45 -0
- package/src/plugin.ts +13 -1
- package/src/render/compact.ts +12 -2
- package/src/rule-load.ts +2 -0
- package/src/run.ts +11 -7
- 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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 文末のコロン。後ろに強調の印(**・_)や空白が残っていても見る。 */
|
|
2
|
+
const ENDS_WITH_COLON = /[::][\s*_]*$/u;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 後ろの箇条書きや表へ読者を渡す文(「次のとおりとする。」「The following expenses require receipts:」)。
|
|
6
|
+
* 見出しの語を繰り返していても、中身は後ろにある。言い回しは言語パッケージの語彙表 lead-in が持つ。
|
|
7
|
+
*/
|
|
8
|
+
export const isLeadIn = (sentence: string, phrases: readonly string[]): boolean => {
|
|
9
|
+
const text = sentence.trim().toLowerCase();
|
|
10
|
+
return ENDS_WITH_COLON.test(text) || phrases.some((phrase) => phrase !== "" && text.includes(phrase.toLowerCase()));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** 渡す先(following: 同じ節の、その文より後ろ)が空なら、渡す文ではなくただの繰り返し。 */
|
|
14
|
+
export const handsOver = (sentence: string, following: string, phrases: readonly string[]): boolean => following.trim() !== "" && isLeadIn(sentence, phrases);
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
export type TokenRange = { readonly start: number; readonly end: number };
|
|
36
|
+
|
|
37
|
+
/** 語彙の語が文のどの語の並びに当たったか(始まりと、終わった次の位置)。品詞が無ければ位置を言えないので空。 */
|
|
38
|
+
export const entryRanges = (sentence: Sentence, entry: LexiconEntry): TokenRange[] => {
|
|
39
|
+
const tokens = sentence.tokens;
|
|
40
|
+
const words = entry.tokens;
|
|
41
|
+
if (tokens === undefined || words === undefined || words.length === 0) return [];
|
|
42
|
+
return tokens.flatMap((_token, start) => (runsAt(tokens, words, start) ? [{ start, end: start + words.length }] : []));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const LEADING_MARK = new Set(["PUNCT", "SYM"]);
|
|
46
|
+
|
|
47
|
+
/** 語彙の語で文が始まるか。文頭の記号は飛ばす。品詞が無ければ文字列の前方一致。 */
|
|
48
|
+
export const entryOpens = (sentence: Sentence, entry: LexiconEntry): boolean => {
|
|
49
|
+
const tokens = sentence.tokens;
|
|
50
|
+
const words = entry.tokens;
|
|
51
|
+
if (tokens === undefined || words === undefined || words.length === 0) return proseText(sentence).toLowerCase().startsWith(entry.pattern.toLowerCase());
|
|
52
|
+
const first = tokens.findIndex((token) => !LEADING_MARK.has(token.pos));
|
|
53
|
+
return runsAt(tokens, words, first);
|
|
54
|
+
};
|
package/src/detectors/lexicon.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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, LexiconEntry, ProseDocument, Sentence } from "../plugin.ts";
|
|
4
|
+
import { entryIn, entryOpens, entryRanges } from "./lexicon-match.ts";
|
|
5
|
+
import { scoped, scopeMarkersOf, type ScopeMarkers } from "./superlative-scope.ts";
|
|
5
6
|
|
|
6
7
|
const PER = 1000;
|
|
7
8
|
|
|
@@ -10,11 +11,8 @@ const FLOOR = { word: 200, char: 500 };
|
|
|
10
11
|
|
|
11
12
|
type Hit = { readonly sentence: Sentence; readonly matched: string };
|
|
12
13
|
|
|
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
|
-
});
|
|
14
|
+
const hitsFor = (doc: ProseDocument, lexicon: Lexicon): Hit[] =>
|
|
15
|
+
doc.sentences.flatMap((sentence) => lexicon.filter((entry) => entryIn(sentence, entry)).map((entry) => ({ sentence, matched: entry.pattern })));
|
|
18
16
|
|
|
19
17
|
/**
|
|
20
18
|
* 単位長あたりの出現率。件数で数えると長い文書ほど当たる(bold-density と同じ)。
|
|
@@ -23,8 +21,7 @@ const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
|
|
|
23
21
|
const densityRule =
|
|
24
22
|
(rule: string): Detector =>
|
|
25
23
|
(doc, options): Finding[] => {
|
|
26
|
-
const
|
|
27
|
-
const hits = hitsFor(doc, patterns);
|
|
24
|
+
const hits = hitsFor(doc, options.lexicon ?? []);
|
|
28
25
|
const length = wordsOf(doc);
|
|
29
26
|
const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
|
|
30
27
|
const first = hits[0];
|
|
@@ -42,15 +39,39 @@ const densityRule =
|
|
|
42
39
|
export const hedgingDensity = densityRule("excessive-hedging");
|
|
43
40
|
export const cushionDensity = densityRule("cushion-phrase-density");
|
|
44
41
|
|
|
42
|
+
/** 数字は語ではないので言語を問わない。数があれば測った結果を言っている。品詞の数(NUM)は "the best one" の one まで含むので使わない。 */
|
|
43
|
+
const DIGIT = /\d/u;
|
|
44
|
+
|
|
45
|
+
type Qualifiers = { readonly comparison: Lexicon; readonly scope: ScopeMarkers };
|
|
46
|
+
|
|
47
|
+
const qualifiersOf = (doc: ProseDocument): Qualifiers => ({
|
|
48
|
+
comparison: doc.lexicons["comparison-marker"] ?? [],
|
|
49
|
+
scope: scopeMarkersOf(doc.lexicons["superlative-scope"] ?? []),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** どの出現も範囲を持つときだけ。1 つでも範囲の無い出現があれば、その文には限定の無い最上級がある。 */
|
|
53
|
+
const everyScoped = (sentence: Sentence, entry: LexiconEntry, scope: ScopeMarkers): boolean => {
|
|
54
|
+
const ranges = entryRanges(sentence, entry);
|
|
55
|
+
return ranges.length > 0 && ranges.every((range) => scoped(sentence.tokens ?? [], range, scope));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const qualified = (sentence: Sentence, entry: LexiconEntry, qualifiers: Qualifiers): boolean =>
|
|
59
|
+
DIGIT.test(sentence.text) || qualifiers.comparison.some((marker) => entryIn(sentence, marker)) || everyScoped(sentence, entry, qualifiers.scope);
|
|
60
|
+
|
|
61
|
+
const bareHits = (doc: ProseDocument, lexicon: Lexicon): Hit[] => {
|
|
62
|
+
const qualifiers = qualifiersOf(doc);
|
|
63
|
+
return doc.sentences.flatMap((sentence) =>
|
|
64
|
+
lexicon.filter((entry) => entryIn(sentence, entry) && !qualified(sentence, entry, qualifiers)).map((entry) => ({ sentence, matched: entry.pattern })),
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
45
68
|
/**
|
|
46
69
|
* 限定のない最上級。「最も速い」だけでは、何と比べて最もなのかが無い。
|
|
47
|
-
*
|
|
70
|
+
* 同じ文に比較対象・条件・範囲(日本で最も、the best in the world)があれば、それは主張であって誇張ではない。
|
|
71
|
+
* 比較と範囲を言う語は言語パッケージの語彙表が持つ。
|
|
48
72
|
*/
|
|
49
|
-
const QUALIFIER = /\d|より|に比べ|のうち|among|than|compared|based on|according/iu;
|
|
50
|
-
|
|
51
73
|
export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
|
|
52
|
-
const
|
|
53
|
-
const bare = hitsFor(doc, patterns).filter((hit) => !QUALIFIER.test(hit.sentence.text));
|
|
74
|
+
const bare = bareHits(doc, options.lexicon ?? []);
|
|
54
75
|
if (bare.length < options.limit) return [];
|
|
55
76
|
return bare.map((hit) => ({
|
|
56
77
|
rule: "unqualified-superlative",
|
|
@@ -62,10 +83,10 @@ export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
|
|
|
62
83
|
}));
|
|
63
84
|
};
|
|
64
85
|
|
|
65
|
-
const openerOf = (doc: ProseDocument,
|
|
86
|
+
const openerOf = (doc: ProseDocument, lexicon: Lexicon): (string | undefined)[] =>
|
|
66
87
|
doc.paragraphs.map((paragraph) => {
|
|
67
|
-
const head = paragraph.sentences[0]
|
|
68
|
-
return
|
|
88
|
+
const head = paragraph.sentences[0];
|
|
89
|
+
return head === undefined ? undefined : lexicon.find((entry) => entryOpens(head, entry))?.pattern;
|
|
69
90
|
});
|
|
70
91
|
|
|
71
92
|
/**
|
|
@@ -73,8 +94,7 @@ const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | un
|
|
|
73
94
|
* 前の段落の付け足しに見えて、話がどこへ向かっているのか分からなくなる。
|
|
74
95
|
*/
|
|
75
96
|
export const repeatedConjunction: Detector = (doc, options): Finding[] => {
|
|
76
|
-
const
|
|
77
|
-
const openers = openerOf(doc, patterns);
|
|
97
|
+
const openers = openerOf(doc, options.lexicon ?? []);
|
|
78
98
|
const runs = openers.reduce<{ runs: number[][]; current: number[] }>(
|
|
79
99
|
(acc, opener, index) => {
|
|
80
100
|
if (opener === undefined) return { runs: [...acc.runs, acc.current], current: [] };
|
|
@@ -106,11 +126,10 @@ export const repeatedConjunction: Detector = (doc, options): Finding[] => {
|
|
|
106
126
|
* 単独で断じない。§20.2 の複合シグナルの入口で、これだけで「AI が書いた」とは言わない。
|
|
107
127
|
*/
|
|
108
128
|
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)));
|
|
129
|
+
const entries = [...new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry])).values()];
|
|
130
|
+
const found = entries.filter((entry) => doc.sentences.some((sentence) => entryIn(sentence, entry)));
|
|
131
|
+
const score = Math.round(found.reduce((sum, entry) => sum + (entry.weight ?? 1), 0) * 10);
|
|
132
|
+
const first = doc.sentences.find((sentence) => found.some((entry) => entryIn(sentence, entry)));
|
|
114
133
|
if (first === undefined || score <= options.limit) return [];
|
|
115
134
|
return [
|
|
116
135
|
{
|
|
@@ -121,7 +140,7 @@ export const aiTell: Detector = (doc, options): Finding[] => {
|
|
|
121
140
|
quote: first.text.trim(),
|
|
122
141
|
values: {
|
|
123
142
|
word: joinWords(
|
|
124
|
-
found.map((
|
|
143
|
+
found.map((entry) => entry.pattern.toLowerCase()),
|
|
125
144
|
doc.language,
|
|
126
145
|
),
|
|
127
146
|
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);
|
package/src/detectors/signals.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { proseText } from "../measure.ts";
|
|
2
2
|
import { wordsOf } from "./structure.ts";
|
|
3
|
-
import
|
|
3
|
+
import { compacted, placeOf } from "./gram-place.ts";
|
|
4
|
+
import { notAcronymSpans } from "./acronym-context.ts";
|
|
5
|
+
import { expansionAt, type ExpandedAt } from "./acronym-expansion.ts";
|
|
6
|
+
import type { Detector, Finding, ProseDocument, Section, Sentence, Token } from "../plugin.ts";
|
|
4
7
|
|
|
5
8
|
const PER = 1000;
|
|
6
9
|
|
|
@@ -91,13 +94,44 @@ const gramsOf = (doc: ProseDocument): Map<string, number> => {
|
|
|
91
94
|
return counts;
|
|
92
95
|
};
|
|
93
96
|
|
|
97
|
+
/** 数えたときと同じ形の文。char 単位では空白を詰めてから数えている。 */
|
|
98
|
+
const gramText = (sentence: Sentence, unit: ProseDocument["lengthUnit"]): string => compacted(sentence.text, unit).text;
|
|
99
|
+
|
|
100
|
+
/** 動詞と助動詞。言い回し(ることができます、it is important to)にはこれが入り、主題の名前(state and local tax)には入らない。 */
|
|
101
|
+
const PREDICATE_POS = new Set(["VERB", "AUX"]);
|
|
102
|
+
|
|
103
|
+
const NOUNS = new Set(["NOUN", "PROPN", "ADJ"]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 英語で名詞のすぐ前の動詞は、名詞を飾る語(形容詞を挟んでも同じ)(the upcoming fiscal year、the Disclosing Party、the borrow checker)で、
|
|
107
|
+
* 述語ではない。日本語の名詞の前の動詞(〜にあるコンポーネント)は言い回しの一部になるので、word 単位だけで見る。
|
|
108
|
+
*/
|
|
109
|
+
const modifiesNoun = (tokens: readonly Token[], index: number, unit: ProseDocument["lengthUnit"]): boolean =>
|
|
110
|
+
unit === "word" && tokens[index]?.pos === "VERB" && NOUNS.has(tokens[index + 1]?.pos ?? "");
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 語句が文の中で占める範囲に、動詞か助動詞があるか。範囲で見る。文字で探すと、同じ文の別の所の動詞「use」が、語句の中の名詞「use」に当たる。
|
|
114
|
+
* 品詞が無ければ見分けられないので、これまでどおり言い回しとして数える。
|
|
115
|
+
*/
|
|
116
|
+
const hasPredicate = (gram: string, sentence: Sentence, unit: ProseDocument["lengthUnit"]): boolean => {
|
|
117
|
+
if (sentence.tokens === undefined) return true;
|
|
118
|
+
const place = placeOf(compacted(sentence.text, unit), gram);
|
|
119
|
+
if (place === undefined) return true;
|
|
120
|
+
const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
|
|
121
|
+
const tokens = sentence.tokens;
|
|
122
|
+
return tokens.some((token, index) => PREDICATE_POS.has(token.pos) && token.span.start < end && start < token.span.end && !modifiesNoun(tokens, index, unit));
|
|
123
|
+
};
|
|
124
|
+
|
|
94
125
|
export const ngramRepetition: Detector = (doc, options): Finding[] => {
|
|
95
126
|
if (wordsOf(doc) < FLOOR[doc.lengthUnit]) return [];
|
|
127
|
+
const sentenceWith = (gram: string): Sentence | undefined => doc.sentences.find((sentence) => gramText(sentence, doc.lengthUnit).includes(gram));
|
|
96
128
|
const worst = [...gramsOf(doc).entries()]
|
|
97
|
-
.filter(([gram]) => isPhrasing(gram, doc.lengthUnit))
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
.filter(([gram, count]) => count > options.limit && isPhrasing(gram, doc.lengthUnit))
|
|
130
|
+
.sort(([, left], [, right]) => right - left)
|
|
131
|
+
.map(([gram, count]) => ({ gram, count, at: sentenceWith(gram) }))
|
|
132
|
+
.find(({ gram, at }) => at === undefined || hasPredicate(gram, at, doc.lengthUnit));
|
|
133
|
+
if (worst === undefined) return [];
|
|
134
|
+
const at = worst.at;
|
|
101
135
|
return [
|
|
102
136
|
{
|
|
103
137
|
rule: "ngram-repetition",
|
|
@@ -113,69 +147,77 @@ export const ngramRepetition: Detector = (doc, options): Finding[] => {
|
|
|
113
147
|
/**
|
|
114
148
|
* 略語が初出で展開されているか。「CI」だけでは、読む人によって指すものが違う。
|
|
115
149
|
* 展開は「略語の直前か直後の括弧」か「括弧の中の略語」で書かれる。
|
|
150
|
+
*
|
|
151
|
+
* & で繋いだ大文字は 1 語として読む(ATT&CK、M&IE)。割ると CK や IE が別の略語に見える。
|
|
116
152
|
*/
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const COMMON = new Set([
|
|
121
|
-
"OK",
|
|
122
|
-
"NG",
|
|
123
|
-
"URL",
|
|
124
|
-
"API",
|
|
125
|
-
"CSS",
|
|
126
|
-
"HTML",
|
|
127
|
-
"JSON",
|
|
128
|
-
"YAML",
|
|
129
|
-
"HTTP",
|
|
130
|
-
"HTTPS",
|
|
131
|
-
"PDF",
|
|
132
|
-
"CPU",
|
|
133
|
-
"GPU",
|
|
134
|
-
"RAM",
|
|
135
|
-
"USB",
|
|
136
|
-
"AI",
|
|
137
|
-
"ID",
|
|
138
|
-
"FAQ",
|
|
139
|
-
"PR",
|
|
140
|
-
"OS",
|
|
141
|
-
"CLI",
|
|
142
|
-
"UI",
|
|
143
|
-
"UX",
|
|
144
|
-
"SQL",
|
|
145
|
-
"CSV",
|
|
146
|
-
"XML",
|
|
147
|
-
"TODO",
|
|
148
|
-
"NOTE",
|
|
149
|
-
]);
|
|
153
|
+
const EDGE_BEFORE = String.raw`(?<![A-Za-z0-9_&])`;
|
|
154
|
+
const EDGE_AFTER = String.raw`(?![A-Za-z0-9_&])`;
|
|
155
|
+
const ACRONYM = new RegExp(String.raw`${EDGE_BEFORE}(?:[A-Z]+(?:&[A-Z]+)+|[A-Z]{2,6})${EDGE_AFTER}`, "gu");
|
|
150
156
|
|
|
151
157
|
/**
|
|
152
|
-
*
|
|
153
|
-
* 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
|
|
158
|
+
* 大文字で書かれていても略語ではないもの。
|
|
154
159
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
160
|
+
* 大文字だけの語が 3 つ以上、空白と引用符だけを挟んで続くのは強調(免責の定型文など)。
|
|
161
|
+
* 強調は読点や括弧をまたいで続くので(THE AUTHOR, THE COMPANY (IF ANY))、そういう続きを含む
|
|
162
|
+
* 「大文字以外の文字を含まない一続き」を丸ごと強調と見なす。小文字の語が挟まれば切れるので、
|
|
163
|
+
* 「SRE, SLO, MTTR」のような略語の並びは残る。引用符に包まれた大文字の句(“AS IS”)も強調。
|
|
158
164
|
*/
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
165
|
+
const CAPS_WORD = String.raw`[A-Z]+(?:&[A-Z]+)*`;
|
|
166
|
+
const SHOUTED_RUN = new RegExp(String.raw`${EDGE_BEFORE}${CAPS_WORD}(?:[\s"“”'‘’]+${CAPS_WORD}){2,}${EDGE_AFTER}`, "u");
|
|
167
|
+
const UNCASED_STRETCH = /(?:[A-Z]|\P{L})+/gu;
|
|
168
|
+
const QUOTED_CAPS = new RegExp(String.raw`["“'‘]${CAPS_WORD}(?:\s+${CAPS_WORD})+["”'’]`, "gu");
|
|
169
|
+
|
|
170
|
+
/** RFC 2119 の要件語。定義された普通の語で、1 語でも強調でも現れる。 */
|
|
171
|
+
const REQUIREMENT_WORD = new RegExp(
|
|
172
|
+
String.raw`${EDGE_BEFORE}(?:(?:MUST|SHALL|SHOULD)(?:\s+NOT)?|NOT\s+RECOMMENDED|REQUIRED|RECOMMENDED|MAY|OPTIONAL)${EDGE_AFTER}`,
|
|
173
|
+
"gu",
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/** - か . で繋がり、数字を含む識別子(AC-2、MS.TEAMS.1.1v1)。大文字の部品は略語ではない。 */
|
|
177
|
+
const IDENTIFIER = /(?<![A-Za-z0-9_&.-])[A-Z0-9][A-Za-z0-9]*(?:[.-][A-Z0-9][A-Za-z0-9]*)+/gu;
|
|
178
|
+
|
|
179
|
+
/** Creative Commons のライセンス名。CC と BY は 1 つの名前の部品。 */
|
|
180
|
+
const LICENCE = new RegExp(String.raw`${EDGE_BEFORE}CC BY(?:-(?:SA|NC|ND))*${EDGE_AFTER}`, "gu");
|
|
181
|
+
|
|
182
|
+
type Span = { readonly start: number; readonly end: number };
|
|
183
|
+
|
|
184
|
+
const spanOf = (match: RegExpExecArray): Span => ({ start: match.index, end: match.index + match[0].length });
|
|
185
|
+
|
|
186
|
+
const spansOf = (text: string): Span[] => [
|
|
187
|
+
...[...text.matchAll(UNCASED_STRETCH)].filter((match) => SHOUTED_RUN.test(match[0])).map(spanOf),
|
|
188
|
+
...[...text.matchAll(IDENTIFIER)].filter((match) => /\d/u.test(match[0])).map(spanOf),
|
|
189
|
+
...[QUOTED_CAPS, REQUIREMENT_WORD, LICENCE].flatMap((pattern) => [...text.matchAll(pattern)].map(spanOf)),
|
|
190
|
+
...notAcronymSpans(text),
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
type AcronymHit = { readonly word: string; readonly hit: Hit };
|
|
194
|
+
|
|
195
|
+
const acronymsOf = (doc: ProseDocument): AcronymHit[] =>
|
|
196
|
+
doc.sentences.flatMap((sentence) => {
|
|
197
|
+
const excluded = spansOf(sentence.text);
|
|
198
|
+
return [...sentence.text.matchAll(ACRONYM)]
|
|
199
|
+
.filter((match) => !excluded.some((span) => span.start <= match.index && match.index + match[0].length <= span.end))
|
|
200
|
+
.map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* どこか 1 か所で展開してあればよい。初出が節の見出し代わりの語(「5.3. DPA.」)で、
|
|
205
|
+
* 展開がその直後の文にあることが契約書では普通にある。見るのは語として現れた所だけ(CISA の中の CI は見ない)。
|
|
206
|
+
*/
|
|
207
|
+
const isExpanded = (body: string, acronym: string, expandedAt: ExpandedAt): boolean =>
|
|
208
|
+
[...body.matchAll(new RegExp(`${EDGE_BEFORE}${acronym}${EDGE_AFTER}`, "gu"))].some((match) => expandedAt(body, acronym, match.index));
|
|
209
|
+
|
|
210
|
+
const patternsOf = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
|
|
170
211
|
|
|
171
212
|
export const undefinedAcronym: Detector = (doc, options): Finding[] => {
|
|
172
213
|
const body = bodyOf(doc);
|
|
214
|
+
const common = new Set((options.lexicon ?? []).map((entry) => entry.pattern));
|
|
173
215
|
const seen = new Map<string, Hit>();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (text.length > 0 && !COMMON.has(text) && !seen.has(text)) seen.set(text, hit);
|
|
216
|
+
acronymsOf(doc).forEach(({ word, hit }) => {
|
|
217
|
+
if (!common.has(word) && !seen.has(word)) seen.set(word, hit);
|
|
177
218
|
});
|
|
178
|
-
const
|
|
219
|
+
const expandedAt = expansionAt({ markers: patternsOf(doc, "definition-marker"), verbs: patternsOf(doc, "definition-verb") });
|
|
220
|
+
const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym, expandedAt));
|
|
179
221
|
if (bare.length < options.limit) return [];
|
|
180
222
|
return bare.map(([acronym, hit]) => ({
|
|
181
223
|
rule: "undefined-acronym",
|
|
@@ -193,9 +235,29 @@ export const undefinedAcronym: Detector = (doc, options): Finding[] => {
|
|
|
193
235
|
*/
|
|
194
236
|
const CONCRETE = /\d|`|https?:\/\//u;
|
|
195
237
|
|
|
238
|
+
/**
|
|
239
|
+
* 見出しのない導入部は、書き出しで呼ぶ。文書の言葉なので、message がどの言語でも混ざらない。
|
|
240
|
+
* 決まった呼び名(「この節」)を置くと、英語の文書の message に日本語が入る。
|
|
241
|
+
*/
|
|
242
|
+
const OPENING = { word: 4, char: 12 };
|
|
243
|
+
|
|
244
|
+
const openingOf = (sentence: Sentence | undefined, unit: ProseDocument["lengthUnit"]): string => {
|
|
245
|
+
const text = sentence === undefined ? "" : proseText(sentence);
|
|
246
|
+
const pieces = unit === "word" ? text.split(" ") : Array.from(text);
|
|
247
|
+
const joiner = unit === "word" ? " " : "";
|
|
248
|
+
return pieces.length <= OPENING[unit] ? pieces.join(joiner) : `${pieces.slice(0, OPENING[unit]).join(joiner)}…`;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 形態素解析が数と読んだ語(UD の NumType=Card)。漢数字の「二割」「十五分」も、算用数字と同じ具体的な数。
|
|
253
|
+
* 言い回しの「二人三脚」「一人ひとり」は、辞書が一語として持つので数にならない。品詞が無ければ、数字だけを見る。
|
|
254
|
+
*/
|
|
255
|
+
const hasNumeral = (section: Section): boolean =>
|
|
256
|
+
section.sentences.some((sentence) => (sentence.tokens ?? []).some((token) => token.features?.["NumType"] === "Card"));
|
|
257
|
+
|
|
196
258
|
export const concreteEvidence: Detector = (doc, options): Finding[] => {
|
|
197
259
|
const sections = doc.sections.filter((section) => section.sentences.length >= 3);
|
|
198
|
-
const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)));
|
|
260
|
+
const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)) && !hasNumeral(section));
|
|
199
261
|
if (empty.length < options.limit) return [];
|
|
200
262
|
return empty.map((section) => ({
|
|
201
263
|
rule: "concrete-evidence-density",
|
|
@@ -204,7 +266,7 @@ export const concreteEvidence: Detector = (doc, options): Finding[] => {
|
|
|
204
266
|
column: 0,
|
|
205
267
|
quote: section.heading.length > 0 ? section.heading : (section.sentences[0]?.text.trim() ?? ""),
|
|
206
268
|
values: {
|
|
207
|
-
word: section.heading.length > 0 ? section.heading :
|
|
269
|
+
word: section.heading.length > 0 ? section.heading : openingOf(section.sentences[0], doc.lengthUnit),
|
|
208
270
|
count: empty.length,
|
|
209
271
|
total: sections.length,
|
|
210
272
|
limit: options.limit,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { charLength, lengthOf } from "../measure.ts";
|
|
2
|
-
import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section } from "../plugin.ts";
|
|
2
|
+
import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section, Span } from "../plugin.ts";
|
|
3
|
+
import { dateStampIndexes, stampCandidates } from "../date-stamp.ts";
|
|
4
|
+
import { inDocumentOrder } from "../structure/issues.ts";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* ばらつきは変動係数(標準偏差 ÷ 平均)で測る。
|
|
@@ -90,6 +92,21 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
|
|
|
90
92
|
];
|
|
91
93
|
};
|
|
92
94
|
|
|
95
|
+
const dateSpans = (doc: ProseDocument): Span[] =>
|
|
96
|
+
doc.structure === undefined ? [] : inDocumentOrder(doc.structure).flatMap((node) => (node.kind === "date" ? [node.span] : []));
|
|
97
|
+
|
|
98
|
+
/** 更新日の刻印(「最終更新日:」「2025年6月20日」)は前置きに数えない。木を作るのは、数えて上限を超えたときだけ。 */
|
|
99
|
+
const withoutDateStamps = (doc: ProseDocument, paragraphs: readonly Paragraph[]): Paragraph[] => {
|
|
100
|
+
const labels = (doc.lexicons["date-stamp-label"] ?? []).map((entry) => entry.pattern);
|
|
101
|
+
const candidates = stampCandidates(
|
|
102
|
+
doc.source,
|
|
103
|
+
paragraphs.map((paragraph) => paragraph.span),
|
|
104
|
+
dateSpans(doc),
|
|
105
|
+
);
|
|
106
|
+
const stamps = dateStampIndexes(candidates, labels);
|
|
107
|
+
return paragraphs.filter((_, index) => !stamps.has(index));
|
|
108
|
+
};
|
|
109
|
+
|
|
93
110
|
/**
|
|
94
111
|
* 本題に入るまでが長い。業務文書では、読み手は結論を探しに来ている。
|
|
95
112
|
*
|
|
@@ -99,7 +116,9 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
|
|
|
99
116
|
export const preambleLength: Detector = (doc, options): Finding[] => {
|
|
100
117
|
const body = doc.sections.find((section) => section.depth >= 2);
|
|
101
118
|
if (body === undefined) return [];
|
|
102
|
-
const
|
|
119
|
+
const all = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
|
|
120
|
+
if (all.length <= options.limit) return [];
|
|
121
|
+
const before = withoutDateStamps(doc, all);
|
|
103
122
|
if (before.length <= options.limit) return [];
|
|
104
123
|
const first = before[0];
|
|
105
124
|
return first === undefined
|