Skip to content

Commit c9b09b4

Browse files
committed
feat: add no-ai-colon-continuation rule with kuromojin morphological analysis
- Implement new rule to detect English syntax patterns translated to Japanese - Use kuromojin for accurate part-of-speech analysis to distinguish nouns from predicates - Allow natural Japanese expressions like '使い方:' (noun + colon) - Detect unnatural patterns like '実行します:' (predicate + colon) - Support AST-based detection for Paragraph + block element combinations - Remove duplicate colon detection from ai-tech-writing-guideline rule - Add comprehensive test coverage for various patterns Fixes #16
1 parent 4f87500 commit c9b09b4

File tree

7 files changed

+301
-50
lines changed

7 files changed

+301
-50
lines changed

package-lock.json

Lines changed: 1 addition & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
},
7070
"dependencies": {
7171
"@textlint/regexp-string-matcher": "^2.0.2",
72+
"kuromojin": "^3.0.1",
7273
"textlint-util-to-string": "^3.3.4"
7374
},
7475
"packageManager": "npm@10.9.2+sha512.8ab88f10f224a0c614cb717a7f7c30499014f77134120e9c1f0211ea3cf3397592cbe483feb38e0c4b3be1c54e347292c76a1b5edb94a3289d5448484ab8ac81"

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,24 @@ import noAiListFormatting from "./rules/no-ai-list-formatting";
22
import noAiHypeExpressions from "./rules/no-ai-hype-expressions";
33
import noAiEmphasisPatterns from "./rules/no-ai-emphasis-patterns";
44
import aiTechWritingGuideline from "./rules/ai-tech-writing-guideline";
5+
import noAiColonContinuation from "./rules/no-ai-colon-continuation";
56

67
const preset = {
78
rules: {
89
"no-ai-list-formatting": noAiListFormatting,
910
"no-ai-hype-expressions": noAiHypeExpressions,
1011
"no-ai-emphasis-patterns": noAiEmphasisPatterns,
11-
"ai-tech-writing-guideline": aiTechWritingGuideline
12+
"ai-tech-writing-guideline": aiTechWritingGuideline,
13+
"no-ai-colon-continuation": noAiColonContinuation
1214
},
1315
rulesConfig: {
1416
"no-ai-list-formatting": true,
1517
"no-ai-hype-expressions": true,
1618
"no-ai-emphasis-patterns": true,
1719
"ai-tech-writing-guideline": {
1820
severity: "info"
19-
}
21+
},
22+
"no-ai-colon-continuation": true
2023
}
2124
};
2225

src/rules/ai-tech-writing-guideline.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ const rule: TextlintRuleModule<Options> = (context, options = {}) => {
188188

189189
/**
190190
* 機械的な段落と箇条書きの組み合わせパターンを検出
191+
* 注意: コロン関連のパターンは no-ai-colon-continuation ルールで処理されます
191192
*/
192193
const detectMechanicalListIntroPattern = (node: any) => {
193194
const children = node.children || [];
@@ -213,14 +214,10 @@ const rule: TextlintRuleModule<Options> = (context, options = {}) => {
213214
let isDetected = false;
214215
let message = "";
215216

216-
// パターン1: コロン(:、:)で終わる段落
217-
if (/[:][\s]*$/.test(paragraphText.trim())) {
218-
isDetected = true;
219-
message =
220-
"【構造化】コロン(:)で終わる文の直後の箇条書きは機械的な印象を与える可能性があります。「たとえば、次のような点があります。」のような導入文を使った自然な表現を検討してください。";
221-
}
222-
// パターン2: 「例えば。」「具体的には。」など、接続表現+句点で終わる段落
223-
else if (/(?:|||||)[\s]*$/.test(paragraphText.trim())) {
217+
// 注意: コロンパターンは no-ai-colon-continuation で処理されるため削除
218+
219+
// パターン: 「例えば。」「具体的には。」など、接続表現+句点で終わる段落
220+
if (/(?:|||||)[\s]*$/.test(paragraphText.trim())) {
224221
isDetected = true;
225222
message =
226223
"【構造化】接続表現と句点で終わる文の直後の箇条書きは機械的な印象を与える可能性があります。「たとえば、次のような点があります。」のような自然な導入文を検討してください。";
@@ -254,7 +251,8 @@ const rule: TextlintRuleModule<Options> = (context, options = {}) => {
254251
return;
255252
}
256253

257-
// コロン + 箇条書きパターンの検出
254+
// 接続表現 + 箇条書きパターンの検出
255+
// 注意: コロン + ブロック要素パターンは no-ai-colon-continuation ルールで処理
258256
detectMechanicalListIntroPattern(node);
259257
// 将来的にここに他の文書レベルの構造化パターンを追加できます
260258
// 例:

src/rules/no-ai-colon-continuation.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { matchPatterns } from "@textlint/regexp-string-matcher";
2+
import { StringSource } from "textlint-util-to-string";
3+
import { tokenize } from "kuromojin";
4+
5+
/**
6+
* コロンの直後にブロック要素が続くパターンを検出するルール
7+
*
8+
* 目的:
9+
* AI生成文章でよく見られる英語構文の直訳パターンを検出します。
10+
* 例:「使用方法:」の直後にコードブロックや箇条書きが続く構文は、
11+
* 英語の "Usage:" を直訳したもので、日本語としては不自然な場合があります。
12+
*
13+
* より自然な日本語表現:
14+
* - 「使用方法は以下の通りです」
15+
* - 「次のように使用します」
16+
* - 「以下の手順で実行してください」
17+
*/
18+
const rule = (context: any, options: any = {}) => {
19+
const { Syntax, RuleError, report, getSource, locator } = context;
20+
const allows = options.allows ?? [];
21+
const disableCodeBlock = options.disableCodeBlock ?? false;
22+
const disableList = options.disableList ?? false;
23+
const disableQuote = options.disableQuote ?? false;
24+
const disableTable = options.disableTable ?? false;
25+
26+
// AST走査で隣接するノードの組み合わせをチェック
27+
28+
// 例外的な名詞表現(Intl.Segmenterで正しく判定できない特殊なケース)
29+
const exceptionNounExpressions = [
30+
// 複合名詞でセグメンテーションが難しいもの
31+
"使用方法",
32+
"実行方法",
33+
"設定方法",
34+
"操作方法",
35+
"API仕様",
36+
"システム仕様"
37+
];
38+
39+
const checkColonContinuation = async (paragraphNode: any, nextNode: any) => {
40+
// Paragraphノードのテキストを取得
41+
const paragraphText = getSource(paragraphNode);
42+
43+
// Check if text matches any allowed patterns
44+
if (allows.length > 0) {
45+
const matches = matchPatterns(paragraphText, allows);
46+
if (matches.length > 0) {
47+
return;
48+
}
49+
}
50+
51+
// コロンで終わっているかチェック
52+
if (!paragraphText.trim().endsWith(":")) {
53+
return;
54+
}
55+
56+
// StringSourceを使ってMarkdownを取り除いたテキストを取得
57+
const stringSource = new StringSource(paragraphNode);
58+
const plainText = stringSource.toString().trim();
59+
60+
// プレーンテキストでもコロンで終わっているかチェック
61+
if (!plainText.endsWith(":")) {
62+
return;
63+
}
64+
65+
// コロンを除いたテキストを取得
66+
const beforeColonText = plainText.slice(0, -1);
67+
68+
// kuromojinで形態素解析を行い、名詞で終わっているかを判定
69+
const isNoun = await (async () => {
70+
// 英語のテキストかどうかを判定(英語の場合はコロンが自然なので許可)
71+
const isEnglishText =
72+
/^[a-zA-Z0-9\s\-_.]+$/.test(beforeColonText.trim()) && /[a-zA-Z]/.test(beforeColonText);
73+
if (isEnglishText) {
74+
return true; // 英語テキストの場合は許可
75+
}
76+
77+
// 短すぎる場合(1-2文字)は名詞として扱う
78+
if (beforeColonText.length <= 2) {
79+
return true;
80+
}
81+
82+
try {
83+
// kuromojinで形態素解析
84+
const tokens = await tokenize(beforeColonText);
85+
86+
if (tokens.length === 0) {
87+
return true; // 解析できない場合は許可
88+
}
89+
90+
// 最後のトークンの品詞をチェック
91+
const lastToken = tokens[tokens.length - 1];
92+
const partOfSpeech = lastToken.pos.split(",")[0]; // 大分類を取得
93+
94+
// 名詞で終わっている場合は許可
95+
if (partOfSpeech === "名詞") {
96+
return true;
97+
}
98+
99+
// 動詞、形容詞、助動詞で終わっている場合は述語として判定
100+
if (["動詞", "形容詞", "助動詞"].includes(partOfSpeech)) {
101+
return false;
102+
}
103+
104+
// その他の品詞(助詞、接続詞等)の場合は文脈による
105+
// より保守的にエラーとする
106+
return false;
107+
} catch (error) {
108+
// 形態素解析でエラーが発生した場合は例外的な名詞表現のチェック
109+
return exceptionNounExpressions.some((expr) => beforeColonText === expr);
110+
}
111+
})();
112+
113+
if (isNoun) {
114+
return; // 名詞の場合はエラーにしない
115+
}
116+
117+
// 次のノードの種類をチェック
118+
const shouldReport = (() => {
119+
if (!nextNode) return false;
120+
121+
switch (nextNode.type) {
122+
case Syntax.CodeBlock:
123+
return !disableCodeBlock;
124+
case Syntax.List:
125+
return !disableList;
126+
case Syntax.BlockQuote:
127+
return !disableQuote;
128+
case Syntax.Table:
129+
return !disableTable;
130+
default:
131+
return false;
132+
}
133+
})();
134+
135+
if (shouldReport) {
136+
const colonIndex = paragraphText.lastIndexOf(":");
137+
const matchRange = [colonIndex, colonIndex + 1] as const;
138+
139+
const ruleError = new RuleError(
140+
`「${beforeColonText}:」のようなパターンは英語構文の直訳の可能性があります。より自然な日本語表現を検討してください。`,
141+
{
142+
padding: locator.range(matchRange)
143+
}
144+
);
145+
report(paragraphNode, ruleError);
146+
}
147+
};
148+
149+
return {
150+
async [Syntax.Document](node: any) {
151+
// ドキュメントの子ノードを順番にチェック
152+
for (let i = 0; i < node.children.length - 1; i++) {
153+
const currentNode = node.children[i];
154+
const nextNode = node.children[i + 1];
155+
156+
// Paragraphノードの後にブロック要素が続く場合をチェック
157+
if (currentNode.type === Syntax.Paragraph) {
158+
await checkColonContinuation(currentNode, nextNode);
159+
}
160+
}
161+
}
162+
};
163+
};
164+
165+
export default rule;

0 commit comments

Comments
 (0)