From 9a8f46da9a7c7cf75bb70075e899f22b16b70f7f Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 14 Jul 2026 10:06:09 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(corpus):=20=E8=A6=8B=E5=87=BA=E3=81=97?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=9E=8B=E6=8A=BD=E5=87=BA=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=81=A8=E4=BA=BA=E9=96=93=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=91=E3=82=B9=E3=81=AE2022=E5=B9=B4=E4=BB=A5=E5=89=8D?= =?UTF-8?q?=E9=99=90=E5=AE=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetch.py: strip_tags が h1-h6 をプレーンテキスト化していたのを修正し、 Markdown見出し(# 〜 ######)として保存するようにした。これにより human/web の見出し3つ以上の文書が 2本 → 52本に回復(再取得68本) - AIコンタミ回避のため、人間側コーパスを公開2022年12月以前に限定。 2023年以降公開の57本(essay 30, tech 23, business 4)を sources.json と corpus/human/web/ から削除 - 見出し検出器校正のため human tech ソースを25本追加 (Zenn/Qiita 13、企業テックブログ 12、いずれも2022年以前公開) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRr7r9tH5G1AvQF4bqtK7a --- corpus/fetch.py | 33 +- corpus/sources.json | 1129 ++++++------------- skills/natural-japanese/scripts/lint.py | 116 +- skills/natural-japanese/scripts/outline.py | 206 +++- skills/natural-japanese/scripts/textcore.py | 54 + 5 files changed, 716 insertions(+), 822 deletions(-) diff --git a/corpus/fetch.py b/corpus/fetch.py index 19cc014..059c551 100644 --- a/corpus/fetch.py +++ b/corpus/fetch.py @@ -51,15 +51,40 @@ def load_sources() -> list[dict]: return [s for s in data if s.get("type") == "web"] +def unescape_entities(text: str) -> str: + text = text.replace(" ", " ").replace("&", "&") + text = text.replace("<", "<").replace(">", ">").replace(""", '"') + text = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text) + text = re.sub(r"&#x([0-9a-fA-F]+);", lambda m: chr(int(m.group(1), 16)), text) + return text + + +def convert_headings(html: str) -> str: + """本文 HTML 中の

を Markdown 見出し(`#`〜`######`)に変換する。 + + strip_tags でタグを剥がす前に呼ぶこと。見出しタグ内部にインライン + タグ(/ 等)が含まれる場合もテキストのみを取り出す。 + """ + + def repl(m: re.Match[str]) -> str: + level = int(m.group(1)) + inner = re.sub(r"<[^>]+>", "", m.group(2)) + inner = unescape_entities(inner) + text = " ".join(inner.split()).strip() + if not text: + return "" + return "\n\n" + ("#" * level) + " " + text + "\n\n" + + return re.sub(r"]*>(.*?)", repl, html, flags=re.S | re.I) + + def strip_tags(html: str) -> str: html = re.sub(r"<(script|style|noscript)[^>]*>.*?", "", html, flags=re.S | re.I) + html = convert_headings(html) html = re.sub(r"", "\n", html, flags=re.I) html = re.sub(r"

", "\n\n", html, flags=re.I) html = re.sub(r"<[^>]+>", "", html) - html = html.replace(" ", " ").replace("&", "&") - html = html.replace("<", "<").replace(">", ">").replace(""", '"') - html = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), html) - html = re.sub(r"&#x([0-9a-fA-F]+);", lambda m: chr(int(m.group(1), 16)), html) + html = unescape_entities(html) lines = [ln.strip() for ln in html.splitlines()] lines = [ln for ln in lines if ln] return "\n\n".join(lines) diff --git a/corpus/sources.json b/corpus/sources.json index 6547ea3..32bc86f 100644 --- a/corpus/sources.json +++ b/corpus/sources.json @@ -131,34 +131,6 @@ "license": "public-domain", "notes": "青空文庫。パブリックドメインのためcorpus/human/aozora/にプレーンテキストをコミット済み。 アナトール・フランス作品の翻訳文。「〜のです」「〜でしょう」など現代的な口語体で、旧仮名遣いや文語特有の助動詞はほぼ見られない。" }, - { - "id": "note-essay-m_s_y-a7cf7146", - "type": "web", - "url": "https://note.com/m_s_y/n/nd8cfa7cf7146", - "title": null, - "author": "m_s_y", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 持ち物紹介の説明文が中心で情報の列挙にとどまり、物語的・文体的な工夫はほぼない。", - "quality": "ordinary", - "published": "2023-03", - "ai_era_risk": true - }, - { - "id": "note-essay-nyake-dc43a390", - "type": "web", - "url": "https://note.com/nyake/n/n827edc43a390", - "title": null, - "author": "nyake", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 分析は鋭いが感嘆符や口語連発(「!!」「たぶん」多用)で息継ぎが荒く、勢い任せの文章になっている。", - "quality": "ordinary", - "published": "2023-02", - "ai_era_risk": true - }, { "id": "note-essay-ciotan-8a0bfe52", "type": "web", @@ -172,132 +144,6 @@ "quality": "ordinary", "published": "2018-04" }, - { - "id": "note-essay-shu_yamaguchi-8ef4d482", - "type": "web", - "url": "https://note.com/shu_yamaguchi/n/na89b8ef4d482", - "title": null, - "author": "shu_yamaguchi", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 赤塚不二夫のエピソードを起伏のある構成で語り、教訓への着地もスムーズで読みやすい。", - "quality": "high", - "published": "2023-06", - "ai_era_risk": true - }, - { - "id": "note-essay-kaien-11211017", - "type": "web", - "url": "https://note.com/kaien/n/n60f311211017", - "title": null, - "author": "kaien", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 内容は濃いが「ぼく」口調の脱線や自己言及的な軽口が多く、締まりのない長広舌になっている。", - "quality": "ordinary", - "published": "2023-07", - "ai_era_risk": true - }, - { - "id": "note-essay-sasakitoshinao-b9080448", - "type": "web", - "url": "https://note.com/sasakitoshinao/n/nf299b9080448", - "title": null, - "author": "sasakitoshinao", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] ネット史の変遷を具体的数字とエピソードで積み上げ、論旨が明快に一本の線で繋がる硬質な論説文。", - "quality": "high", - "published": "2023-08", - "ai_era_risk": true - }, - { - "id": "zenn-tech-the_exile-gy-stack", - "type": "web", - "url": "https://zenn.dev/the_exile/articles/2024-technology-stack", - "title": "2024年の個人開発おすすめ技術スタック", - "author": "the_exile", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 技術スタックの箇条書きが中心で、文としての構成・接続よりも情報列挙に終始している。", - "quality": "ordinary", - "published": "2024-07", - "ai_era_risk": true - }, - { - "id": "zenn-tech-joo_hashi-d738f4be", - "type": "web", - "url": "https://zenn.dev/joo_hashi/articles/53f781d738f4be", - "title": "2025年個人開発者のお役に立つかも情報", - "author": "joo_hashi", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 絵文字とリンク列挙中心のカタログ的構成で、文としての彫琢はほぼなく箇条書き寄り。", - "quality": "ordinary", - "published": "2024-12", - "ai_era_risk": true - }, - { - "id": "zenn-tech-t_hayashi-1944731f", - "type": "web", - "url": "https://zenn.dev/t_hayashi/articles/faa1ec1944731f", - "title": "【読書感想文】TypeScriptとReact/Next.jsでつくる実践Webアプリケーション", - "author": "t_hayashi", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] Q&A形式で情報を詰め込んだ実用重視の文章で、説明が教科書的に並ぶだけで工夫は少ない。", - "quality": "ordinary", - "published": "2025-01", - "ai_era_risk": true - }, - { - "id": "zenn-tech-kamo_tomoki-b5d6b056", - "type": "web", - "url": "https://zenn.dev/kamo_tomoki/articles/c8ab1cb5d6b056", - "title": "【2025年】爆速でゼロイチ開発するための技術スタック", - "author": "kamo_tomoki", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 技術選定の理由は明快だが体言止めと短文の羅列が延々続き、説明文としての滑らかさは乏しい。", - "quality": "ordinary", - "published": "2025-05", - "ai_era_risk": true - }, - { - "id": "zenn-tech-gunners6518-ea29a5b4", - "type": "web", - "url": "https://zenn.dev/gunners6518/articles/147cceea29a5b4", - "title": "「State of JavaScript 2024」完全解説", - "author": "gunners6518", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 感嘆符の多用や語尾のばらつきで軽い調子が続き、誤字も散見され推敲不足が目立つ。", - "quality": "ordinary", - "published": "2025-01", - "ai_era_risk": true - }, - { - "id": "zenn-tech-manalink_dev-age-2024", - "type": "web", - "url": "https://zenn.dev/manalink_dev/articles/howto-build-react-ui-private-package-2024", - "title": "Reactで社内向けUIライブラリ開発・ビルド・公開・布教入門【2024年】", - "author": "manalink_dev", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 技術情報は充実しているが「〜と思います」の多用と長い一文の羅列で、説明の切れ味や整理が粗い。", - "quality": "ordinary", - "published": "2024-02", - "ai_era_risk": true - }, { "id": "zenn-tech-kugyu10-8e8ba334", "type": "web", @@ -311,244 +157,6 @@ "quality": "ordinary", "published": "2022-08" }, - { - "id": "zenn-tech-acntechjp-ea504f48", - "type": "web", - "url": "https://zenn.dev/acntechjp/articles/8a4547ea504f48", - "title": "この1年ほぼ毎日Zenn記事を書き続けて思ったこと", - "author": "acntechjp", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 「〜ことが分かりました」の同型文が延々と続く統計報告調で、説明文としての工夫やリズムがほぼない。", - "quality": "ordinary", - "published": "2025-05", - "ai_era_risk": true - }, - { - "id": "zenn-tech-gachigachi-72f23252", - "type": "web", - "url": "https://zenn.dev/gachigachi/articles/c1ed7372f23252", - "title": "Zennを1年間運営した結果、PV数やフォロワー数はどう変化したのか?", - "author": "gachigachi", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 見出しと箇条書きに依存した構成で、文はぶつ切り気味、レポート的な淡々とした記述に留まる。", - "quality": "ordinary", - "published": "2024-12", - "ai_era_risk": true - }, - { - "id": "note-essay-dalars10-a44d14c1", - "type": "web", - "url": "https://note.com/dalars10/n/nf0b0a44d14c1", - "title": "「別人判定」顔認証システム(Face ID)からの容赦ない人格否定", - "author": "dalars10", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] Face IDあるあるネタとして軽快だが、比喩表現が定型的で文章として際立つ工夫は少ない。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-hinata_love-c35566e8", - "type": "web", - "url": "https://note.com/hinata_love/n/ne411c35566e8", - "title": "いつまでも遠くをさまよい続けたいのか?", - "author": "hinata_love", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 一文一行の詩的スタイルが京都の話とゲーテの引用まで一貫したテーマで丁寧にまとめられている。", - "quality": "high", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-taka124-2fdd5473", - "type": "web", - "url": "https://note.com/taka124/n/nd6182fdd5473", - "title": "変えなくていい私まで、変えようとしていた。", - "author": "taka124", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 一文一行の短文詩調だが「〜だった」「〜かもしれない」の反復が多く、自己啓発系の定型パターンに寄っている。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-firm_dog9668-ef42b8da", - "type": "web", - "url": "https://note.com/firm_dog9668/n/ndb2bef42b8da", - "title": "ドミノには300通り以上の恋愛がある", - "author": "firm_dog9668", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 一文一行の断片列挙が延々続き、単調な箇条書きに近く冗長さが目立つ。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-w_tomato_note-1a6f6240", - "type": "web", - "url": "https://note.com/w_tomato_note/n/nacf81a6f6240", - "title": "私がついていきたいリーダー~海外警察ドラマより~", - "author": "w_tomato_note", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 海外ドラマ愛好の熱意は伝わるが話があちこちに飛び、段落の切れ目が唐突で整理された読みやすさに欠ける。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-vast_yak5141-1ac2ebc8", - "type": "web", - "url": "https://note.com/vast_yak5141/n/n974e1ac2ebc8", - "title": "休職日記㉛ また働くことが、少しだけ怖かった", - "author": "vast_yak5141", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 「〜と知った」の反復構文が続き、内省の内容は率直だが文の型が単調で工夫が少ない。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-next_phase_edu-fe6d0dac", - "type": "web", - "url": "https://note.com/next_phase_edu/n/n2034fe6d0dac", - "title": "読むことは、いつか誰かに渡すこと", - "author": "next_phase_edu", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 内省の筋は明快だが短い断定文の連続で単調にとどまり、密度や工夫には欠ける。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-ayaririy-10366bdf", - "type": "web", - "url": "https://note.com/ayaririy/n/nc94710366bdf", - "title": "見えない心の痛みを非難する資格が誰にあるのだろう", - "author": "ayaririy", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 句読点区切りで段が分断され、法律論と私的体験が唐突に混ざり合い論理の運びが粗い。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-kinokoro-040ca0aa", - "type": "web", - "url": "https://note.com/kinokoro/n/n96fd040ca0aa", - "title": "ドラえもんのひみつ道具、何が欲しい?――私の答えは決まっている【創作大賞】", - "author": "kinokoro", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 軽妙な反復芸を積み上げ、最後に人生論へ自然に転調する構成が達者。", - "quality": "high", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-learn_ikiruchie-241a905b", - "type": "web", - "url": "https://note.com/learn_ikiruchie/n/nc85c241a905b", - "title": "海外留学・ワーホリ|不安だらけだった過去の私を動かした決断の話", - "author": "learn_ikiruchie", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 見出しと短い改行の羅列が多く、自己啓発記事的な定型表現に頼っていて彫琢は薄い。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-osatsu_journey50-0bb567f7", - "type": "web", - "url": "https://note.com/osatsu_journey50/n/n88f30bb567f7", - "title": "何歳になっても一人旅をしよう", - "author": "osatsu_journey50", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 一人旅の魅力を五感描写と具体的エピソードで丁寧に展開し、段落構成もテーマごとに整理されている。", - "quality": "high", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-jolly_ibis7421-4bfee5c8", - "type": "web", - "url": "https://note.com/jolly_ibis7421/n/nc33b4bfee5c8", - "title": "鯖嫌いの仲間が完食した🇹🇭タイの黄金鯖。2度と叶わない「成田の約束」", - "author": "jolly_ibis7421", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 亡き友との思い出を鯖と成田の約束という具体的モチーフで束ね、感情の起伏を無理なく運ぶ構成力がある。", - "quality": "high", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-aomame_nono-c9c2f305", - "type": "web", - "url": "https://note.com/aomame_nono/n/n7603c9c2f305", - "title": "日記|ビールと二時間サスペンスに惚れ込む友人の語りから小説を考える。", - "author": "aomame_nono", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 会話のテンポと情景描写が自然で、日高屋巡りの顛末から小説論への着地まで無理なく繋がる構成が巧み。", - "quality": "high", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "note-essay-mame5539-204ad252", - "type": "web", - "url": "https://note.com/mame5539/n/n7845204ad252", - "title": "親ツバメだけじゃない?小さな命を見守っていて気づいたこと", - "author": "mame5539", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 絵文字と短文の羅列による日記調で、内容は微笑ましいが文章としての彫琢は乏しい。", - "quality": "ordinary", - "published": "2026-07", - "ai_era_risk": true - }, - { - "id": "zenn-tech-h_taro-7242256f", - "type": "web", - "url": "https://zenn.dev/h_taro/articles/ea5fdd7242256f", - "title": "26歳エンジニアの転職活動の振り返りと自分への宣言", - "author": "h_taro", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 一文一行の断片的な書き方で、内容は整理されているが文章としての流れやリズムは単調。", - "quality": "ordinary", - "published": "2025-11", - "ai_era_risk": true - }, { "id": "zenn-tech-big_tanukiudon-227b61d9", "type": "web", @@ -588,48 +196,6 @@ "quality": "ordinary", "published": "2022-02" }, - { - "id": "zenn-tech-sho555-8cafc3d0", - "type": "web", - "url": "https://zenn.dev/sho555/articles/0fef6a8cafc3d0", - "title": "普通のエンジニアが知らない「個人開発」の特殊な世界", - "author": "sho555", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 短い記事だが具体例とテンポの良い口語表現で一貫したまとまりのある文章。", - "quality": "high", - "published": "2024-10", - "ai_era_risk": true - }, - { - "id": "zenn-tech-sa_mon12-78eb37ab", - "type": "web", - "url": "https://zenn.dev/sa_mon12/articles/ad388c78eb37ab", - "title": "NTT退職エントリ 底辺子会社編", - "author": "sa_mon12", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 事実の羅列的な文が続き、体言止めや箇条書き的な短文が繋がっているだけで推敲感が薄い。", - "quality": "ordinary", - "published": "2023-09", - "ai_era_risk": true - }, - { - "id": "zenn-tech-hidetoshi-20230624_ntt_retirement", - "type": "web", - "url": "https://zenn.dev/hidetoshi/articles/20230624_ntt_retirement", - "title": "百番煎じのNTT退職エントリ", - "author": "hidetoshi", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 長文だが構成が明快で、論理の運びや語彙選択が丁寧、感情と分析のバランスが取れた読みやすい文章。", - "quality": "high", - "published": "2023-06", - "ai_era_risk": true - }, { "id": "zenn-tech-hideoamezawa-a4c0a5d2", "type": "web", @@ -643,34 +209,6 @@ "quality": "ordinary", "published": "2022-12" }, - { - "id": "zenn-tech-ymurakamieng-fc0da6d1", - "type": "web", - "url": "https://zenn.dev/ymurakamieng/articles/998867fc0da6d1", - "title": "退職します", - "author": "ymurakamieng", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 短いながら段落構成が明確で、感情を抑えた端正な文章で退職の経緯と感謝が自然に伝わる。", - "quality": "high", - "published": "2026-01", - "ai_era_risk": true - }, - { - "id": "zenn-tech-yutaro1985-freelance-career-strategy", - "type": "web", - "url": "https://zenn.dev/yutaro1985/articles/freelance-career-strategy", - "title": "フリーランスエンジニアは「経験の切り売り」になりやすいのか?", - "author": "yutaro1985", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 論点は明快だが改行や箇条書きの多用で文章としての流れが分断され、冗長な繰り返しも目立つ。", - "quality": "ordinary", - "published": "2026-01", - "ai_era_risk": true - }, { "id": "zenn-tech-airy-86665cab", "type": "web", @@ -684,76 +222,6 @@ "quality": "ordinary", "published": "2022-01" }, - { - "id": "zenn-tech-yumechi-ae3007c6", - "type": "web", - "url": "https://zenn.dev/yumechi/articles/d8f545ae3007c6", - "title": "スクラムマスターからエンジニア戻って1年くらいたった", - "author": "yumechi", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 箇条書き多用で内省を並べているが、文同士のつながりが弱く読みにくい箇所が多い。", - "quality": "ordinary", - "published": "2024-02", - "ai_era_risk": true - }, - { - "id": "zenn-tech-yokomachi-202512_change_job", - "type": "web", - "url": "https://zenn.dev/yokomachi/articles/202512_change_job", - "title": "準備してたら終わった転職活動 2025", - "author": "yokomachi", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 時系列の記録的な語りで内容は具体的だが、文が単調に連なり推敲された滑らかさに欠ける。", - "quality": "ordinary", - "published": "2025-12", - "ai_era_risk": true - }, - { - "id": "zenn-tech-lv-2be4c646", - "type": "web", - "url": "https://zenn.dev/lv/articles/f139102be4c646", - "title": "【退職エントリ】激動のAI時代にどう生き残るか", - "author": "lv", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 短文のリズムと反復・対比を効かせた構成で、退職エントリながら論の運びが明快で読みやすい。", - "quality": "high", - "published": "2026-06", - "ai_era_risk": true - }, - { - "id": "note-essay-personal_editor-81f0dab0", - "type": "web", - "url": "https://note.com/personal_editor/n/n8e4c81f0dab0", - "title": "note1万フォロワーのライターが教える、最後まで読まれるエッセイのコツ", - "author": "personal_editor", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] イベントレポートの対話書き起こしに近く、要約的な箇条書きが多用され文章としての流麗さは求められていない。", - "quality": "ordinary", - "published": "2025-09", - "ai_era_risk": true - }, - { - "id": "note-essay-oh_mai_canada-f8145235", - "type": "web", - "url": "https://note.com/oh_mai_canada/n/n99d6f8145235", - "title": "古典が苦手だった私が楽しめた、おすすめの本:日本三大随筆編", - "author": "oh_mai_canada", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 書籍紹介の定型フォーマット(内容・感想の箇条書き)が並び、通読向けの工夫は薄い。", - "quality": "ordinary", - "published": "2024-09", - "ai_era_risk": true - }, { "id": "note-essay-shadow0918-d1897f3d", "type": "web", @@ -780,48 +248,6 @@ "quality": "ordinary", "published": "2020-02" }, - { - "id": "note-essay-hatanohiroshi-de87ceb", - "type": "web", - "url": "https://note.com/hatanohiroshi/n/n27222de87ceb", - "title": "金持ちが悪い人だと思ってた", - "author": "hatanohiroshi", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力で知られる書き手を優先選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 短文の積み重ねで淡々と語りつつ、お菓子から証券口座への比喩展開が効いており低負荷で読める。", - "quality": "high", - "published": "2026-03", - "ai_era_risk": true - }, - { - "id": "note-essay-sasakitoshinao-46752666", - "type": "web", - "url": "https://note.com/sasakitoshinao/n/n939546752666", - "title": "登山でも散歩でもない、新しい徒歩の旅〜フラット登山という提案①", - "author": "sasakitoshinao", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力で知られる書き手を優先選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 主張は明快だが同じ問いかけ構文の反復が多く、後半でリード文と本文の整合が崩れる箇所もある。", - "quality": "ordinary", - "published": "2024-05", - "ai_era_risk": true - }, - { - "id": "note-essay-yokumoku_jp-5861789", - "type": "web", - "url": "https://note.com/yokumoku_jp/n/n449be5861789", - "title": "六本木に密かに好きな公園がある。", - "author": "yokumoku_jp", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力で知られる書き手を優先選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 幼少期の回想とベンチの現在が自然に往還し、描写が具体的で感傷に流れすぎない構成の巧さがある。", - "quality": "high", - "published": "2026-03", - "ai_era_risk": true - }, { "id": "note-essay-kishidanami-a67433", "type": "web", @@ -861,20 +287,6 @@ "quality": "ordinary", "published": "2022-12" }, - { - "id": "note-essay-kashiragi_box-f674777b", - "type": "web", - "url": "https://note.com/kashiragi_box/n/n2485f674777b", - "title": "自由と不自由", - "author": "kashiragi_box", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力で知られる書き手を優先選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 「砂の女」を軸に自由/不便/快の三項対立を丁寧に往還させる構成力があり、引用の使い方も的確。", - "quality": "high", - "published": "2024-09", - "ai_era_risk": true - }, { "id": "note-essay-yutachikauchi-f60aaeb3", "type": "web", @@ -888,146 +300,6 @@ "quality": "high", "published": "2020-03" }, - { - "id": "zenn-tech-nekoniki-7bef1d7fc", - "type": "web", - "url": "https://zenn.dev/nekoniki/articles/9120f7bef1d7fc", - "title": "システムエンジニアになってから10年経ったので個人的な振り返り", - "author": "nekoniki", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力の高さを基準に選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 年代ごとの経歴列挙で構成は明快だが、文は説明的な事実の羅列が中心で文章としての工夫は少ない。", - "quality": "ordinary", - "published": "2024-11", - "ai_era_risk": true - }, - { - "id": "zenn-tech-igz0-without-proof", - "type": "web", - "url": "https://zenn.dev/igz0/articles/skill-without-proof", - "title": "技術発信を後回しにした私が、転職で詰んだ話。", - "author": "igz0", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力の高さを基準に選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 短文を積んだ啓発記事的な文体で、キャッチーな一文の反復がやや作為的で単調。", - "quality": "ordinary", - "published": "2026-02", - "ai_era_risk": true - }, - { - "id": "zenn-tech-torao-cognitive-strategies", - "type": "web", - "url": "https://zenn.dev/torao/articles/20260502-differences-in-engrs-cognitive-strategies", - "title": "ソフトウェアの「設計原則」を、なぜ一部のエンジニアは生理的に嫌うのか", - "author": "torao", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力の高さを基準に選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 内容は非常に優れているが技術文書的な説明が中心で、表を多用し文章そのものの技巧よりは論理構成の緻密さが主体。", - "quality": "ordinary", - "published": "2026-05", - "ai_era_risk": true - }, - { - "id": "zenn-tech-pura-c8814c15557619", - "type": "web", - "url": "https://zenn.dev/pura/articles/c8814c15557619", - "title": "プログラマーとしてのこれまでを振り返る", - "author": "pura", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力の高さを基準に選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 経歴の回想として素朴で誠実だが、文がやや冗長で「〜と思います」の繰り返しなど整えが甘い。", - "quality": "ordinary", - "published": "2026-01", - "ai_era_risk": true - }, - { - "id": "zenn-tech-yusuke1225math2-9d9d7879", - "type": "web", - "url": "https://zenn.dev/yusuke1225math2/articles/b019400d9e7879", - "title": "新卒2年目で退職するエンジニアのAさんへ", - "author": "yusuke1225math2", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力の高さを基準に選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 熱量はあるが説教口調の反復と脱線が多く、1万字の長さに見合う推敲がされていない印象。", - "quality": "ordinary", - "published": "2025-01", - "ai_era_risk": true - }, - { - "id": "zenn-tech-shota_web305-4552f06f", - "type": "web", - "url": "https://zenn.dev/shota_web305/articles/d6a5844552f06f", - "title": "フリーランス10年目に気づいた走り初め続けることの大切さ", - "author": "shota_web305", - "genre": "tech", - "register": "modern", - "license": "all-rights-reserved", - "notes": "Zenn掲載の技術記事。全文フリー公開(会員限定/有料記事ではないことをfetch.pyの試走で確認済み)。文章力の高さを基準に選定。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 経歴の時系列列挙が中心で、事実の羅列的な文が続き文章としての彫琢は少ない。", - "quality": "ordinary", - "published": "2024-12", - "ai_era_risk": true - }, - { - "id": "note-essay-shadow0918-17d0997e2", - "type": "web", - "url": "https://note.com/shadow0918/n/n3c917d0997e2", - "title": "ボンボンドロップシールが表象するもの", - "author": "shadow0918", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(有料/会員限定ではないことをfetch.pyの試走で確認済み)。既に高品質と確認済みの書き手の別記事。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 抽象度の高い批評的語彙とリズムの整った文で、流行現象から時代精神を鮮やかに抽出する筆致。", - "quality": "high", - "published": "2026-01", - "ai_era_risk": true - }, - { - "id": "note-essay-hatanohiroshi-a0cfa518a", - "type": "web", - "url": "https://note.com/hatanohiroshi/n/naf5a0cfa518a", - "title": "ちいさなトラウマ", - "author": "hatanohiroshi", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(有料/会員限定ではないことをfetch.pyの試走で確認済み)。既に高品質と確認済みの書き手の別記事。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 率直で切れ味のある口語表現が、感傷に流れず自己省察を運ぶ構成の巧さ。", - "quality": "high", - "published": "2024-11", - "ai_era_risk": true - }, - { - "id": "note-essay-yokumoku_jp-a08ef22f38", - "type": "web", - "url": "https://note.com/yokumoku_jp/n/ndfa08ef22f38", - "title": "いつか、気持ちを乗せてくれるものが作れたら", - "author": "yokumoku_jp", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(有料/会員限定ではないことをfetch.pyの試走で確認済み)。既に高品質と確認済みの書き手の別記事。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 会社員からクッキー屋開業に至る回想が淡々とした一人称の語りで積み重なり、無理のない自然な段落転換で読ませる。", - "quality": "high", - "published": "2026-05", - "ai_era_risk": true - }, - { - "id": "note-essay-kashiragi_box-df0708cc0", - "type": "web", - "url": "https://note.com/kashiragi_box/n/n549df0708cc0", - "title": "シオランを知っていますか?", - "author": "kashiragi_box", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(有料/会員限定ではないことをfetch.pyの試走で確認済み)。既に高品質と確認済みの書き手の別記事。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] シオラン受容史を語りながら読者への静かな呼びかけに転じる構成の緩急。", - "quality": "high", - "published": "2026-07", - "ai_era_risk": true - }, { "id": "note-essay-yutachikauchi-e1d7d88046", "type": "web", @@ -1041,20 +313,6 @@ "quality": "high", "published": "2020-03" }, - { - "id": "note-essay-d_v_osorezan-d036d8421f", - "type": "web", - "url": "https://note.com/d_v_osorezan/n/na2d036d8421f", - "title": "こっくりさんエネルギーの利用構想とその発展", - "author": "d_v_osorezan", - "genre": "essay", - "register": "modern", - "license": "all-rights-reserved", - "notes": "note.com掲載の無料公開エッセイ。全文フリー(有料/会員限定ではないことをfetch.pyの試走で確認済み)。既に高品質と確認済みの書き手の別記事。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 疑似論文口調を精密に模倣した文体統制と、淡々とした語りの中に仕込まれるユーモアのタイミングが際立つ。", - "quality": "high", - "published": "2025-03", - "ai_era_risk": true - }, { "id": "biz-report-kayac-postmortem", "type": "web", @@ -1083,21 +341,6 @@ "notes": "業務報告/障害報告書/白書/提案書解説記事。fetch.pyの試走で無料公開・全文アクセス可能であることを確認済み。business ジャンルとして新規収集。", "published": "2020-12" }, - { - "id": "biz-report-findy-postmortem", - "type": "web", - "url": "https://tech.findy.co.jp/entry/2024/05/29/110313", - "title": "障害対応におけるポストモーテムのご紹介", - "author": "Findy Inc.", - "genre": "business", - "doc_type": "report", - "register": "modern", - "quality": "high", - "license": "all-rights-reserved", - "notes": "業務報告/障害報告書/白書/提案書解説記事。fetch.pyの試走で無料公開・全文アクセス可能であることを確認済み。business ジャンルとして新規収集。", - "published": "2024-05", - "ai_era_risk": true - }, { "id": "biz-report-mercari-incident", "type": "web", @@ -1126,21 +369,6 @@ "notes": "業務報告/障害報告書/白書/提案書解説記事。fetch.pyの試走で無料公開・全文アクセス可能であることを確認済み。business ジャンルとして新規収集。", "published": "2020-07" }, - { - "id": "biz-proposal-travel_seal-20years", - "type": "web", - "url": "https://note.com/travel_seal/n/n5ac5fd2a3837", - "title": "20年でたどり着いた、提案書の作り方", - "author": "travel_seal", - "genre": "business", - "doc_type": "proposal", - "register": "modern", - "quality": "ordinary", - "license": "all-rights-reserved", - "notes": "業務報告/障害報告書/白書/提案書解説記事。fetch.pyの試走で無料公開・全文アクセス可能であることを確認済み。business ジャンルとして新規収集。", - "published": "2024-06", - "ai_era_risk": true - }, { "id": "biz-proposal-dj141-template", "type": "web", @@ -1155,21 +383,6 @@ "notes": "業務報告/障害報告書/白書/提案書解説記事。fetch.pyの試走で無料公開・全文アクセス可能であることを確認済み。business ジャンルとして新規収集。", "published": "2019-11" }, - { - "id": "biz-email-ai_akaman-guide", - "type": "web", - "url": "https://note.com/ai_akaman/n/n74be3472cc74", - "title": "ビジネスメール書き方マスターガイド", - "author": "ai_akaman", - "genre": "business", - "doc_type": "email", - "register": "modern", - "quality": "ordinary", - "license": "all-rights-reserved", - "notes": "業務報告/障害報告書/白書/提案書解説記事。fetch.pyの試走で無料公開・全文アクセス可能であることを確認済み。business ジャンルとして新規収集。", - "published": "2025-02", - "ai_era_risk": true - }, { "id": "biz-report-cybozu-live-outage", "type": "web", @@ -1184,21 +397,6 @@ "notes": "業務報告/障害報告書。fetch.pyの試走で無料公開・全文アクセス可能、article要素からのクリーンな抽出を確認済み。business ジャンルとして新規収集。", "published": "2017-12" }, - { - "id": "biz-report-moneyforward-db-incident", - "type": "web", - "url": "https://moneyforward-dev.jp/entry/2024/03/28/185934", - "title": "DB呼び出し回数を減らしてコア機能を高速化した話。そして起こった悲劇と教訓", - "author": "Money Forward, Inc.", - "genre": "business", - "doc_type": "report", - "register": "modern", - "quality": "high", - "license": "all-rights-reserved", - "notes": "業務報告/障害報告書。fetch.pyの試走で無料公開・全文アクセス可能、article要素からのクリーンな抽出を確認済み。business ジャンルとして新規収集。", - "published": "2024-03", - "ai_era_risk": true - }, { "id": "zenn-tech-azukiazusa-pnpm-npm", "type": "web", @@ -1884,5 +1082,330 @@ "published": "2022-09", "ai_era_risk": false, "notes": "データ戦略推進ワーキンググループ(第4回)資料PDF(45ページ)。図解・箇条書き中心。" + }, + { + "id": "zenn-tech-faycute-aws-architecture", + "type": "web", + "url": "https://zenn.dev/faycute/articles/37a352a63de437", + "title": "SaaS系スタートアップのリアルなAWSアーキテクチャ設計", + "author": "faycute", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 実際に本番運用したAWS構成をAWS担当者レビューの経験も交えて具体的に解説しており、実務裏付けのある構成。", + "quality": "high", + "published": "2021-04" + }, + { + "id": "zenn-tech-foxtail88-go-project-structure", + "type": "web", + "url": "https://zenn.dev/foxtail88/articles/824c5e8e0c6d82", + "title": "少しずつ育てるGo言語のプロジェクト構成", + "author": "foxtail88", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 著者自身の実務経験に基づく段階的なGoプロジェクト構成論として文章のまとまりがある。", + "quality": "high", + "published": "2022-05" + }, + { + "id": "zenn-tech-uma002-golang-tools", + "type": "web", + "url": "https://zenn.dev/uma002/articles/18b8eb7854745b", + "title": "Golangでいい設計を実践するための6つのツール", + "author": "uma002", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] 書籍を読んで実践したツール紹介の羅列に近く、見出し数もやや少なめで彫琢は薄い。", + "quality": "ordinary", + "published": "2022-05" + }, + { + "id": "zenn-tech-yuneco-vue-2022", + "type": "web", + "url": "https://zenn.dev/yuneco/articles/vue-2021-to-2022", + "title": "ここ3年くらいの個人制作を振り返って2022年のVueを考える", + "author": "yuneco", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 個人開発と実務経験を踏まえたVueエコシステムの変遷考察で、構成・論旨ともにまとまりがある。", + "quality": "high", + "published": "2021-12" + }, + { + "id": "zenn-tech-jiin-css-design", + "type": "web", + "url": "https://zenn.dev/jiin/articles/18b547efa161d2", + "title": "CSS設計の大切さについて学びました", + "author": "jiin", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 実際に遭遇したCSS競合バグのデバッグ経験から得た教訓を素直な文体でまとめている。", + "quality": "high", + "published": "2022-01" + }, + { + "id": "qiita-tech-kapibarasensei-dockerfile", + "type": "web", + "url": "https://qiita.com/kapibarasensei/items/f81a93481cefd1d57474", + "title": "本番運用のための理想的なDockerfileについての考察", + "author": "kapibarasensei", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Qiita掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 実際の案件経験に基づく本番Dockerfileの考察で、構成がしっかりしている。", + "quality": "high", + "published": "2020-07" + }, + { + "id": "qiita-tech-marie_khr-test-antipatterns", + "type": "web", + "url": "https://qiita.com/marie_khr/items/8906c11f8c09a4944564", + "title": "現場で遭遇したテスト設計のアンチパターン", + "author": "marie_khr", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Qiita掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 実務での失敗事例をまとめたテスト設計アンチパターン集。公開2022年12月でChatGPT公開直後だが本文は実務経験ベースの内容。", + "quality": "high", + "published": "2022-12" + }, + { + "id": "qiita-tech-n_slender-refactoring", + "type": "web", + "url": "https://qiita.com/n_slender/items/b1fa13a03e465c6ff766", + "title": "チーム開発でリファクタリングをすすめるために試してみたこと", + "author": "n_slender", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Qiita掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] チームリーダー経験に基づく改善施策の記録。公開2022年12月でChatGPT公開直後だが本文は実務経験ベースの内容。", + "quality": "high", + "published": "2022-12" + }, + { + "id": "zenn-tech-iwamasa-react-chart", + "type": "web", + "url": "https://zenn.dev/iwamasa/articles/7f57ce20b750bf", + "title": "Reactベースのチャートツール選定(2022年版)", + "author": "iwamasa", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 業務でのライブラリ調査・比較を丁寧にまとめた情報量の多い記事。", + "quality": "high", + "published": "2022-09" + }, + { + "id": "zenn-tech-shimpeiws-react-state-history", + "type": "web", + "url": "https://zenn.dev/shimpeiws/articles/afcc43990d13c0", + "title": "Reactの状態管理の変遷に関する自分史 From 2014 To 2022", + "author": "shimpeiws", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 8年間のReact開発経験を振り返る自分史で、個人の考察が豊富。", + "quality": "high", + "published": "2022-03" + }, + { + "id": "qiita-tech-akid3020-refactoring-basics", + "type": "web", + "url": "https://qiita.com/akid3020/items/7fe355933da1fbeeb766", + "title": "チーム開発におけるリファクタリング、基本のキ", + "author": "akid3020", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Qiita掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 実務経験からのリファクタリング原則のまとめ。公開2022年12月でChatGPT公開直後だが本文は実務経験ベースの内容。", + "quality": "high", + "published": "2022-12" + }, + { + "id": "zenn-tech-kou_pg_0131-go-fullstack", + "type": "web", + "url": "https://zenn.dev/kou_pg_0131/articles/gogogo-introduction", + "title": "インフラもバックエンドもフロントエンドも Go で書いてみた", + "author": "kou_pg_0131", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:ordinary] Goで一通り実装した個人開発記録だが見出し数が最低ラインでやや薄め。", + "quality": "ordinary", + "published": "2022-08" + }, + { + "id": "zenn-tech-gege-kubernetes", + "type": "web", + "url": "https://zenn.dev/gege/articles/80b55c345cc1cb", + "title": "Kubernetesをだいたい理解するまで", + "author": "gege", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "Zenn掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 実務で急にK8sを使うことになった学習記録で、個人の理解プロセスが具体的。", + "quality": "high", + "published": "2022-06" + }, + { + "id": "cookpad-tech-moro-time-external-input", + "type": "web", + "url": "https://techlife.cookpad.com/entry/2016/05/30/183947", + "title": "「現在時刻」を外部入力とする設計と、その実装のこと", + "author": "moro", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "クックパッド開発者ブログ掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] システム時刻依存の外部化をTriceで実装した過程を、テスト容易性の議論として丁寧に展開している。", + "quality": "high", + "published": "2016-05" + }, + { + "id": "cookpad-tech-winebarrel-nginx-proxy-test", + "type": "web", + "url": "https://techlife.cookpad.com/entry/2018/03/02/000000", + "title": "Nginxへの変更に伴うリバースプロキシのテストの改善", + "author": "winebarrel", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "クックパッド開発者ブログ掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] Apache→Nginx移行とDocker Compose/Itamaeによるテスト基盤刷新の実務記録。", + "quality": "high", + "published": "2018-03" + }, + { + "id": "cookpad-tech-moro-form-object", + "type": "web", + "url": "https://techlife.cookpad.com/entry/2018/05/30/084420", + "title": "Railsアプリケーションでフォームをオブジェクトにして育てる", + "author": "moro", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "クックパッド開発者ブログ掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] フォームオブジェクトへのリファクタリングをドメイン駆動的観点で丁寧に解説している。", + "quality": "high", + "published": "2018-05" + }, + { + "id": "cookpad-tech-ymmttks-oicy-taste", + "type": "web", + "url": "https://techlife.cookpad.com/entry/oicy_taste_di", + "title": "レシピ連動調味料サーバー「OiCy Taste」の設計情報を公開、解説します", + "author": "ymmttks", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "クックパッド開発者ブログ掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] ハード/ソフト統合プロダクトの設計思想を、OSS公開の経緯も含めて濃く解説している。", + "quality": "high", + "published": "2018-09" + }, + { + "id": "mercari-tech-sabottenda-spanner", + "type": "web", + "url": "https://engineering.mercari.com/blog/entry/2019-04-18-090000/", + "title": "メルペイでのSpannerとの戦いの日々", + "author": "sabottenda", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "メルカリ/メルペイ Engineering Blog掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] Cloud Spanner本番障害の調査・解決過程を克明に記録した事例報告。", + "quality": "high", + "published": "2019-04" + }, + { + "id": "mercari-tech-foghost-payment-transaction", + "type": "web", + "url": "https://engineering.mercari.com/blog/entry/2019-06-07-155849/", + "title": "マイクロサービスにおける決済トランザクション管理", + "author": "foghost", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "メルカリ Engineering Blog掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 分散トランザクションの課題を具体例つきで整理した設計解説。", + "quality": "high", + "published": "2019-06" + }, + { + "id": "zozo-tech-vasilyjp-sql-autoscale", + "type": "web", + "url": "https://techblog.zozo.com/entry/sqldatabase_autoscale", + "title": "Azure Automationを利用してSQL Databaseをオートスケールしコスト削減させた話", + "author": "vasilyjp", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "ZOZO Tech Blog掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] PowerShellコード付きの実践的なコスト最適化手順を丁寧に解説している。", + "quality": "high", + "published": "2019-06" + }, + { + "id": "hatena-tech-tarao-elasticsearch-migration", + "type": "web", + "url": "https://developer.hatenastaff.com/entry/2020/08/21/elasticsearch-migration", + "title": "Elasticsearchクラスタを再構築し、無停止で切り替えるベストプラクティス", + "author": "tarao", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "はてな Developer Blog掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 非同期二重書き込み・楽観ロックまで踏み込んだ無停止移行の詳細な技術記事。", + "quality": "high", + "published": "2020-08" + }, + { + "id": "hatena-tech-cohalz-cache-improvement", + "type": "web", + "url": "https://developer.hatenastaff.com/entry/2020/09/18/093000", + "title": "はてなブログのキャッシュ周りをきちんと改善したら、アプリケーションサーバの台数を半分にできた話", + "author": "cohalz", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "はてな Developer Blog掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 計測に基づく地道なキャッシュ改善の過程を丁寧に追ったパフォーマンス記事。", + "quality": "high", + "published": "2020-09" + }, + { + "id": "recruit-tech-ishigame-vue-replace", + "type": "web", + "url": "https://techblog.recruit.co.jp/article-1139/", + "title": "「レストランボード」における大規模フロントエンドの漸進的なVueリプレイスの取り組み", + "author": "石亀広大", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "リクルート Tech Blog掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] 大規模プロダクトの段階的フレームワーク移行を組織的観点も含め論じている。", + "quality": "high", + "published": "2020-10" + }, + { + "id": "cookpad-tech-slightair-mobile-dev-pain", + "type": "web", + "url": "https://techlife.cookpad.com/entry/2020/12/16/170000", + "title": "モバイルアプリの開発上の違和感・痛みに向き合い、少しずつでも前進するための取り組み", + "author": "Slightair", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "クックパッド開発者ブログ掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] モバイル開発の課題感を実務目線で整理し、課題共有会の運用まで具体的に記述している。", + "quality": "high", + "published": "2020-12" + }, + { + "id": "cookpad-tech-osada-cookpadlive-aws", + "type": "web", + "url": "https://techlife.cookpad.com/entry/2021/08/24/175828", + "title": "AWSフル活用!クッキングLiveアプリ「cookpadLive」を支える技術", + "author": "Osada", + "genre": "tech", + "register": "modern", + "license": "all-rights-reserved", + "notes": "クックパッド開発者ブログ掲載の技術記事。全文フリー公開。本文非コミット、fetch.pyでローカル取得して評価専用。 [品質判定:high] ライブ配信基盤のAWSアーキテクチャを機能ごとに具体的に解説している。", + "quality": "high", + "published": "2021-08" } -] \ No newline at end of file +] diff --git a/skills/natural-japanese/scripts/lint.py b/skills/natural-japanese/scripts/lint.py index 2d1f358..34071a8 100644 --- a/skills/natural-japanese/scripts/lint.py +++ b/skills/natural-japanese/scripts/lint.py @@ -41,9 +41,12 @@ from pathlib import Path from textcore import ( Finding, + NOUN_ENDING_POS, SENTENCE_SPLIT_RE, + TRAILING_SYMBOL_POS, _HEADING_RE, _LIST_ITEM_RE, + _heading_level_and_text, get_tokenizer, iter_lines_with_no, iter_paragraphs_with_lines, @@ -51,6 +54,7 @@ from textcore import ( mask_markdown_structure, read_source_file, split_sentences_with_lines, + strip_trailing_symbols, ) # --------------------------------------------------------------------------- @@ -254,6 +258,36 @@ MTLD_THRESHOLD = 40 # 差を示すのは文書長4000字以上のビンのみ(それ未満は human/ai とも0%で無意味)。 LEXDIV_MIN_DOC_CHARS = 4000 +# heading_length_uniformity(見出し長の均質さ)検出器のパラメータ +# +# 設計意図: 他の検出器はすべて「デフォルト閾値があり、ジャンルによって緩める/ +# 無効化する」opt-out方式(disabled_categories)だが、この検出器は逆に +# 「デフォルトでは無効、--genre tech のときだけ opt-in で有効になる」。 +# HEADING_LENGTH_CV_THRESHOLD をモジュール既定値では None にしておき、 +# 検出器側は None なら何もせず空リストを返す。GENRE_PROFILES["tech"] にだけ +# 数値の閾値を設定し、そのジャンルでのみ検出器が実際に働くようにする。 +# これは、見出し長の均質さが tech ジャンル以外(business の省庁系定型見出し等) +# では human 側の誤検知が高く、汎用の検出器として成立しないと実測されたため +# (下記コメント参照)。 +HEADING_CV_MIN_HEADINGS = 5 +HEADING_LENGTH_CV_THRESHOLD: float | None = None +# 2026-07 実測(corpus/reports/heading-stats-analysis.md 追記分「コーパス拡充後の +# 再スイープと検出器化」節): human tech 45本(2022年以前公開、拡充済みコーパスの +# genre:tech 全件)vs AI tech 81本(7モデル、見出し0本の3件を除く)で見出し長 +# (文字数、h2以下・h1除く)の変動係数(CV = pstdev/mean、見出し5本以上の文書のみ +# 判定対象)をスイープした。閾値0.30で human FP率4.4%(2/45: zenn-tech- +# hideoamezawa-a4c0a5d2 CV=0.261、hatena-tech-cohalz-cache-improvement CV=0.267。 +# ともに見出し5本以上の中での最小CV)・AI検出率32.1%(26/81)。 +# 閾値0.40まで緩めるとAI検出率は55.6%まで伸びるが、human FP率も15.6%(7/45)まで +# 悪化する(他の校正済み検出器が目安とする「human FP<5%」を大きく超える)ため、 +# 0.30を採用してFP優先で保守的に振る。 +# ジャンル横断で見ると business(省庁系白書・報告書の定型見出し。「第1章」 +# 「1. 概要」等の均質な短い見出しが正当に多い)は同じ閾値0.30で human FP率12.5% +# (4/32、corpus/human/web/biz-*)まで悪化するため、tech ジャンル限定の opt-in +# 検出器とする(GENRE_PROFILES["tech"]でのみ閾値0.30を設定)。体言止め率・ +# テンプレ語彙の併用も試したが human/AI を分離せず、単純なCVのみを採用した。 +GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD = 0.30 + # --------------------------------------------------------------------------- # low_specificity(具体性/一般論臭)検出器のパラメータ # @@ -376,6 +410,10 @@ GENRE_PROFILES: dict[str, dict] = { # 目標の5%を超えたため、0.045に緩める(tech human critical化率0%に低下、 # AI側もcritical 9/84 + warn 6/84 で検出は維持)。 "antithesis_rate_critical_above": 0.045, + # heading_length_uniformity は tech ジャンルのみで有効化する opt-in 検出器 + # (モジュール定数 HEADING_LENGTH_CV_THRESHOLD のコメント・GENRE_TECH_ + # HEADING_LENGTH_CV_THRESHOLD の実測根拠コメントを参照)。 + "heading_length_cv_threshold": GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD, }, # business は 2026-07 の実地校正(corpus/reports/business-calibration.md)で # 実測した。人間側コーパスは corpus/human/web の biz-* 10件のみと薄いため、 @@ -458,6 +496,7 @@ _CATEGORY_ONLY_KEY_CATEGORIES = { "uniform_paragraph_structure", "low_lexical_diversity_ttr", "low_lexical_diversity_mtld", + "heading_length_uniformity", } @@ -729,8 +768,66 @@ def detect_low_sentence_length_variance( return [] -NOUN_ENDING_POS = {"名詞"} -TRAILING_SYMBOL_POS = {"補助記号", "空白"} +def detect_heading_length_uniformity( + raw_text: str, + min_headings: int = HEADING_CV_MIN_HEADINGS, + threshold: float | None = HEADING_LENGTH_CV_THRESHOLD, +) -> list[Finding]: + """見出し(h2以下)の長さ(文字数)の変動係数(CV = pstdev/mean)が閾値未満なら + 「見出しの長さが均質すぎる = AI臭い」として info で指摘する。 + + h1は文書タイトルなので集計対象から除外する。lint.py のマスク処理は見出し行を + 空にしてしまうため、マスク前の raw_text から直接見出し行を拾い、 + textcore._heading_level_and_text で見出しテキストを取り出す。 + + この検出器はデフォルト(genre未指定)では無効(threshold=None)。 + HEADING_LENGTH_CV_THRESHOLD のコメント、および GENRE_PROFILES["tech"] の + heading_length_cv_threshold を参照。--genre tech のときだけ数値の閾値が + 渡され、実際に発火しうる。 + """ + if threshold is None: + return [] + + heading_lines: list[tuple[int, str]] = [] + for no, line in iter_lines_with_no(raw_text): + m = _HEADING_RE.match(line) + if not m: + continue + level, text = _heading_level_and_text(line) + if level < 2 or not text: + continue + heading_lines.append((no, text)) + + if len(heading_lines) < min_headings: + return [] + + lengths = [len(text) for _, text in heading_lines] + mean = statistics.mean(lengths) + if mean == 0: + return [] + stdev = statistics.pstdev(lengths) + cv = stdev / mean + if cv >= threshold: + return [] + + related = [no for no, _ in heading_lines] + return [ + Finding( + line=related[0], + category="heading_length_uniformity", + excerpt=f"見出し数={len(heading_lines)}, 平均長={mean:.1f}字, 変動係数={cv:.3f}", + severity="info", + detail=( + f"見出し長の変動係数が閾値({threshold})未満。見出しの長さが均質すぎて" + f"AI臭い可能性。{format_related_lines(related)}" + ), + related_lines=related, + ) + ] + + +# NOUN_ENDING_POS / TRAILING_SYMBOL_POS は textcore.py に移設済み(outline.py の +# 見出し統計と共有するため)。ここでは textcore からの import をそのまま使う。 # 語彙多様性計測の対象とする内容語 POS CONTENT_WORD_POS = {"名詞", "動詞", "形容詞", "副詞"} @@ -786,14 +883,6 @@ def tokenize_sentences(sentences: list[tuple[int, str, str]]) -> list[TokenizedS return result -def _strip_trailing_symbols(morphemes: list) -> list: - """文末の記号(」など)を除いた実質的な最終形態素列を返す。""" - i = len(morphemes) - while i > 0 and morphemes[i - 1].part_of_speech()[0] in TRAILING_SYMBOL_POS: - i -= 1 - return morphemes[:i] - - def detect_nominal_ending_and_paragraph_conjunctions( lines: list[tuple[int, str]], tokenized: list[TokenizedSentence], @@ -827,7 +916,7 @@ def detect_nominal_ending_and_paragraph_conjunctions( total_sentences += 1 total_chars += len(ts.raw_text) last_line = ts.line - effective = _strip_trailing_symbols(ts.morphemes) + effective = strip_trailing_symbols(ts.morphemes) if not effective: continue last = effective[-1] @@ -1761,6 +1850,11 @@ def run_lint( low_spec_findings, low_spec_stats = detect_low_specificity(lines, raw_lines_by_no) findings += low_spec_findings + findings += detect_heading_length_uniformity( + raw_text, + threshold=profile.get("heading_length_cv_threshold", HEADING_LENGTH_CV_THRESHOLD), + ) + # EXPERIMENTAL_CATEGORIES はデフォルトでは除外する(--experimental でのみ出力)。 if not experimental: findings = [f for f in findings if f.category not in EXPERIMENTAL_CATEGORIES] diff --git a/skills/natural-japanese/scripts/outline.py b/skills/natural-japanese/scripts/outline.py index ec5366f..e68cca7 100644 --- a/skills/natural-japanese/scripts/outline.py +++ b/skills/natural-japanese/scripts/outline.py @@ -10,15 +10,20 @@ 設計原則「検出は機械、判断はAI」に基づき、良し悪しの判断はせず、決定的な抽出のみを 行う。SKILL.md §4 の構造レビュー(スケルトン通読)への入力として使う。 +スケルトンに加えて「見出し統計」も出力する(本数・レベル分布、見出し長の平均・ +変動係数、体言止め率、見出し間のPOSパターン一致率、テンプレ見出し語彙ヒット、 +連番/記号などの構造パターン率)。これらは severity 付きの検出結果(Finding) +ではなく、AI臭いかどうかを読む側のAIが判断するための材料の提示に留める +——見出し統計そのものが「AI臭い/自然」を断定することはしない。 + 使い方: uv run scripts/outline.py [--json] 入力エラー(ファイル不在・ディレクトリ指定・読み取り不可等)は exit code 1、 それ以外は exit code 0(判断は人間/AIに委ねる。他の検査層エントリと同じ方針)。 -sudachipy への依存はこのスクリプト自体では使わないが、textcore.py の共有基盤 -(他のエントリと共通のマスク処理・ヘルパー)を import するため PEP 723 -メタデータは textcore.py と同じ内容を宣言しておく。 +見出し統計の体言止め判定・POSシグネチャ化には sudachipy を使うため、 +textcore.py と同じ PEP 723 メタデータを宣言しておく。 """ from __future__ import annotations @@ -30,6 +35,8 @@ import sys from pathlib import Path from textcore import ( + NOUN_ENDING_POS, + TEMPLATE_HEADING_WORDS, _BLOCKQUOTE_RE, _CODE_FENCE_RE, _FRONT_MATTER_DELIM_RE, @@ -38,8 +45,10 @@ from textcore import ( _TABLE_DELIMITER_RE, _TABLE_ROW_RE, _heading_level_and_text, + get_tokenizer, mask_html_comments, read_source_file, + strip_trailing_symbols, ) # --------------------------------------------------------------------------- @@ -175,6 +184,193 @@ def build_outline(raw_text: str) -> list[dict]: return outline +# --------------------------------------------------------------------------- +# 見出し統計(--outline に付随する判断材料の提示) +# +# ここより下は「検出は機械、判断はAI」の"機械"側の追加ブロックである。ただし +# lint.py の検出器群とは性質が異なり、severity 付きの Finding は一切生成しない。 +# 見出しの本数・長さ・構造パターンを集計するだけで、「これはAI臭い」という +# 判定はしない(例えば体言止め率が高い見出しでも、技術文書では自然に高くなり +# うる。閾値判断・良し悪しの判断は読む側のAIに委ねる)。 +# +# 対象は build_outline() が抽出した kind="heading" のエントリのみ。h1(文書 +# タイトル)を含めるかどうかは呼び出し側次第だが、本文の構成パターンを見たい +# という目的上、レベル別統計は「レベルごとの兄弟見出し群」を単位に集計する。 +# --------------------------------------------------------------------------- + +# POS シグネチャ化で使う粗い品詞カテゴリ。sudachipy の part_of_speech()[0] は +# 「名詞」「動詞」「助詞」等の詳細分類だが、見出し全体の構造パターン(対称性)を +# 見たいだけなので、意味のある大分類のみ抽出し、それ以外(助詞・助動詞・記号・ +# 空白等の機能語/記号)はシグネチャから除外する。除外しないと「◯◯の設計」 +# 「◯◯の実装」のような対称見出しでも助詞「の」の有無等で微妙にシグネチャが +# ずれ、パターン一致率が実態より低く出てしまう。 +_SIGNATURE_POS = {"名詞", "動詞", "形容詞", "副詞", "接頭辞"} + +# テンプレ見出し語彙のマッチングは、単純な前方一致だと見出し先頭の記号・番号 +# (例:「1. はじめに」「## はじめに」)に引きずられて不一致になる。見出しテキスト +# 側の先頭にある番号・記号を軽く剥がしてから判定する。 +_LEADING_NUMBERING_RE = re.compile(r"^[\s0-90-9..、,()()【】\[\]#・-]+") + +# 構造的パターン検出用の正規表現。 +# 1) 連番: 「1. ◯◯」「1) ◯◯」「①◯◯」など見出しテキスト先頭の番号 +_NUMBERED_HEADING_RE = re.compile(r"^\s*([0-90-9]+[.).、]|[①-⑳])\s*\S") +# 2) 括弧見出し: 「【◯◯】」「[◯◯]」など全体または先頭を囲む記号 +_BRACKETED_HEADING_RE = re.compile(r"^\s*[【\[[((].+[】\]]))]\s*$") +# 3) 「◯◯とは」型: 定義提示の定型 +_TOWA_HEADING_RE = re.compile(r".+とは[??]?\s*$") + + +def _heading_pos_signature(text: str) -> tuple[str, ...]: + """見出しテキストの粗い品詞列(機能語・記号を除く)をタプル化したもの。 + 同一シグネチャの兄弟見出しが多いほど、構造的に対称な(=AIが書きがちな + テンプレ的な)見出し群である可能性が高い、という判断材料になる。 + """ + tokenizer = get_tokenizer() + sig = [] + for m in tokenizer.tokenize(text): + pos = m.part_of_speech()[0] + if pos in _SIGNATURE_POS: + sig.append(pos) + return tuple(sig) + + +def _is_nominal_ending(text: str) -> bool: + """見出し末尾の実質的な最終形態素が名詞かどうか(体言止め判定)。 + lint.py の文末体言止め判定(detect_nominal_ending_and_paragraph_conjunctions) + と同じロジックを見出しテキストに適用する。空見出しは False 扱い。 + """ + tokenizer = get_tokenizer() + morphemes = list(tokenizer.tokenize(text)) + effective = strip_trailing_symbols(morphemes) + if not effective: + return False + return effective[-1].part_of_speech()[0] in NOUN_ENDING_POS + + +def _match_template_word(text: str) -> str | None: + """見出し先頭の番号・記号を除いたうえで、テンプレ見出し語彙カタログ + (TEMPLATE_HEADING_WORDS)の前方一致を判定する。ヒットした最初の語を返す。 + """ + stripped = _LEADING_NUMBERING_RE.sub("", text).strip().lower() + for word in TEMPLATE_HEADING_WORDS: + if stripped.startswith(word.lower()): + return word + return None + + +def _match_structural_pattern(text: str) -> str | None: + """連番・括弧・「◯◯とは」型など、構造的な定型パターンに一致するか判定する。 + 複数該当しうるが、提示上は最初に一致したもの1つを採用する。 + """ + if _NUMBERED_HEADING_RE.match(text): + return "numbered" + if _BRACKETED_HEADING_RE.match(text): + return "bracketed" + if _TOWA_HEADING_RE.match(text): + return "towa" + return None + + +def _summarize_heading_group(headings: list[dict]) -> dict: + """見出し群(同一レベルの兄弟、または文書全体)1つ分の統計をまとめる。 + headings は build_outline() の kind="heading" エントリのリスト。 + """ + count = len(headings) + if count == 0: + return { + "count": 0, + "length_mean": 0.0, + "length_cv": 0.0, + "nominal_ending_ratio": 0.0, + "dominant_pos_signature_ratio": 0.0, + "template_hits": [], + "structural_pattern_ratio": 0.0, + } + + lengths = [len(h["text"]) for h in headings] + mean_len = sum(lengths) / count + if mean_len > 0 and count > 1: + variance = sum((length - mean_len) ** 2 for length in lengths) / count + stdev = variance**0.5 + cv = stdev / mean_len + else: + cv = 0.0 + + nominal_count = sum(1 for h in headings if _is_nominal_ending(h["text"])) + + signatures = [_heading_pos_signature(h["text"]) for h in headings] + non_empty_signatures = [s for s in signatures if s] + if non_empty_signatures: + most_common = max(set(non_empty_signatures), key=non_empty_signatures.count) + dominant_ratio = non_empty_signatures.count(most_common) / count + else: + dominant_ratio = 0.0 + + template_hits = [] + for h in headings: + word = _match_template_word(h["text"]) + if word is not None: + template_hits.append({"line": h["line"], "text": h["text"], "matched": word}) + + structural_count = sum(1 for h in headings if _match_structural_pattern(h["text"]) is not None) + + return { + "count": count, + "length_mean": round(mean_len, 2), + "length_cv": round(cv, 3), + "nominal_ending_ratio": round(nominal_count / count, 3), + "dominant_pos_signature_ratio": round(dominant_ratio, 3), + "template_hits": template_hits, + "structural_pattern_ratio": round(structural_count / count, 3), + } + + +def build_heading_stats(outline: list[dict]) -> dict: + """スケルトンから見出しだけを取り出し、レベル別(h1〜h6)+文書全体の + 統計をまとめる。severity や良し悪しの判断は含めない(判断材料の提示のみ)。 + """ + headings = [e for e in outline if e["kind"] == "heading"] + by_level: dict[int, list[dict]] = {} + for h in headings: + by_level.setdefault(h["level"], []).append(h) + + level_distribution = {str(level): len(hs) for level, hs in sorted(by_level.items())} + + return { + "total_headings": len(headings), + "level_distribution": level_distribution, + "by_level": { + str(level): _summarize_heading_group(hs) for level, hs in sorted(by_level.items()) + }, + "overall": _summarize_heading_group(headings), + } + + +def print_heading_stats_human(stats: dict) -> None: + print() + print("=== 見出し統計(判断材料。判定はAIが行う) ===") + print() + print(f"見出し総数: {stats['total_headings']}") + if stats["level_distribution"]: + dist = ", ".join(f"h{level}={n}" for level, n in stats["level_distribution"].items()) + print(f"レベル分布: {dist}") + + def print_group(label: str, g: dict) -> None: + if g["count"] == 0: + return + print(f"[{label}] 本数={g['count']} 平均長={g['length_mean']}字 " + f"長さの変動係数={g['length_cv']} 体言止め率={g['nominal_ending_ratio']:.0%} " + f"品詞パターン一致率={g['dominant_pos_signature_ratio']:.0%} " + f"構造パターン率={g['structural_pattern_ratio']:.0%}") + if g["template_hits"]: + hits = ", ".join(f"L{h['line']}:{h['text']}({h['matched']})" for h in g["template_hits"]) + print(f" テンプレ見出しヒット: {hits}") + + for level, g in stats["by_level"].items(): + print_group(f"h{level}", g) + print_group("全体", stats["overall"]) + + def print_outline_human(path: Path, outline: list[dict]) -> None: print(f"=== outline: {path} ===") print() @@ -207,10 +403,12 @@ def main() -> int: return 1 outline = build_outline(text) + heading_stats = build_heading_stats(outline) if args.json: - print(json.dumps({"outline": outline}, ensure_ascii=False, indent=2)) + print(json.dumps({"outline": outline, "heading_stats": heading_stats}, ensure_ascii=False, indent=2)) else: print_outline_human(args.file, outline) + print_heading_stats_human(heading_stats) return 0 diff --git a/skills/natural-japanese/scripts/textcore.py b/skills/natural-japanese/scripts/textcore.py index 5fc3a22..21a5213 100644 --- a/skills/natural-japanese/scripts/textcore.py +++ b/skills/natural-japanese/scripts/textcore.py @@ -70,6 +70,60 @@ def get_tokenizer(): return _tokenizer_obj +# --------------------------------------------------------------------------- +# 体言止め判定(lint.py の nominal_ending 検出器と outline.py の見出し統計で共用)。 +# +# TRAILING_SYMBOL_POS / NOUN_ENDING_POS / strip_trailing_symbols() は元々 +# lint.py 側だけに定義されていたが、outline.py の見出し統計(体言止め率)でも +# 同じ判定ロジックが必要になったため、共有基盤である textcore.py に移設した。 +# lint.py は本モジュールから import して使う(値は移設前と完全に同一)。 +# --------------------------------------------------------------------------- +NOUN_ENDING_POS = {"名詞"} +TRAILING_SYMBOL_POS = {"補助記号", "空白"} + + +def strip_trailing_symbols(morphemes: list) -> list: + """文末(または見出し末尾)の記号(」など)を除いた実質的な最終形態素列を返す。""" + i = len(morphemes) + while i > 0 and morphemes[i - 1].part_of_speech()[0] in TRAILING_SYMBOL_POS: + i -= 1 + return morphemes[:i] + + +# --------------------------------------------------------------------------- +# テンプレ見出し語彙カタログ(outline.py の見出し統計「テンプレ見出し検出」で使用)。 +# +# lint.py の BOILERPLATE_HEADING_WORDS(「まとめ」「おわりに」等、締めの定型句のみ) +# より対象を広げ、書き出し側の定型(「はじめに」「背景」)も含む。outline.py は +# severity 付きの検出器ではなく統計提示なので、ここでのヒットは「AI臭い」の +# 断定ではなく判断材料の一つに過ぎない。拡張前提のカタログとして、見出しの +# 前方一致で判定する(例:「まとめと今後の課題」は「まとめ」にも「今後」にも +# 一部一致しうるが、判定は startswith のみで十分。カタログはリスト順に評価し、 +# 最初に一致した語を採用する)。 +# --------------------------------------------------------------------------- +TEMPLATE_HEADING_WORDS: list[str] = [ + "はじめに", + "背景", + "概要", + "本記事について", + "この記事について", + "まとめと今後", + "今後の展望", + "今後の課題", + "今後について", + "まとめ", + "おわりに", + "終わりに", + "さいごに", + "最後に", + "結論", + "総括", + "conclusion", + "introduction", + "summary", +] + + # --------------------------------------------------------------------------- # Markdown構造行のマスク処理 # 見出し・リスト項目・コードブロック内・引用ブロックは「文章」ではないため、 From b3cca51eeb429363756861b2b837551420ea4efc Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 14 Jul 2026 10:06:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(outline):=20=E8=A6=8B=E5=87=BA?= =?UTF-8?q?=E3=81=97=E7=B5=B1=E8=A8=88=E3=82=92=E5=88=A4=E6=96=AD=E6=9D=90?= =?UTF-8?q?=E6=96=99=E3=81=A8=E3=81=97=E3=81=A6=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レベル別/全体の見出し本数・平均長・長さの変動係数・体言止め率・ 品詞パターン一致率・テンプレ見出しヒット・構造パターン率 (連番/【】/「〜とは」型)をテキスト出力末尾と --json の heading_stats キーに出す。severity 付き Finding は出さない (検出は機械、判断はAI)。 共有ロジック(strip_trailing_symbols, NOUN_ENDING_POS, TEMPLATE_HEADING_WORDS)は textcore.py に移動し lint.py と共用。 コーパス実測(human 51 vs AI 355)では、体言止め率は human の方が 高く(business 見出しは平均0.84)、テンプレ語彙は分離なし、 品詞パターン一致率はレベルで向きが逆転するため、いずれも 検出器化せず統計提示に留める。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRr7r9tH5G1AvQF4bqtK7a --- scripts/outline.py | 206 +++++++++++++++++++++++++++++++++++++++++++- scripts/textcore.py | 54 ++++++++++++ 2 files changed, 256 insertions(+), 4 deletions(-) diff --git a/scripts/outline.py b/scripts/outline.py index ec5366f..e68cca7 100644 --- a/scripts/outline.py +++ b/scripts/outline.py @@ -10,15 +10,20 @@ 設計原則「検出は機械、判断はAI」に基づき、良し悪しの判断はせず、決定的な抽出のみを 行う。SKILL.md §4 の構造レビュー(スケルトン通読)への入力として使う。 +スケルトンに加えて「見出し統計」も出力する(本数・レベル分布、見出し長の平均・ +変動係数、体言止め率、見出し間のPOSパターン一致率、テンプレ見出し語彙ヒット、 +連番/記号などの構造パターン率)。これらは severity 付きの検出結果(Finding) +ではなく、AI臭いかどうかを読む側のAIが判断するための材料の提示に留める +——見出し統計そのものが「AI臭い/自然」を断定することはしない。 + 使い方: uv run scripts/outline.py [--json] 入力エラー(ファイル不在・ディレクトリ指定・読み取り不可等)は exit code 1、 それ以外は exit code 0(判断は人間/AIに委ねる。他の検査層エントリと同じ方針)。 -sudachipy への依存はこのスクリプト自体では使わないが、textcore.py の共有基盤 -(他のエントリと共通のマスク処理・ヘルパー)を import するため PEP 723 -メタデータは textcore.py と同じ内容を宣言しておく。 +見出し統計の体言止め判定・POSシグネチャ化には sudachipy を使うため、 +textcore.py と同じ PEP 723 メタデータを宣言しておく。 """ from __future__ import annotations @@ -30,6 +35,8 @@ import sys from pathlib import Path from textcore import ( + NOUN_ENDING_POS, + TEMPLATE_HEADING_WORDS, _BLOCKQUOTE_RE, _CODE_FENCE_RE, _FRONT_MATTER_DELIM_RE, @@ -38,8 +45,10 @@ from textcore import ( _TABLE_DELIMITER_RE, _TABLE_ROW_RE, _heading_level_and_text, + get_tokenizer, mask_html_comments, read_source_file, + strip_trailing_symbols, ) # --------------------------------------------------------------------------- @@ -175,6 +184,193 @@ def build_outline(raw_text: str) -> list[dict]: return outline +# --------------------------------------------------------------------------- +# 見出し統計(--outline に付随する判断材料の提示) +# +# ここより下は「検出は機械、判断はAI」の"機械"側の追加ブロックである。ただし +# lint.py の検出器群とは性質が異なり、severity 付きの Finding は一切生成しない。 +# 見出しの本数・長さ・構造パターンを集計するだけで、「これはAI臭い」という +# 判定はしない(例えば体言止め率が高い見出しでも、技術文書では自然に高くなり +# うる。閾値判断・良し悪しの判断は読む側のAIに委ねる)。 +# +# 対象は build_outline() が抽出した kind="heading" のエントリのみ。h1(文書 +# タイトル)を含めるかどうかは呼び出し側次第だが、本文の構成パターンを見たい +# という目的上、レベル別統計は「レベルごとの兄弟見出し群」を単位に集計する。 +# --------------------------------------------------------------------------- + +# POS シグネチャ化で使う粗い品詞カテゴリ。sudachipy の part_of_speech()[0] は +# 「名詞」「動詞」「助詞」等の詳細分類だが、見出し全体の構造パターン(対称性)を +# 見たいだけなので、意味のある大分類のみ抽出し、それ以外(助詞・助動詞・記号・ +# 空白等の機能語/記号)はシグネチャから除外する。除外しないと「◯◯の設計」 +# 「◯◯の実装」のような対称見出しでも助詞「の」の有無等で微妙にシグネチャが +# ずれ、パターン一致率が実態より低く出てしまう。 +_SIGNATURE_POS = {"名詞", "動詞", "形容詞", "副詞", "接頭辞"} + +# テンプレ見出し語彙のマッチングは、単純な前方一致だと見出し先頭の記号・番号 +# (例:「1. はじめに」「## はじめに」)に引きずられて不一致になる。見出しテキスト +# 側の先頭にある番号・記号を軽く剥がしてから判定する。 +_LEADING_NUMBERING_RE = re.compile(r"^[\s0-90-9..、,()()【】\[\]#・-]+") + +# 構造的パターン検出用の正規表現。 +# 1) 連番: 「1. ◯◯」「1) ◯◯」「①◯◯」など見出しテキスト先頭の番号 +_NUMBERED_HEADING_RE = re.compile(r"^\s*([0-90-9]+[.).、]|[①-⑳])\s*\S") +# 2) 括弧見出し: 「【◯◯】」「[◯◯]」など全体または先頭を囲む記号 +_BRACKETED_HEADING_RE = re.compile(r"^\s*[【\[[((].+[】\]]))]\s*$") +# 3) 「◯◯とは」型: 定義提示の定型 +_TOWA_HEADING_RE = re.compile(r".+とは[??]?\s*$") + + +def _heading_pos_signature(text: str) -> tuple[str, ...]: + """見出しテキストの粗い品詞列(機能語・記号を除く)をタプル化したもの。 + 同一シグネチャの兄弟見出しが多いほど、構造的に対称な(=AIが書きがちな + テンプレ的な)見出し群である可能性が高い、という判断材料になる。 + """ + tokenizer = get_tokenizer() + sig = [] + for m in tokenizer.tokenize(text): + pos = m.part_of_speech()[0] + if pos in _SIGNATURE_POS: + sig.append(pos) + return tuple(sig) + + +def _is_nominal_ending(text: str) -> bool: + """見出し末尾の実質的な最終形態素が名詞かどうか(体言止め判定)。 + lint.py の文末体言止め判定(detect_nominal_ending_and_paragraph_conjunctions) + と同じロジックを見出しテキストに適用する。空見出しは False 扱い。 + """ + tokenizer = get_tokenizer() + morphemes = list(tokenizer.tokenize(text)) + effective = strip_trailing_symbols(morphemes) + if not effective: + return False + return effective[-1].part_of_speech()[0] in NOUN_ENDING_POS + + +def _match_template_word(text: str) -> str | None: + """見出し先頭の番号・記号を除いたうえで、テンプレ見出し語彙カタログ + (TEMPLATE_HEADING_WORDS)の前方一致を判定する。ヒットした最初の語を返す。 + """ + stripped = _LEADING_NUMBERING_RE.sub("", text).strip().lower() + for word in TEMPLATE_HEADING_WORDS: + if stripped.startswith(word.lower()): + return word + return None + + +def _match_structural_pattern(text: str) -> str | None: + """連番・括弧・「◯◯とは」型など、構造的な定型パターンに一致するか判定する。 + 複数該当しうるが、提示上は最初に一致したもの1つを採用する。 + """ + if _NUMBERED_HEADING_RE.match(text): + return "numbered" + if _BRACKETED_HEADING_RE.match(text): + return "bracketed" + if _TOWA_HEADING_RE.match(text): + return "towa" + return None + + +def _summarize_heading_group(headings: list[dict]) -> dict: + """見出し群(同一レベルの兄弟、または文書全体)1つ分の統計をまとめる。 + headings は build_outline() の kind="heading" エントリのリスト。 + """ + count = len(headings) + if count == 0: + return { + "count": 0, + "length_mean": 0.0, + "length_cv": 0.0, + "nominal_ending_ratio": 0.0, + "dominant_pos_signature_ratio": 0.0, + "template_hits": [], + "structural_pattern_ratio": 0.0, + } + + lengths = [len(h["text"]) for h in headings] + mean_len = sum(lengths) / count + if mean_len > 0 and count > 1: + variance = sum((length - mean_len) ** 2 for length in lengths) / count + stdev = variance**0.5 + cv = stdev / mean_len + else: + cv = 0.0 + + nominal_count = sum(1 for h in headings if _is_nominal_ending(h["text"])) + + signatures = [_heading_pos_signature(h["text"]) for h in headings] + non_empty_signatures = [s for s in signatures if s] + if non_empty_signatures: + most_common = max(set(non_empty_signatures), key=non_empty_signatures.count) + dominant_ratio = non_empty_signatures.count(most_common) / count + else: + dominant_ratio = 0.0 + + template_hits = [] + for h in headings: + word = _match_template_word(h["text"]) + if word is not None: + template_hits.append({"line": h["line"], "text": h["text"], "matched": word}) + + structural_count = sum(1 for h in headings if _match_structural_pattern(h["text"]) is not None) + + return { + "count": count, + "length_mean": round(mean_len, 2), + "length_cv": round(cv, 3), + "nominal_ending_ratio": round(nominal_count / count, 3), + "dominant_pos_signature_ratio": round(dominant_ratio, 3), + "template_hits": template_hits, + "structural_pattern_ratio": round(structural_count / count, 3), + } + + +def build_heading_stats(outline: list[dict]) -> dict: + """スケルトンから見出しだけを取り出し、レベル別(h1〜h6)+文書全体の + 統計をまとめる。severity や良し悪しの判断は含めない(判断材料の提示のみ)。 + """ + headings = [e for e in outline if e["kind"] == "heading"] + by_level: dict[int, list[dict]] = {} + for h in headings: + by_level.setdefault(h["level"], []).append(h) + + level_distribution = {str(level): len(hs) for level, hs in sorted(by_level.items())} + + return { + "total_headings": len(headings), + "level_distribution": level_distribution, + "by_level": { + str(level): _summarize_heading_group(hs) for level, hs in sorted(by_level.items()) + }, + "overall": _summarize_heading_group(headings), + } + + +def print_heading_stats_human(stats: dict) -> None: + print() + print("=== 見出し統計(判断材料。判定はAIが行う) ===") + print() + print(f"見出し総数: {stats['total_headings']}") + if stats["level_distribution"]: + dist = ", ".join(f"h{level}={n}" for level, n in stats["level_distribution"].items()) + print(f"レベル分布: {dist}") + + def print_group(label: str, g: dict) -> None: + if g["count"] == 0: + return + print(f"[{label}] 本数={g['count']} 平均長={g['length_mean']}字 " + f"長さの変動係数={g['length_cv']} 体言止め率={g['nominal_ending_ratio']:.0%} " + f"品詞パターン一致率={g['dominant_pos_signature_ratio']:.0%} " + f"構造パターン率={g['structural_pattern_ratio']:.0%}") + if g["template_hits"]: + hits = ", ".join(f"L{h['line']}:{h['text']}({h['matched']})" for h in g["template_hits"]) + print(f" テンプレ見出しヒット: {hits}") + + for level, g in stats["by_level"].items(): + print_group(f"h{level}", g) + print_group("全体", stats["overall"]) + + def print_outline_human(path: Path, outline: list[dict]) -> None: print(f"=== outline: {path} ===") print() @@ -207,10 +403,12 @@ def main() -> int: return 1 outline = build_outline(text) + heading_stats = build_heading_stats(outline) if args.json: - print(json.dumps({"outline": outline}, ensure_ascii=False, indent=2)) + print(json.dumps({"outline": outline, "heading_stats": heading_stats}, ensure_ascii=False, indent=2)) else: print_outline_human(args.file, outline) + print_heading_stats_human(heading_stats) return 0 diff --git a/scripts/textcore.py b/scripts/textcore.py index 5fc3a22..21a5213 100644 --- a/scripts/textcore.py +++ b/scripts/textcore.py @@ -70,6 +70,60 @@ def get_tokenizer(): return _tokenizer_obj +# --------------------------------------------------------------------------- +# 体言止め判定(lint.py の nominal_ending 検出器と outline.py の見出し統計で共用)。 +# +# TRAILING_SYMBOL_POS / NOUN_ENDING_POS / strip_trailing_symbols() は元々 +# lint.py 側だけに定義されていたが、outline.py の見出し統計(体言止め率)でも +# 同じ判定ロジックが必要になったため、共有基盤である textcore.py に移設した。 +# lint.py は本モジュールから import して使う(値は移設前と完全に同一)。 +# --------------------------------------------------------------------------- +NOUN_ENDING_POS = {"名詞"} +TRAILING_SYMBOL_POS = {"補助記号", "空白"} + + +def strip_trailing_symbols(morphemes: list) -> list: + """文末(または見出し末尾)の記号(」など)を除いた実質的な最終形態素列を返す。""" + i = len(morphemes) + while i > 0 and morphemes[i - 1].part_of_speech()[0] in TRAILING_SYMBOL_POS: + i -= 1 + return morphemes[:i] + + +# --------------------------------------------------------------------------- +# テンプレ見出し語彙カタログ(outline.py の見出し統計「テンプレ見出し検出」で使用)。 +# +# lint.py の BOILERPLATE_HEADING_WORDS(「まとめ」「おわりに」等、締めの定型句のみ) +# より対象を広げ、書き出し側の定型(「はじめに」「背景」)も含む。outline.py は +# severity 付きの検出器ではなく統計提示なので、ここでのヒットは「AI臭い」の +# 断定ではなく判断材料の一つに過ぎない。拡張前提のカタログとして、見出しの +# 前方一致で判定する(例:「まとめと今後の課題」は「まとめ」にも「今後」にも +# 一部一致しうるが、判定は startswith のみで十分。カタログはリスト順に評価し、 +# 最初に一致した語を採用する)。 +# --------------------------------------------------------------------------- +TEMPLATE_HEADING_WORDS: list[str] = [ + "はじめに", + "背景", + "概要", + "本記事について", + "この記事について", + "まとめと今後", + "今後の展望", + "今後の課題", + "今後について", + "まとめ", + "おわりに", + "終わりに", + "さいごに", + "最後に", + "結論", + "総括", + "conclusion", + "introduction", + "summary", +] + + # --------------------------------------------------------------------------- # Markdown構造行のマスク処理 # 見出し・リスト項目・コードブロック内・引用ブロックは「文章」ではないため、 From a4d4a71767410f8a59f4e811091a9c5430f3b2fe Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 14 Jul 2026 10:06:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(lint):=20--genre=20tech=20=E9=99=90?= =?UTF-8?q?=E5=AE=9A=E3=81=AE=20heading=5Flength=5Funiformity=20=E6=A4=9C?= =?UTF-8?q?=E5=87=BA=E5=99=A8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit h2以下の見出しが5本以上あり、見出し長(文字数)の変動係数が 0.30 未満なら「見出しが等長すぎる」info を1件出す。AIは 「◯◯の設計」「◯◯の実装」のような等長見出しを量産しがちで、 人間の見出しは長さがバラつく。 実測(2022年以前公開の human tech 45本 vs AI tech 81本・7モデル): 閾値0.30で human FP 4.4%(2/45)・AI検出率32.1%(26/81)。 ジャンル横断では business(省庁系の定型見出し)の FP が高く 基準を超えるため、GENRE_PROFILES 経由の tech 限定 opt-in とした (デフォルト閾値 None = 無効)。h1 はタイトルで実文書では フロントマターに置かれ本文にないことが多いため、CV 計算から除外。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRr7r9tH5G1AvQF4bqtK7a --- scripts/lint.py | 116 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/scripts/lint.py b/scripts/lint.py index 2d1f358..34071a8 100644 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -41,9 +41,12 @@ from pathlib import Path from textcore import ( Finding, + NOUN_ENDING_POS, SENTENCE_SPLIT_RE, + TRAILING_SYMBOL_POS, _HEADING_RE, _LIST_ITEM_RE, + _heading_level_and_text, get_tokenizer, iter_lines_with_no, iter_paragraphs_with_lines, @@ -51,6 +54,7 @@ from textcore import ( mask_markdown_structure, read_source_file, split_sentences_with_lines, + strip_trailing_symbols, ) # --------------------------------------------------------------------------- @@ -254,6 +258,36 @@ MTLD_THRESHOLD = 40 # 差を示すのは文書長4000字以上のビンのみ(それ未満は human/ai とも0%で無意味)。 LEXDIV_MIN_DOC_CHARS = 4000 +# heading_length_uniformity(見出し長の均質さ)検出器のパラメータ +# +# 設計意図: 他の検出器はすべて「デフォルト閾値があり、ジャンルによって緩める/ +# 無効化する」opt-out方式(disabled_categories)だが、この検出器は逆に +# 「デフォルトでは無効、--genre tech のときだけ opt-in で有効になる」。 +# HEADING_LENGTH_CV_THRESHOLD をモジュール既定値では None にしておき、 +# 検出器側は None なら何もせず空リストを返す。GENRE_PROFILES["tech"] にだけ +# 数値の閾値を設定し、そのジャンルでのみ検出器が実際に働くようにする。 +# これは、見出し長の均質さが tech ジャンル以外(business の省庁系定型見出し等) +# では human 側の誤検知が高く、汎用の検出器として成立しないと実測されたため +# (下記コメント参照)。 +HEADING_CV_MIN_HEADINGS = 5 +HEADING_LENGTH_CV_THRESHOLD: float | None = None +# 2026-07 実測(corpus/reports/heading-stats-analysis.md 追記分「コーパス拡充後の +# 再スイープと検出器化」節): human tech 45本(2022年以前公開、拡充済みコーパスの +# genre:tech 全件)vs AI tech 81本(7モデル、見出し0本の3件を除く)で見出し長 +# (文字数、h2以下・h1除く)の変動係数(CV = pstdev/mean、見出し5本以上の文書のみ +# 判定対象)をスイープした。閾値0.30で human FP率4.4%(2/45: zenn-tech- +# hideoamezawa-a4c0a5d2 CV=0.261、hatena-tech-cohalz-cache-improvement CV=0.267。 +# ともに見出し5本以上の中での最小CV)・AI検出率32.1%(26/81)。 +# 閾値0.40まで緩めるとAI検出率は55.6%まで伸びるが、human FP率も15.6%(7/45)まで +# 悪化する(他の校正済み検出器が目安とする「human FP<5%」を大きく超える)ため、 +# 0.30を採用してFP優先で保守的に振る。 +# ジャンル横断で見ると business(省庁系白書・報告書の定型見出し。「第1章」 +# 「1. 概要」等の均質な短い見出しが正当に多い)は同じ閾値0.30で human FP率12.5% +# (4/32、corpus/human/web/biz-*)まで悪化するため、tech ジャンル限定の opt-in +# 検出器とする(GENRE_PROFILES["tech"]でのみ閾値0.30を設定)。体言止め率・ +# テンプレ語彙の併用も試したが human/AI を分離せず、単純なCVのみを採用した。 +GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD = 0.30 + # --------------------------------------------------------------------------- # low_specificity(具体性/一般論臭)検出器のパラメータ # @@ -376,6 +410,10 @@ GENRE_PROFILES: dict[str, dict] = { # 目標の5%を超えたため、0.045に緩める(tech human critical化率0%に低下、 # AI側もcritical 9/84 + warn 6/84 で検出は維持)。 "antithesis_rate_critical_above": 0.045, + # heading_length_uniformity は tech ジャンルのみで有効化する opt-in 検出器 + # (モジュール定数 HEADING_LENGTH_CV_THRESHOLD のコメント・GENRE_TECH_ + # HEADING_LENGTH_CV_THRESHOLD の実測根拠コメントを参照)。 + "heading_length_cv_threshold": GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD, }, # business は 2026-07 の実地校正(corpus/reports/business-calibration.md)で # 実測した。人間側コーパスは corpus/human/web の biz-* 10件のみと薄いため、 @@ -458,6 +496,7 @@ _CATEGORY_ONLY_KEY_CATEGORIES = { "uniform_paragraph_structure", "low_lexical_diversity_ttr", "low_lexical_diversity_mtld", + "heading_length_uniformity", } @@ -729,8 +768,66 @@ def detect_low_sentence_length_variance( return [] -NOUN_ENDING_POS = {"名詞"} -TRAILING_SYMBOL_POS = {"補助記号", "空白"} +def detect_heading_length_uniformity( + raw_text: str, + min_headings: int = HEADING_CV_MIN_HEADINGS, + threshold: float | None = HEADING_LENGTH_CV_THRESHOLD, +) -> list[Finding]: + """見出し(h2以下)の長さ(文字数)の変動係数(CV = pstdev/mean)が閾値未満なら + 「見出しの長さが均質すぎる = AI臭い」として info で指摘する。 + + h1は文書タイトルなので集計対象から除外する。lint.py のマスク処理は見出し行を + 空にしてしまうため、マスク前の raw_text から直接見出し行を拾い、 + textcore._heading_level_and_text で見出しテキストを取り出す。 + + この検出器はデフォルト(genre未指定)では無効(threshold=None)。 + HEADING_LENGTH_CV_THRESHOLD のコメント、および GENRE_PROFILES["tech"] の + heading_length_cv_threshold を参照。--genre tech のときだけ数値の閾値が + 渡され、実際に発火しうる。 + """ + if threshold is None: + return [] + + heading_lines: list[tuple[int, str]] = [] + for no, line in iter_lines_with_no(raw_text): + m = _HEADING_RE.match(line) + if not m: + continue + level, text = _heading_level_and_text(line) + if level < 2 or not text: + continue + heading_lines.append((no, text)) + + if len(heading_lines) < min_headings: + return [] + + lengths = [len(text) for _, text in heading_lines] + mean = statistics.mean(lengths) + if mean == 0: + return [] + stdev = statistics.pstdev(lengths) + cv = stdev / mean + if cv >= threshold: + return [] + + related = [no for no, _ in heading_lines] + return [ + Finding( + line=related[0], + category="heading_length_uniformity", + excerpt=f"見出し数={len(heading_lines)}, 平均長={mean:.1f}字, 変動係数={cv:.3f}", + severity="info", + detail=( + f"見出し長の変動係数が閾値({threshold})未満。見出しの長さが均質すぎて" + f"AI臭い可能性。{format_related_lines(related)}" + ), + related_lines=related, + ) + ] + + +# NOUN_ENDING_POS / TRAILING_SYMBOL_POS は textcore.py に移設済み(outline.py の +# 見出し統計と共有するため)。ここでは textcore からの import をそのまま使う。 # 語彙多様性計測の対象とする内容語 POS CONTENT_WORD_POS = {"名詞", "動詞", "形容詞", "副詞"} @@ -786,14 +883,6 @@ def tokenize_sentences(sentences: list[tuple[int, str, str]]) -> list[TokenizedS return result -def _strip_trailing_symbols(morphemes: list) -> list: - """文末の記号(」など)を除いた実質的な最終形態素列を返す。""" - i = len(morphemes) - while i > 0 and morphemes[i - 1].part_of_speech()[0] in TRAILING_SYMBOL_POS: - i -= 1 - return morphemes[:i] - - def detect_nominal_ending_and_paragraph_conjunctions( lines: list[tuple[int, str]], tokenized: list[TokenizedSentence], @@ -827,7 +916,7 @@ def detect_nominal_ending_and_paragraph_conjunctions( total_sentences += 1 total_chars += len(ts.raw_text) last_line = ts.line - effective = _strip_trailing_symbols(ts.morphemes) + effective = strip_trailing_symbols(ts.morphemes) if not effective: continue last = effective[-1] @@ -1761,6 +1850,11 @@ def run_lint( low_spec_findings, low_spec_stats = detect_low_specificity(lines, raw_lines_by_no) findings += low_spec_findings + findings += detect_heading_length_uniformity( + raw_text, + threshold=profile.get("heading_length_cv_threshold", HEADING_LENGTH_CV_THRESHOLD), + ) + # EXPERIMENTAL_CATEGORIES はデフォルトでは除外する(--experimental でのみ出力)。 if not experimental: findings = [f for f in findings if f.category not in EXPERIMENTAL_CATEGORIES] From c4b9523589b789788b8f21707afc997e89aec08c Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 14 Jul 2026 11:28:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Revert=20"feat(lint):=20--genre=20tech=20?= =?UTF-8?q?=E9=99=90=E5=AE=9A=E3=81=AE=20heading=5Flength=5Funiformity=20?= =?UTF-8?q?=E6=A4=9C=E5=87=BA=E5=99=A8=E3=82=92=E8=BF=BD=E5=8A=A0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a4d4a71767410f8a59f4e811091a9c5430f3b2fe. --- scripts/lint.py | 116 +++--------------------- skills/natural-japanese/scripts/lint.py | 116 +++--------------------- 2 files changed, 22 insertions(+), 210 deletions(-) diff --git a/scripts/lint.py b/scripts/lint.py index 34071a8..2d1f358 100644 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -41,12 +41,9 @@ from pathlib import Path from textcore import ( Finding, - NOUN_ENDING_POS, SENTENCE_SPLIT_RE, - TRAILING_SYMBOL_POS, _HEADING_RE, _LIST_ITEM_RE, - _heading_level_and_text, get_tokenizer, iter_lines_with_no, iter_paragraphs_with_lines, @@ -54,7 +51,6 @@ from textcore import ( mask_markdown_structure, read_source_file, split_sentences_with_lines, - strip_trailing_symbols, ) # --------------------------------------------------------------------------- @@ -258,36 +254,6 @@ MTLD_THRESHOLD = 40 # 差を示すのは文書長4000字以上のビンのみ(それ未満は human/ai とも0%で無意味)。 LEXDIV_MIN_DOC_CHARS = 4000 -# heading_length_uniformity(見出し長の均質さ)検出器のパラメータ -# -# 設計意図: 他の検出器はすべて「デフォルト閾値があり、ジャンルによって緩める/ -# 無効化する」opt-out方式(disabled_categories)だが、この検出器は逆に -# 「デフォルトでは無効、--genre tech のときだけ opt-in で有効になる」。 -# HEADING_LENGTH_CV_THRESHOLD をモジュール既定値では None にしておき、 -# 検出器側は None なら何もせず空リストを返す。GENRE_PROFILES["tech"] にだけ -# 数値の閾値を設定し、そのジャンルでのみ検出器が実際に働くようにする。 -# これは、見出し長の均質さが tech ジャンル以外(business の省庁系定型見出し等) -# では human 側の誤検知が高く、汎用の検出器として成立しないと実測されたため -# (下記コメント参照)。 -HEADING_CV_MIN_HEADINGS = 5 -HEADING_LENGTH_CV_THRESHOLD: float | None = None -# 2026-07 実測(corpus/reports/heading-stats-analysis.md 追記分「コーパス拡充後の -# 再スイープと検出器化」節): human tech 45本(2022年以前公開、拡充済みコーパスの -# genre:tech 全件)vs AI tech 81本(7モデル、見出し0本の3件を除く)で見出し長 -# (文字数、h2以下・h1除く)の変動係数(CV = pstdev/mean、見出し5本以上の文書のみ -# 判定対象)をスイープした。閾値0.30で human FP率4.4%(2/45: zenn-tech- -# hideoamezawa-a4c0a5d2 CV=0.261、hatena-tech-cohalz-cache-improvement CV=0.267。 -# ともに見出し5本以上の中での最小CV)・AI検出率32.1%(26/81)。 -# 閾値0.40まで緩めるとAI検出率は55.6%まで伸びるが、human FP率も15.6%(7/45)まで -# 悪化する(他の校正済み検出器が目安とする「human FP<5%」を大きく超える)ため、 -# 0.30を採用してFP優先で保守的に振る。 -# ジャンル横断で見ると business(省庁系白書・報告書の定型見出し。「第1章」 -# 「1. 概要」等の均質な短い見出しが正当に多い)は同じ閾値0.30で human FP率12.5% -# (4/32、corpus/human/web/biz-*)まで悪化するため、tech ジャンル限定の opt-in -# 検出器とする(GENRE_PROFILES["tech"]でのみ閾値0.30を設定)。体言止め率・ -# テンプレ語彙の併用も試したが human/AI を分離せず、単純なCVのみを採用した。 -GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD = 0.30 - # --------------------------------------------------------------------------- # low_specificity(具体性/一般論臭)検出器のパラメータ # @@ -410,10 +376,6 @@ GENRE_PROFILES: dict[str, dict] = { # 目標の5%を超えたため、0.045に緩める(tech human critical化率0%に低下、 # AI側もcritical 9/84 + warn 6/84 で検出は維持)。 "antithesis_rate_critical_above": 0.045, - # heading_length_uniformity は tech ジャンルのみで有効化する opt-in 検出器 - # (モジュール定数 HEADING_LENGTH_CV_THRESHOLD のコメント・GENRE_TECH_ - # HEADING_LENGTH_CV_THRESHOLD の実測根拠コメントを参照)。 - "heading_length_cv_threshold": GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD, }, # business は 2026-07 の実地校正(corpus/reports/business-calibration.md)で # 実測した。人間側コーパスは corpus/human/web の biz-* 10件のみと薄いため、 @@ -496,7 +458,6 @@ _CATEGORY_ONLY_KEY_CATEGORIES = { "uniform_paragraph_structure", "low_lexical_diversity_ttr", "low_lexical_diversity_mtld", - "heading_length_uniformity", } @@ -768,66 +729,8 @@ def detect_low_sentence_length_variance( return [] -def detect_heading_length_uniformity( - raw_text: str, - min_headings: int = HEADING_CV_MIN_HEADINGS, - threshold: float | None = HEADING_LENGTH_CV_THRESHOLD, -) -> list[Finding]: - """見出し(h2以下)の長さ(文字数)の変動係数(CV = pstdev/mean)が閾値未満なら - 「見出しの長さが均質すぎる = AI臭い」として info で指摘する。 - - h1は文書タイトルなので集計対象から除外する。lint.py のマスク処理は見出し行を - 空にしてしまうため、マスク前の raw_text から直接見出し行を拾い、 - textcore._heading_level_and_text で見出しテキストを取り出す。 - - この検出器はデフォルト(genre未指定)では無効(threshold=None)。 - HEADING_LENGTH_CV_THRESHOLD のコメント、および GENRE_PROFILES["tech"] の - heading_length_cv_threshold を参照。--genre tech のときだけ数値の閾値が - 渡され、実際に発火しうる。 - """ - if threshold is None: - return [] - - heading_lines: list[tuple[int, str]] = [] - for no, line in iter_lines_with_no(raw_text): - m = _HEADING_RE.match(line) - if not m: - continue - level, text = _heading_level_and_text(line) - if level < 2 or not text: - continue - heading_lines.append((no, text)) - - if len(heading_lines) < min_headings: - return [] - - lengths = [len(text) for _, text in heading_lines] - mean = statistics.mean(lengths) - if mean == 0: - return [] - stdev = statistics.pstdev(lengths) - cv = stdev / mean - if cv >= threshold: - return [] - - related = [no for no, _ in heading_lines] - return [ - Finding( - line=related[0], - category="heading_length_uniformity", - excerpt=f"見出し数={len(heading_lines)}, 平均長={mean:.1f}字, 変動係数={cv:.3f}", - severity="info", - detail=( - f"見出し長の変動係数が閾値({threshold})未満。見出しの長さが均質すぎて" - f"AI臭い可能性。{format_related_lines(related)}" - ), - related_lines=related, - ) - ] - - -# NOUN_ENDING_POS / TRAILING_SYMBOL_POS は textcore.py に移設済み(outline.py の -# 見出し統計と共有するため)。ここでは textcore からの import をそのまま使う。 +NOUN_ENDING_POS = {"名詞"} +TRAILING_SYMBOL_POS = {"補助記号", "空白"} # 語彙多様性計測の対象とする内容語 POS CONTENT_WORD_POS = {"名詞", "動詞", "形容詞", "副詞"} @@ -883,6 +786,14 @@ def tokenize_sentences(sentences: list[tuple[int, str, str]]) -> list[TokenizedS return result +def _strip_trailing_symbols(morphemes: list) -> list: + """文末の記号(」など)を除いた実質的な最終形態素列を返す。""" + i = len(morphemes) + while i > 0 and morphemes[i - 1].part_of_speech()[0] in TRAILING_SYMBOL_POS: + i -= 1 + return morphemes[:i] + + def detect_nominal_ending_and_paragraph_conjunctions( lines: list[tuple[int, str]], tokenized: list[TokenizedSentence], @@ -916,7 +827,7 @@ def detect_nominal_ending_and_paragraph_conjunctions( total_sentences += 1 total_chars += len(ts.raw_text) last_line = ts.line - effective = strip_trailing_symbols(ts.morphemes) + effective = _strip_trailing_symbols(ts.morphemes) if not effective: continue last = effective[-1] @@ -1850,11 +1761,6 @@ def run_lint( low_spec_findings, low_spec_stats = detect_low_specificity(lines, raw_lines_by_no) findings += low_spec_findings - findings += detect_heading_length_uniformity( - raw_text, - threshold=profile.get("heading_length_cv_threshold", HEADING_LENGTH_CV_THRESHOLD), - ) - # EXPERIMENTAL_CATEGORIES はデフォルトでは除外する(--experimental でのみ出力)。 if not experimental: findings = [f for f in findings if f.category not in EXPERIMENTAL_CATEGORIES] diff --git a/skills/natural-japanese/scripts/lint.py b/skills/natural-japanese/scripts/lint.py index 34071a8..2d1f358 100644 --- a/skills/natural-japanese/scripts/lint.py +++ b/skills/natural-japanese/scripts/lint.py @@ -41,12 +41,9 @@ from pathlib import Path from textcore import ( Finding, - NOUN_ENDING_POS, SENTENCE_SPLIT_RE, - TRAILING_SYMBOL_POS, _HEADING_RE, _LIST_ITEM_RE, - _heading_level_and_text, get_tokenizer, iter_lines_with_no, iter_paragraphs_with_lines, @@ -54,7 +51,6 @@ from textcore import ( mask_markdown_structure, read_source_file, split_sentences_with_lines, - strip_trailing_symbols, ) # --------------------------------------------------------------------------- @@ -258,36 +254,6 @@ MTLD_THRESHOLD = 40 # 差を示すのは文書長4000字以上のビンのみ(それ未満は human/ai とも0%で無意味)。 LEXDIV_MIN_DOC_CHARS = 4000 -# heading_length_uniformity(見出し長の均質さ)検出器のパラメータ -# -# 設計意図: 他の検出器はすべて「デフォルト閾値があり、ジャンルによって緩める/ -# 無効化する」opt-out方式(disabled_categories)だが、この検出器は逆に -# 「デフォルトでは無効、--genre tech のときだけ opt-in で有効になる」。 -# HEADING_LENGTH_CV_THRESHOLD をモジュール既定値では None にしておき、 -# 検出器側は None なら何もせず空リストを返す。GENRE_PROFILES["tech"] にだけ -# 数値の閾値を設定し、そのジャンルでのみ検出器が実際に働くようにする。 -# これは、見出し長の均質さが tech ジャンル以外(business の省庁系定型見出し等) -# では human 側の誤検知が高く、汎用の検出器として成立しないと実測されたため -# (下記コメント参照)。 -HEADING_CV_MIN_HEADINGS = 5 -HEADING_LENGTH_CV_THRESHOLD: float | None = None -# 2026-07 実測(corpus/reports/heading-stats-analysis.md 追記分「コーパス拡充後の -# 再スイープと検出器化」節): human tech 45本(2022年以前公開、拡充済みコーパスの -# genre:tech 全件)vs AI tech 81本(7モデル、見出し0本の3件を除く)で見出し長 -# (文字数、h2以下・h1除く)の変動係数(CV = pstdev/mean、見出し5本以上の文書のみ -# 判定対象)をスイープした。閾値0.30で human FP率4.4%(2/45: zenn-tech- -# hideoamezawa-a4c0a5d2 CV=0.261、hatena-tech-cohalz-cache-improvement CV=0.267。 -# ともに見出し5本以上の中での最小CV)・AI検出率32.1%(26/81)。 -# 閾値0.40まで緩めるとAI検出率は55.6%まで伸びるが、human FP率も15.6%(7/45)まで -# 悪化する(他の校正済み検出器が目安とする「human FP<5%」を大きく超える)ため、 -# 0.30を採用してFP優先で保守的に振る。 -# ジャンル横断で見ると business(省庁系白書・報告書の定型見出し。「第1章」 -# 「1. 概要」等の均質な短い見出しが正当に多い)は同じ閾値0.30で human FP率12.5% -# (4/32、corpus/human/web/biz-*)まで悪化するため、tech ジャンル限定の opt-in -# 検出器とする(GENRE_PROFILES["tech"]でのみ閾値0.30を設定)。体言止め率・ -# テンプレ語彙の併用も試したが human/AI を分離せず、単純なCVのみを採用した。 -GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD = 0.30 - # --------------------------------------------------------------------------- # low_specificity(具体性/一般論臭)検出器のパラメータ # @@ -410,10 +376,6 @@ GENRE_PROFILES: dict[str, dict] = { # 目標の5%を超えたため、0.045に緩める(tech human critical化率0%に低下、 # AI側もcritical 9/84 + warn 6/84 で検出は維持)。 "antithesis_rate_critical_above": 0.045, - # heading_length_uniformity は tech ジャンルのみで有効化する opt-in 検出器 - # (モジュール定数 HEADING_LENGTH_CV_THRESHOLD のコメント・GENRE_TECH_ - # HEADING_LENGTH_CV_THRESHOLD の実測根拠コメントを参照)。 - "heading_length_cv_threshold": GENRE_TECH_HEADING_LENGTH_CV_THRESHOLD, }, # business は 2026-07 の実地校正(corpus/reports/business-calibration.md)で # 実測した。人間側コーパスは corpus/human/web の biz-* 10件のみと薄いため、 @@ -496,7 +458,6 @@ _CATEGORY_ONLY_KEY_CATEGORIES = { "uniform_paragraph_structure", "low_lexical_diversity_ttr", "low_lexical_diversity_mtld", - "heading_length_uniformity", } @@ -768,66 +729,8 @@ def detect_low_sentence_length_variance( return [] -def detect_heading_length_uniformity( - raw_text: str, - min_headings: int = HEADING_CV_MIN_HEADINGS, - threshold: float | None = HEADING_LENGTH_CV_THRESHOLD, -) -> list[Finding]: - """見出し(h2以下)の長さ(文字数)の変動係数(CV = pstdev/mean)が閾値未満なら - 「見出しの長さが均質すぎる = AI臭い」として info で指摘する。 - - h1は文書タイトルなので集計対象から除外する。lint.py のマスク処理は見出し行を - 空にしてしまうため、マスク前の raw_text から直接見出し行を拾い、 - textcore._heading_level_and_text で見出しテキストを取り出す。 - - この検出器はデフォルト(genre未指定)では無効(threshold=None)。 - HEADING_LENGTH_CV_THRESHOLD のコメント、および GENRE_PROFILES["tech"] の - heading_length_cv_threshold を参照。--genre tech のときだけ数値の閾値が - 渡され、実際に発火しうる。 - """ - if threshold is None: - return [] - - heading_lines: list[tuple[int, str]] = [] - for no, line in iter_lines_with_no(raw_text): - m = _HEADING_RE.match(line) - if not m: - continue - level, text = _heading_level_and_text(line) - if level < 2 or not text: - continue - heading_lines.append((no, text)) - - if len(heading_lines) < min_headings: - return [] - - lengths = [len(text) for _, text in heading_lines] - mean = statistics.mean(lengths) - if mean == 0: - return [] - stdev = statistics.pstdev(lengths) - cv = stdev / mean - if cv >= threshold: - return [] - - related = [no for no, _ in heading_lines] - return [ - Finding( - line=related[0], - category="heading_length_uniformity", - excerpt=f"見出し数={len(heading_lines)}, 平均長={mean:.1f}字, 変動係数={cv:.3f}", - severity="info", - detail=( - f"見出し長の変動係数が閾値({threshold})未満。見出しの長さが均質すぎて" - f"AI臭い可能性。{format_related_lines(related)}" - ), - related_lines=related, - ) - ] - - -# NOUN_ENDING_POS / TRAILING_SYMBOL_POS は textcore.py に移設済み(outline.py の -# 見出し統計と共有するため)。ここでは textcore からの import をそのまま使う。 +NOUN_ENDING_POS = {"名詞"} +TRAILING_SYMBOL_POS = {"補助記号", "空白"} # 語彙多様性計測の対象とする内容語 POS CONTENT_WORD_POS = {"名詞", "動詞", "形容詞", "副詞"} @@ -883,6 +786,14 @@ def tokenize_sentences(sentences: list[tuple[int, str, str]]) -> list[TokenizedS return result +def _strip_trailing_symbols(morphemes: list) -> list: + """文末の記号(」など)を除いた実質的な最終形態素列を返す。""" + i = len(morphemes) + while i > 0 and morphemes[i - 1].part_of_speech()[0] in TRAILING_SYMBOL_POS: + i -= 1 + return morphemes[:i] + + def detect_nominal_ending_and_paragraph_conjunctions( lines: list[tuple[int, str]], tokenized: list[TokenizedSentence], @@ -916,7 +827,7 @@ def detect_nominal_ending_and_paragraph_conjunctions( total_sentences += 1 total_chars += len(ts.raw_text) last_line = ts.line - effective = strip_trailing_symbols(ts.morphemes) + effective = _strip_trailing_symbols(ts.morphemes) if not effective: continue last = effective[-1] @@ -1850,11 +1761,6 @@ def run_lint( low_spec_findings, low_spec_stats = detect_low_specificity(lines, raw_lines_by_no) findings += low_spec_findings - findings += detect_heading_length_uniformity( - raw_text, - threshold=profile.get("heading_length_cv_threshold", HEADING_LENGTH_CV_THRESHOLD), - ) - # EXPERIMENTAL_CATEGORIES はデフォルトでは除外する(--experimental でのみ出力)。 if not experimental: findings = [f for f in findings if f.category not in EXPERIMENTAL_CATEGORIES]