Skip to content

feat: add no-ai-colon-continuation rule with kuromojin morphological analysis #17

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 5, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 1 addition & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
},
"dependencies": {
"@textlint/regexp-string-matcher": "^2.0.2",
"kuromojin": "^3.0.1",
"textlint-util-to-string": "^3.3.4"
},
"packageManager": "npm@10.9.2+sha512.8ab88f10f224a0c614cb717a7f7c30499014f77134120e9c1f0211ea3cf3397592cbe483feb38e0c4b3be1c54e347292c76a1b5edb94a3289d5448484ab8ac81"
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ import noAiListFormatting from "./rules/no-ai-list-formatting";
import noAiHypeExpressions from "./rules/no-ai-hype-expressions";
import noAiEmphasisPatterns from "./rules/no-ai-emphasis-patterns";
import aiTechWritingGuideline from "./rules/ai-tech-writing-guideline";
import noAiColonContinuation from "./rules/no-ai-colon-continuation";

const preset = {
rules: {
"no-ai-list-formatting": noAiListFormatting,
"no-ai-hype-expressions": noAiHypeExpressions,
"no-ai-emphasis-patterns": noAiEmphasisPatterns,
"ai-tech-writing-guideline": aiTechWritingGuideline
"ai-tech-writing-guideline": aiTechWritingGuideline,
"no-ai-colon-continuation": noAiColonContinuation
},
rulesConfig: {
"no-ai-list-formatting": true,
"no-ai-hype-expressions": true,
"no-ai-emphasis-patterns": true,
"ai-tech-writing-guideline": {
severity: "info"
}
},
"no-ai-colon-continuation": true
}
};

Expand Down
16 changes: 7 additions & 9 deletions src/rules/ai-tech-writing-guideline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ const rule: TextlintRuleModule<Options> = (context, options = {}) => {

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

// パターン1: コロン(:、:)で終わる段落
if (/[::][\s]*$/.test(paragraphText.trim())) {
isDetected = true;
message =
"【構造化】コロン(:)で終わる文の直後の箇条書きは機械的な印象を与える可能性があります。「たとえば、次のような点があります。」のような導入文を使った自然な表現を検討してください。";
}
// パターン2: 「例えば。」「具体的には。」など、接続表現+句点で終わる段落
else if (/(?:例えば|具体的には|詳細には|以下|次に|また)。[\s]*$/.test(paragraphText.trim())) {
// 注意: コロンパターンは no-ai-colon-continuation で処理されるため削除

// パターン: 「例えば。」「具体的には。」など、接続表現+句点で終わる段落
if (/(?:例えば|具体的には|詳細には|以下|次に|また)。[\s]*$/.test(paragraphText.trim())) {
isDetected = true;
message =
"【構造化】接続表現と句点で終わる文の直後の箇条書きは機械的な印象を与える可能性があります。「たとえば、次のような点があります。」のような自然な導入文を検討してください。";
Expand Down Expand Up @@ -254,7 +251,8 @@ const rule: TextlintRuleModule<Options> = (context, options = {}) => {
return;
}

// コロン + 箇条書きパターンの検出
// 接続表現 + 箇条書きパターンの検出
// 注意: コロン + ブロック要素パターンは no-ai-colon-continuation ルールで処理
detectMechanicalListIntroPattern(node);
// 将来的にここに他の文書レベルの構造化パターンを追加できます
// 例:
Expand Down
175 changes: 175 additions & 0 deletions src/rules/no-ai-colon-continuation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { matchPatterns } from "@textlint/regexp-string-matcher";
import { StringSource } from "textlint-util-to-string";
import { tokenize } from "kuromojin";

/**
* コロンの直後にブロック要素が続くパターンを検出するルール
*
* 目的:
* AI生成文章でよく見られる英語構文の直訳パターンを検出します。
* 例:「使用方法:」の直後にコードブロックや箇条書きが続く構文は、
* 英語の "Usage:" を直訳したもので、日本語としては不自然な場合があります。
*
* より自然な日本語表現:
* - 「使用方法は以下の通りです」
* - 「次のように使用します」
* - 「以下の手順で実行してください」
*/
const rule = (context: any, options: any = {}) => {
const { Syntax, RuleError, report, getSource, locator } = context;
const allows = options.allows ?? [];
const disableCodeBlock = options.disableCodeBlock ?? false;
const disableList = options.disableList ?? false;
const disableQuote = options.disableQuote ?? false;
const disableTable = options.disableTable ?? false;

// AST走査で隣接するノードの組み合わせをチェック

// 例外的な名詞表現(Intl.Segmenterで正しく判定できない特殊なケース)
const exceptionNounExpressions = [
// 複合名詞でセグメンテーションが難しいもの
"使用方法",
"実行方法",
"設定方法",
"操作方法",
"API仕様",
"システム仕様"
];

const checkColonContinuation = async (paragraphNode: any, nextNode: any) => {
// Paragraphノードのテキストを取得
const paragraphText = getSource(paragraphNode);

// Check if text matches any allowed patterns
if (allows.length > 0) {
const matches = matchPatterns(paragraphText, allows);
if (matches.length > 0) {
return;
}
}

// コロン(半角・全角)で終わっているかチェック
if (!/[::]$/.test(paragraphText.trim())) {
return;
}

// StringSourceを使ってMarkdownを取り除いたテキストを取得
const stringSource = new StringSource(paragraphNode);
const plainText = stringSource.toString().trim();

// プレーンテキストでもコロン(半角・全角)で終わっているかチェック
if (!/[::]$/.test(plainText)) {
return;
}

// コロンの種類を特定
const isFullWidthColon = plainText.endsWith(":");
const colonChar = isFullWidthColon ? ":" : ":";

// コロンを除いたテキストを取得
const beforeColonText = plainText.slice(0, -1);

// kuromojinで形態素解析を行い、名詞で終わっているかを判定
const isNoun = await (async () => {
// 英語のテキストかどうかを判定(英語の場合はコロンが自然なので許可)
const isEnglishText =
/^[a-zA-Z0-9\s\-_.]+$/.test(beforeColonText.trim()) && /[a-zA-Z]/.test(beforeColonText);
if (isEnglishText) {
return true; // 英語テキストの場合は許可
}

// 短すぎる場合(1-2文字)は名詞として扱う
if (beforeColonText.length <= 2) {
return true;
}

try {
// kuromojinで形態素解析
const tokens = await tokenize(beforeColonText);

if (tokens.length === 0) {
return true; // 解析できない場合は許可
}

// 最後のトークンの品詞をチェック
const lastToken = tokens[tokens.length - 1];
const partOfSpeech = lastToken.pos.split(",")[0]; // 大分類を取得

// 名詞で終わっている場合は許可
if (partOfSpeech === "名詞") {
return true;
}

// 動詞、形容詞、助動詞で終わっている場合は述語として判定
if (["動詞", "形容詞", "助動詞"].includes(partOfSpeech)) {
return false;
}

// 接続詞の場合もエラーとする(「例えば:」等は機械的パターン)
if (partOfSpeech === "接続詞") {
return false;
}

// その他の品詞(助詞等)の場合は文脈による
// より保守的にエラーとする
return false;
} catch (error) {
// 形態素解析でエラーが発生した場合は例外的な名詞表現のチェック
return exceptionNounExpressions.some((expr) => beforeColonText === expr);
}
})();

if (isNoun) {
return; // 名詞の場合はエラーにしない
}

// 次のノードの種類をチェック
const shouldReport = (() => {
if (!nextNode) return false;

switch (nextNode.type) {
case Syntax.CodeBlock:
return !disableCodeBlock;
case Syntax.List:
return !disableList;
case Syntax.BlockQuote:
return !disableQuote;
case Syntax.Table:
return !disableTable;
default:
return false;
}
})();

if (shouldReport) {
// paragraphTextでのコロン位置を計算(StringSourceとの位置差を考慮)
const paragraphColonIndex = paragraphText.lastIndexOf(colonChar);
const matchRange = [paragraphColonIndex, paragraphColonIndex + 1] as const;

const ruleError = new RuleError(
`「${beforeColonText}${colonChar}」のようなパターンは英語構文の直訳の可能性があります。より自然な日本語表現を検討してください。`,
{
padding: locator.range(matchRange)
}
);
report(paragraphNode, ruleError);
}
};

return {
async [Syntax.Document](node: any) {
// ドキュメントの子ノードを順番にチェック
for (let i = 0; i < node.children.length - 1; i++) {
const currentNode = node.children[i];
const nextNode = node.children[i + 1];

// Paragraphノードの後にブロック要素が続く場合をチェック
if (currentNode.type === Syntax.Paragraph) {
await checkColonContinuation(currentNode, nextNode);
}
}
}
};
};

export default rule;
Loading