chaffjs 0.0.1 → 0.2.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/checks.d.ts +20 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +58 -0
- package/dist/checks.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +14 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts +15 -0
- package/dist/commands/eval.d.ts.map +1 -0
- package/dist/commands/eval.js +44 -0
- package/dist/commands/eval.js.map +1 -0
- package/dist/commands/test.d.ts +21 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +70 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/config/by-path.d.ts +15 -0
- package/dist/config/by-path.d.ts.map +1 -0
- package/dist/config/by-path.js +41 -0
- package/dist/config/by-path.js.map +1 -0
- package/dist/config/load.d.ts +8 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +34 -1
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/count-per-section.d.ts +8 -1
- package/dist/detectors/count-per-section.d.ts.map +1 -1
- package/dist/detectors/count-per-section.js +26 -5
- package/dist/detectors/count-per-section.js.map +1 -1
- package/dist/detectors/sentence-length.d.ts.map +1 -1
- package/dist/detectors/sentence-length.js +3 -9
- package/dist/detectors/sentence-length.js.map +1 -1
- package/dist/detectors/sentence-rhythm.d.ts.map +1 -1
- package/dist/detectors/sentence-rhythm.js +3 -2
- package/dist/detectors/sentence-rhythm.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +21 -6
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts +31 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/eval.js +62 -0
- package/dist/eval.js.map +1 -0
- package/dist/genre.d.ts.map +1 -1
- package/dist/genre.js +8 -1
- package/dist/genre.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +2 -0
- package/dist/init.js.map +1 -1
- package/dist/judge.d.ts +40 -0
- package/dist/judge.d.ts.map +1 -0
- package/dist/judge.js +0 -0
- package/dist/judge.js.map +1 -0
- package/dist/measure.d.ts +15 -0
- package/dist/measure.d.ts.map +1 -0
- package/dist/measure.js +16 -0
- package/dist/measure.js.map +1 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/render/eval.d.ts +7 -0
- package/dist/render/eval.d.ts.map +1 -0
- package/dist/render/eval.js +59 -0
- package/dist/render/eval.js.map +1 -0
- package/dist/render/semantic.d.ts +7 -0
- package/dist/render/semantic.d.ts.map +1 -0
- package/dist/render/semantic.js +50 -0
- package/dist/render/semantic.js.map +1 -0
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +12 -3
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.d.ts +17 -0
- package/dist/run-semantic.d.ts.map +1 -0
- package/dist/run-semantic.js +71 -0
- package/dist/run-semantic.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +4 -0
- package/dist/run.js.map +1 -1
- package/dist/semantic.d.ts +25 -0
- package/dist/semantic.d.ts.map +1 -0
- package/dist/semantic.js +55 -0
- package/dist/semantic.js.map +1 -0
- package/package.json +4 -3
- package/rules/bold-density.yaml +8 -6
- package/rules/empty-conclusion.yaml +37 -0
- package/rules/empty-intensifier.yaml +1 -1
- package/rules/heading-echo.yaml +1 -1
- package/rules/max-sentence-length.yaml +1 -1
- package/rules/repeated-sentence-head.yaml +1 -1
- package/rules/risk-disclosure.yaml +41 -0
- package/rules/unsourced-number.yaml +40 -0
- package/src/checks.ts +74 -0
- package/src/cli.ts +12 -1
- package/src/commands/eval.ts +64 -0
- package/src/commands/test.ts +89 -0
- package/src/config/by-path.ts +47 -0
- package/src/config/load.ts +41 -1
- package/src/detectors/count-per-section.ts +30 -6
- package/src/detectors/sentence-length.ts +4 -10
- package/src/detectors/sentence-rhythm.ts +3 -2
- package/src/document.ts +22 -6
- package/src/eval.ts +80 -0
- package/src/genre.ts +8 -1
- package/src/init.ts +2 -0
- package/src/judge.ts +0 -0
- package/src/measure.ts +21 -0
- package/src/plugin.ts +2 -0
- package/src/render/eval.ts +62 -0
- package/src/render/semantic.ts +60 -0
- package/src/rule-load.ts +14 -3
- package/src/run-semantic.ts +107 -0
- package/src/run.ts +4 -0
- package/src/semantic.ts +74 -0
package/src/genre.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
export type GenreGuess = { readonly genre: string; readonly from: string };
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 仕様書・README は「読ませる文章」ではなく「間違えさせない文章」。
|
|
5
|
+
* 書き出しのつかみもリズムも要らない。技術文書として別に見る。
|
|
6
|
+
*/
|
|
3
7
|
const BY_PATH: readonly (readonly [RegExp, string])[] = [
|
|
8
|
+
[/(^|\/)readme\.md$/iu, "technical/readme"],
|
|
9
|
+
[/spec[^/]*\.md$/iu, "technical/spec"],
|
|
10
|
+
[/(^|\/)(spec|specs)\//iu, "technical/spec"],
|
|
4
11
|
[/(^|\/)(proposals?|teian)\//iu, "business/proposal"],
|
|
5
12
|
[/(^|\/)(minutes|gijiroku)\//iu, "business/meeting-notes"],
|
|
6
13
|
[/(^|\/)(press|pr)\//iu, "business/press-release"],
|
|
7
14
|
[/(^|\/)(reports?)\//iu, "business/report"],
|
|
8
15
|
[/(^|\/)(blog|posts?|articles?)\//iu, "blog/tech"],
|
|
9
|
-
[/(^|\/)docs?\//iu, "
|
|
16
|
+
[/(^|\/)docs?\//iu, "technical/readme"],
|
|
10
17
|
];
|
|
11
18
|
|
|
12
19
|
/**
|
package/src/init.ts
CHANGED
package/src/judge.ts
ADDED
|
Binary file
|
package/src/measure.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Sentence } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 文の長さ。**覆った部分の空白を数えない。**
|
|
5
|
+
*
|
|
6
|
+
* 非 prose は同じ長さの空白で覆っている(オフセットを保つため)。そのまま数えると、
|
|
7
|
+
* URL を 1 つ含む文が「245 文字」になる。実文書で max-sentence-length が指摘の
|
|
8
|
+
* 8 割を占めていた原因はこれで、読み手が長いと感じる長さではなかった。
|
|
9
|
+
*
|
|
10
|
+
* 日本語は文字数、英語は語数。単位は adapter が宣言する。
|
|
11
|
+
*/
|
|
12
|
+
export const proseText = (sentence: Sentence): string => sentence.text.replace(/\s+/gu, " ").trim();
|
|
13
|
+
|
|
14
|
+
export const charLength = (sentence: Sentence): number => sentence.text.replace(/\s+/gu, "").length;
|
|
15
|
+
|
|
16
|
+
export const wordLength = (sentence: Sentence): number =>
|
|
17
|
+
proseText(sentence)
|
|
18
|
+
.split(" ")
|
|
19
|
+
.filter((word) => word.length > 0).length;
|
|
20
|
+
|
|
21
|
+
export const lengthOf = (sentence: Sentence, unit: "char" | "word"): number => (unit === "char" ? charLength(sentence) : wordLength(sentence));
|
package/src/plugin.ts
CHANGED
|
@@ -118,6 +118,8 @@ export type RuleDefinition = {
|
|
|
118
118
|
readonly levels: LevelTable;
|
|
119
119
|
readonly how_to_find: string;
|
|
120
120
|
readonly word_list: string | undefined;
|
|
121
|
+
/** L4 のみ。LLM に渡す決まり。言語別。 */
|
|
122
|
+
readonly what_to_check: Localized | undefined;
|
|
121
123
|
readonly where: string | undefined;
|
|
122
124
|
readonly use_for: readonly string[];
|
|
123
125
|
readonly severity: Severity;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { MIN_CORPUS, TARGET_HIT_RATE, type RuleReport } from "../eval.ts";
|
|
2
|
+
|
|
3
|
+
const pct = (part: number, whole: number): string => `${((part / Math.max(1, whole)) * 100).toFixed(1)}%`;
|
|
4
|
+
|
|
5
|
+
const mark = (report: RuleReport, limit: number): string => {
|
|
6
|
+
if (limit === report.current && limit === report.recommended) return " ← 現在 / 推奨";
|
|
7
|
+
if (limit === report.current) return " ← 現在";
|
|
8
|
+
if (limit === report.recommended) return " ← 推奨";
|
|
9
|
+
return "";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sweepLines = (report: RuleReport): string[] =>
|
|
13
|
+
report.sweep.map((point) => {
|
|
14
|
+
const limit = String(point.limit).padStart(6);
|
|
15
|
+
const docs = `${String(point.documents).padStart(3)} 文書`;
|
|
16
|
+
const density = `1万字あたり ${point.per10k.toFixed(1)}`;
|
|
17
|
+
return ` ${limit} ${docs} (${pct(point.documents, report.total).padStart(6)}) 指摘 ${String(point.findings).padStart(3)} 件 ${density}${mark(report, point.limit)}`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const verdict = (report: RuleReport): string[] => {
|
|
21
|
+
const now = report.sweep.find((point) => point.limit === report.current);
|
|
22
|
+
const rate = now === undefined ? 0 : now.documents / Math.max(1, report.total);
|
|
23
|
+
if (rate <= TARGET_HIT_RATE) return [` いまの閾値で目標(${TARGET_HIT_RATE * 100}% 未満)を満たしています。`];
|
|
24
|
+
if (report.recommended === undefined) {
|
|
25
|
+
return [` どの閾値でも目標を満たしません。この corpus に対して rule 自体が合っていない可能性があります。`];
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
` いまの閾値では ${pct(now?.documents ?? 0, report.total)} の文書が該当し、目標(${TARGET_HIT_RATE * 100}% 未満)を超えています。`,
|
|
29
|
+
` 推奨: ${report.recommended}`,
|
|
30
|
+
"",
|
|
31
|
+
` chaff.yaml に書くなら: rules:\n ${report.rule}: ${report.recommended}`,
|
|
32
|
+
];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const block = (report: RuleReport): string[] => ["", ` ${report.name} (${report.rule})`, "", ...sweepLines(report), "", ...verdict(report), ""];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 閾値は自動で書き換えない。提示して選ばせる。
|
|
39
|
+
* 較正は corpus に対する答えであって、正解ではないため。
|
|
40
|
+
*/
|
|
41
|
+
export const renderEval = (reports: readonly RuleReport[], total: number, paths: number): string =>
|
|
42
|
+
[
|
|
43
|
+
"",
|
|
44
|
+
` ${paths} ファイルを corpus として ${reports.length} 本の rule を測りました。`,
|
|
45
|
+
"",
|
|
46
|
+
" ここにある文書は「人間が書いて公開した良い文書」として扱っています。",
|
|
47
|
+
` そこで多く発火する rule は、閾値が現実に合っていない疑いがあります(目標: ${TARGET_HIT_RATE * 100}% 未満)。`,
|
|
48
|
+
...(total < MIN_CORPUS
|
|
49
|
+
? [
|
|
50
|
+
"",
|
|
51
|
+
` ※ corpus が ${total} 文書しかありません。${TARGET_HIT_RATE * 100}% を表すには ${MIN_CORPUS} 文書以上が要ります。`,
|
|
52
|
+
" いまは割合が「0 か全部」に振れるので、右端の密度(1万字あたりの指摘数)のほうを見てください。",
|
|
53
|
+
]
|
|
54
|
+
: []),
|
|
55
|
+
...reports.flatMap(block),
|
|
56
|
+
" " + "─".repeat(58),
|
|
57
|
+
"",
|
|
58
|
+
` 閾値は自動で書き換えていません。corpus が変われば答えも変わるためです。`,
|
|
59
|
+
"",
|
|
60
|
+
]
|
|
61
|
+
.join("\n")
|
|
62
|
+
.replace(/\{total\}/gu, String(total));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Finding, RuleDefinition } from "../plugin.ts";
|
|
2
|
+
import type { UserCheck } from "../checks.ts";
|
|
3
|
+
import type { SemanticResult } from "../run-semantic.ts";
|
|
4
|
+
import { MARK, localized } from "./text.ts";
|
|
5
|
+
|
|
6
|
+
const RULE = 60;
|
|
7
|
+
|
|
8
|
+
const indent = (text: string, pad: string): string[] => text.split("\n").map((line) => `${pad}${line}`);
|
|
9
|
+
|
|
10
|
+
export const MACHINE_BANNER = ["", `═══ 機械による判定 ${"═".repeat(RULE - 18)}`, " 同じ文章なら何度実行しても同じ結果になります", ""];
|
|
11
|
+
|
|
12
|
+
export const aiBanner = (result: SemanticResult): string[] => [
|
|
13
|
+
"",
|
|
14
|
+
`═══ AI による判定 ${"═".repeat(RULE - 17)}`,
|
|
15
|
+
" 文章の意味を読んでいます。実行するたび結果が変わることが",
|
|
16
|
+
" あります。おかしいと思ったら、そのまま無視して構いません。",
|
|
17
|
+
"",
|
|
18
|
+
` ${result.sentencesSeen} 文のうち ${result.asked} 箇所を読みました(残りは機械が対象外と判断)`,
|
|
19
|
+
"",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
type Named = { readonly name: string; readonly howToFix: string };
|
|
23
|
+
|
|
24
|
+
const nameOf = (rule: string, rules: readonly RuleDefinition[], checks: readonly UserCheck[], language: string): Named => {
|
|
25
|
+
const built = rules.find((entry) => entry.id === rule);
|
|
26
|
+
if (built !== undefined) return { name: localized(built.name, language), howToFix: localized(built.how_to_fix, language) };
|
|
27
|
+
const user = checks.find((entry) => entry.id === rule);
|
|
28
|
+
return { name: user?.name ?? rule, howToFix: user?.how_to_fix ?? "" };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* AI の指摘にだけ確からしさを出し、納得できないときの逃げ道を 2 つ添える。
|
|
33
|
+
* 揺れる判定だと読む人が知らないと、誤検知に振り回される。
|
|
34
|
+
*/
|
|
35
|
+
const block = (finding: Finding, named: Named): string[] => {
|
|
36
|
+
const confidence = finding.values["confidence"];
|
|
37
|
+
const reason = String(finding.values["reason"] ?? "");
|
|
38
|
+
const sure = confidence === undefined ? "" : ` 確からしさ ${String(confidence)}`;
|
|
39
|
+
const fix = named.howToFix.length > 0 ? ["", ...indent(`→ ${named.howToFix}`, " ")] : [];
|
|
40
|
+
return [
|
|
41
|
+
"",
|
|
42
|
+
`─── ${finding.line} 行目 ${"─".repeat(Math.max(0, RULE - String(finding.line).length - 8))}`,
|
|
43
|
+
"",
|
|
44
|
+
` ${MARK[finding.severity] ?? "·"} ${named.name}${sure}`,
|
|
45
|
+
"",
|
|
46
|
+
...indent(reason, " "),
|
|
47
|
+
...fix,
|
|
48
|
+
"",
|
|
49
|
+
" この指摘が違うと思ったら:",
|
|
50
|
+
` この箇所だけ黙らせる <!-- stet: ${finding.rule} — 理由 -->`,
|
|
51
|
+
` ルールごとゆるめる npx chaff relax ${finding.rule}`,
|
|
52
|
+
"",
|
|
53
|
+
];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const renderSemantic = (result: SemanticResult, rules: readonly RuleDefinition[], checks: readonly UserCheck[], language: string): string[] => [
|
|
57
|
+
...aiBanner(result),
|
|
58
|
+
...result.findings.flatMap((finding) => block(finding, nameOf(finding.rule, rules, checks, language))),
|
|
59
|
+
...(result.skipped.length > 0 ? [` ${result.skipped.length} 件の検査は、見るところが無いため動いていません。`, ""] : []),
|
|
60
|
+
];
|
package/src/rule-load.ts
CHANGED
|
@@ -10,15 +10,25 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
|
|
10
10
|
|
|
11
11
|
const isLocalized = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
12
12
|
|
|
13
|
+
const SEVERITY_BY_NAME: Readonly<Record<string, number>> = { info: 1, warning: 2, error: 3 };
|
|
14
|
+
|
|
15
|
+
/** L4 は件数ではなく深刻度を段で持つ。error/warning/info を数に写して同じ器に入れる。 */
|
|
16
|
+
const asNumber = (entry: unknown): number | undefined => {
|
|
17
|
+
if (typeof entry === "number") return entry;
|
|
18
|
+
return typeof entry === "string" ? SEVERITY_BY_NAME[entry] : undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
13
21
|
const isLevelTable = (value: unknown): value is LevelTable =>
|
|
14
|
-
isRecord(value) && Object.entries(value).every(([key, entry]) => ["strict", "normal", "relaxed"].includes(key) &&
|
|
22
|
+
isRecord(value) && Object.entries(value).every(([key, entry]) => ["strict", "normal", "relaxed"].includes(key) && asNumber(entry) !== undefined);
|
|
15
23
|
|
|
16
24
|
/** 言語別の levels を、その言語のぶんだけに平坦化する。単位が言語で違う rule のため。 */
|
|
25
|
+
const normalize = (table: LevelTable): LevelTable => Object.fromEntries(Object.entries(table).map(([key, entry]) => [key, asNumber(entry) ?? 0]));
|
|
26
|
+
|
|
17
27
|
const flattenLevels = (raw: unknown, language: string): LevelTable | undefined => {
|
|
18
|
-
if (isLevelTable(raw)) return raw;
|
|
28
|
+
if (isLevelTable(raw)) return normalize(raw);
|
|
19
29
|
if (!isRecord(raw)) return undefined;
|
|
20
30
|
const forLanguage = raw[language] ?? raw["default"];
|
|
21
|
-
return isLevelTable(forLanguage) ? forLanguage : undefined;
|
|
31
|
+
return isLevelTable(forLanguage) ? normalize(forLanguage) : undefined;
|
|
22
32
|
};
|
|
23
33
|
|
|
24
34
|
const SEVERITIES: readonly Severity[] = ["error", "warning", "info"];
|
|
@@ -65,6 +75,7 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
|
|
|
65
75
|
levels,
|
|
66
76
|
how_to_find: String(raw["how_to_find"]),
|
|
67
77
|
word_list: typeof raw["word_list"] === "string" ? raw["word_list"] : undefined,
|
|
78
|
+
what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
|
|
68
79
|
where: typeof raw["where"] === "string" ? raw["where"] : undefined,
|
|
69
80
|
use_for: Array.isArray(raw["use_for"]) ? raw["use_for"].map((entry) => String(entry)) : [],
|
|
70
81
|
severity: isSeverity(raw["severity"]) ? raw["severity"] : "warning",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { severityOf, type UserCheck } from "./checks.ts";
|
|
2
|
+
import { ask, type JudgeOptions } from "./judge.ts";
|
|
3
|
+
import { FILTERS, type Candidate } from "./semantic.ts";
|
|
4
|
+
import { localized } from "./render/text.ts";
|
|
5
|
+
import type { Finding, Level, ProseDocument, RuleDefinition, Severity } from "./plugin.ts";
|
|
6
|
+
import { lineStarts, placeOf } from "./position.ts";
|
|
7
|
+
|
|
8
|
+
export type SemanticResult = {
|
|
9
|
+
readonly findings: readonly Finding[];
|
|
10
|
+
readonly skipped: readonly { rule: string; why: string }[];
|
|
11
|
+
readonly asked: number;
|
|
12
|
+
readonly sentencesSeen: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Job = {
|
|
16
|
+
readonly rule: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly rubric: string;
|
|
19
|
+
readonly howToFix: string;
|
|
20
|
+
readonly severity: Severity;
|
|
21
|
+
readonly candidates: readonly Candidate[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const filterFor = (name: string | undefined): (typeof FILTERS)[string] => FILTERS[name ?? "whole-document"] ?? FILTERS["whole-document"] ?? (() => []);
|
|
25
|
+
|
|
26
|
+
const SEVERITY_BY_VALUE: readonly Severity[] = ["info", "info", "warning", "error"];
|
|
27
|
+
|
|
28
|
+
const severityFromLevel = (rule: RuleDefinition, level: Level): Severity => {
|
|
29
|
+
if (level === "off") return "info";
|
|
30
|
+
const value = rule.levels[level] ?? rule.levels.normal ?? 2;
|
|
31
|
+
return SEVERITY_BY_VALUE[Math.min(3, Math.max(1, Math.round(value)))] ?? "warning";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const builtInJobs = (doc: ProseDocument, rules: readonly RuleDefinition[], settings: Readonly<Record<string, Level>>, genre: string): Job[] =>
|
|
35
|
+
rules
|
|
36
|
+
.filter((rule) => rule.layer === "L4" && rule.use_for.some((target) => genre.startsWith(target)) && settings[rule.id] !== "off")
|
|
37
|
+
.flatMap((rule) => {
|
|
38
|
+
const rubric = rule.what_to_check === undefined ? undefined : localized(rule.what_to_check, doc.language);
|
|
39
|
+
if (rubric === undefined || rubric.length === 0) return [];
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
rule: rule.id,
|
|
43
|
+
name: localized(rule.name, doc.language),
|
|
44
|
+
rubric,
|
|
45
|
+
howToFix: localized(rule.how_to_fix, doc.language),
|
|
46
|
+
severity: severityFromLevel(rule, settings[rule.id] ?? "normal"),
|
|
47
|
+
candidates: filterFor(rule.how_to_find)(doc),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const userJobs = (doc: ProseDocument, checks: readonly UserCheck[], genre: string): Job[] =>
|
|
53
|
+
checks
|
|
54
|
+
.filter((check) => check.level !== "off" && check.use_for.some((target) => genre.startsWith(target)))
|
|
55
|
+
.map((check) => ({
|
|
56
|
+
rule: check.id,
|
|
57
|
+
name: check.name,
|
|
58
|
+
// look_at は自然文なので、まだ絞り込みに変換できない。文書全体を渡す。spec §26-7。
|
|
59
|
+
rubric: check.look_at === undefined ? check.check : `${check.check}\n\n見るところ: ${check.look_at}`,
|
|
60
|
+
howToFix: check.how_to_fix,
|
|
61
|
+
severity: severityOf(check.level),
|
|
62
|
+
candidates: filterFor("whole-document")(doc),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const findingOf = (job: Job, candidate: Candidate, reason: string, confidence: number, threshold: number, starts: readonly number[]): Finding => {
|
|
66
|
+
const at = placeOf(starts, candidate.offset);
|
|
67
|
+
// 確からしさが低いものは指摘として出すが、重さを下げる。spec §14。
|
|
68
|
+
const severity = confidence < threshold ? "info" : job.severity;
|
|
69
|
+
return {
|
|
70
|
+
rule: job.rule,
|
|
71
|
+
severity,
|
|
72
|
+
line: at.line,
|
|
73
|
+
column: at.column,
|
|
74
|
+
quote: candidate.text.slice(0, 200),
|
|
75
|
+
values: { reason, confidence: confidence.toFixed(2), offset: candidate.offset },
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type SemanticOptions = JudgeOptions & { readonly confidenceThreshold: number };
|
|
80
|
+
|
|
81
|
+
export const runSemantic = async (
|
|
82
|
+
doc: ProseDocument,
|
|
83
|
+
rules: readonly RuleDefinition[],
|
|
84
|
+
checks: readonly UserCheck[],
|
|
85
|
+
settings: Readonly<Record<string, Level>>,
|
|
86
|
+
genre: string,
|
|
87
|
+
options: SemanticOptions,
|
|
88
|
+
): Promise<SemanticResult> => {
|
|
89
|
+
const jobs = [...builtInJobs(doc, rules, settings, genre), ...userJobs(doc, checks, genre)];
|
|
90
|
+
const starts = lineStarts(doc.source);
|
|
91
|
+
const skipped = jobs.filter((job) => job.candidates.length === 0).map((job) => ({ rule: job.rule, why: "見るところが無かったため" }));
|
|
92
|
+
const live = jobs.filter((job) => job.candidates.length > 0);
|
|
93
|
+
const answers = await Promise.all(
|
|
94
|
+
live.flatMap((job) =>
|
|
95
|
+
job.candidates.map(async (candidate) => {
|
|
96
|
+
const verdict = await ask({ rule: job.rule, rubric: job.rubric, candidate: candidate.text, language: doc.language }, options);
|
|
97
|
+
return verdict.violated ? findingOf(job, candidate, verdict.reason, verdict.confidence, options.confidenceThreshold, starts) : undefined;
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
findings: answers.filter((finding) => finding !== undefined).sort((left, right) => left.line - right.line),
|
|
103
|
+
skipped,
|
|
104
|
+
asked: live.reduce((sum, job) => sum + job.candidates.length, 0),
|
|
105
|
+
sentencesSeen: doc.sentences.length,
|
|
106
|
+
};
|
|
107
|
+
};
|
package/src/run.ts
CHANGED
|
@@ -36,6 +36,10 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
|
|
|
36
36
|
.map((rule) => rule.id);
|
|
37
37
|
const outcome = applicable.reduce<{ findings: Finding[]; skipped: Skipped[] }>(
|
|
38
38
|
(acc, rule) => {
|
|
39
|
+
// L4 は意味を読む検査。chaff test が扱う。ここで「検出器が無い」と言わせない。
|
|
40
|
+
if (rule.layer === "L4") {
|
|
41
|
+
return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: "意味を読む検査のため(npx chaff test で動きます)" }] };
|
|
42
|
+
}
|
|
39
43
|
const level = levelFor(rule, settings, experimental);
|
|
40
44
|
if (level === "off") {
|
|
41
45
|
const why = rule.status === "experimental" && settings[rule.id] === undefined ? "まだ試験中のため" : "設定で止めているため";
|
package/src/semantic.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ProseDocument, Section } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 二段構えの 1 段目。文書全体を LLM に投げない。spec §14。
|
|
5
|
+
*
|
|
6
|
+
* 絞り込みを持たない L4 rule は登録できない。持たせないと、1 記事ごとに全文を
|
|
7
|
+
* 読ませることになり、速度もコストも成り立たない。
|
|
8
|
+
*/
|
|
9
|
+
export type Candidate = { readonly text: string; readonly offset: number };
|
|
10
|
+
|
|
11
|
+
export type Filter = (doc: ProseDocument) => Candidate[];
|
|
12
|
+
|
|
13
|
+
const heads = (doc: ProseDocument): string => doc.sections.map((section) => section.heading).join(" ");
|
|
14
|
+
|
|
15
|
+
const sectionText = (doc: ProseDocument, section: Section): string => doc.source.slice(section.span.start, section.span.end).trim();
|
|
16
|
+
|
|
17
|
+
/** 見出しに答えが書いてあるなら、本文を読ませる必要はない。 */
|
|
18
|
+
const skipIfHeadingMatches = (doc: ProseDocument, pattern: RegExp): boolean => pattern.test(heads(doc));
|
|
19
|
+
|
|
20
|
+
const RISK_HEADING = /リスク|懸念|デメリット|risk|tradeoff|downside/iu;
|
|
21
|
+
|
|
22
|
+
/** 提案文書にリスクが書かれているか。見出しにあれば見ない。 */
|
|
23
|
+
export const riskDisclosure: Filter = (doc) => {
|
|
24
|
+
if (skipIfHeadingMatches(doc, RISK_HEADING)) return [];
|
|
25
|
+
const body = doc.sentences.map((sentence) => sentence.text.trim()).join("");
|
|
26
|
+
if (body.length === 0) return [];
|
|
27
|
+
return [{ text: body.slice(0, 4000), offset: doc.sentences[0]?.span.start ?? 0 }];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const EVIDENCE = /\d|`|https?:\/\//u;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 結びが本文の要約でしかないか。
|
|
34
|
+
* 最後の節だけを見て、そこに具体物が無いときにだけ問い合わせる。
|
|
35
|
+
*/
|
|
36
|
+
export const emptyConclusion: Filter = (doc) => {
|
|
37
|
+
const last = doc.sections.at(-1);
|
|
38
|
+
if (last === undefined || last.sentences.length === 0) return [];
|
|
39
|
+
const text = sectionText(doc, last);
|
|
40
|
+
if (EVIDENCE.test(text)) return [];
|
|
41
|
+
return [{ text: text.slice(0, 2000), offset: last.sentences[0]?.span.start ?? last.span.start }];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// \s* を挟むとバックトラックする。単位の前の空白は 1 つまでで足りる。
|
|
45
|
+
const NUMBER = /\d ?(?:%|%|倍|割|件|人|円|ドル|時間|分|秒)/u;
|
|
46
|
+
const EFFECT = /向上|改善|削減|短縮|増加|減少|成長|伸び/u;
|
|
47
|
+
/**
|
|
48
|
+
* 根拠と認めるもの。ここを狭く取ると、根拠つきの文まで LLM に送ってしまう。
|
|
49
|
+
* 二段構えの 1 段目が防ぐべきなのは、まさにその無駄。
|
|
50
|
+
*/
|
|
51
|
+
const EVIDENCE_WORDS = ["出典", "調査", "自社調べ", "参考", "期間", "比較", "対比", "前年", "昨年", "同期", "前月", "前四半期", "vs"];
|
|
52
|
+
const EVIDENCE_SHAPE = /n ?=|\[\d+\]|\d{4} ?年|Q[1-4]/iu;
|
|
53
|
+
|
|
54
|
+
/** 語の照合は正規表現にしない。数が増えるほど式が読めなくなり、複雑度も上がる。 */
|
|
55
|
+
const hasEvidence = (text: string): boolean => EVIDENCE_WORDS.some((word) => text.includes(word)) || EVIDENCE_SHAPE.test(text);
|
|
56
|
+
|
|
57
|
+
/** 効果を主張する数値に根拠が無い文だけを渡す。 */
|
|
58
|
+
export const unsourcedNumber: Filter = (doc) =>
|
|
59
|
+
doc.sentences
|
|
60
|
+
.filter((sentence) => NUMBER.test(sentence.text) && EFFECT.test(sentence.text) && !hasEvidence(sentence.text))
|
|
61
|
+
.map((sentence) => ({ text: sentence.text.trim(), offset: sentence.span.start }));
|
|
62
|
+
|
|
63
|
+
/** 利用者が checks.yaml に書いた検査の既定。絞り込みを書かなければ文書全体。 */
|
|
64
|
+
export const wholeDocument: Filter = (doc) => {
|
|
65
|
+
const body = doc.sentences.map((sentence) => sentence.text.trim()).join("");
|
|
66
|
+
return body.length === 0 ? [] : [{ text: body.slice(0, 4000), offset: doc.sentences[0]?.span.start ?? 0 }];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const FILTERS: Readonly<Record<string, Filter>> = {
|
|
70
|
+
"risk-disclosure": riskDisclosure,
|
|
71
|
+
"empty-conclusion": emptyConclusion,
|
|
72
|
+
"unsourced-number": unsourcedNumber,
|
|
73
|
+
"whole-document": wholeDocument,
|
|
74
|
+
};
|