#!/usr/bin/env python3 """检查中文成稿的硬禁令与常见模型化形状。只报警,不自动改文。 来源:https://github.com/KKKKhazix/human-writing (MIT,版权归 Human Writing Skill contributors)。本工坊原样引入未改检测规则;禁令清单见 references/human-voice.md 第七节与 review-playbook.md「模型腔硬禁清单」(增删禁词须同步改本脚本)。 """ from __future__ import annotations import argparse import collections import re import sys from dataclasses import dataclass from pathlib import Path HARD_STOPS = ( "说白了", "说穿了", "先说结论", ) HARD_JARGON = ( "赋能", "抓手", "商业闭环", "价值闭环", "能力沉淀", "拉通", "底层逻辑", "顶层设计", "认知跃迁", "价值释放", "能力建设", "降本增效", "内容矩阵", "全链路", "组合拳", "打开想象空间", "结构性机会", "关键命题", "深层逻辑", "技术底座", "公共底座", "技术主权", "单点风险", "主脊柱", "材料锚点", "认知增量", "迭代闭环", ) CONTEXT_JARGON = ( "沉淀", "颗粒度", "对齐", "协同", "链路", "生态位", "心智", "范式", "方法论", "核心变量", "打法", "想象空间", "闭环", "不丢", ) LYRIC_WORDS = ( "安放", "抵达", "微光", "褶皱", "丰盈", "滚烫", "轻盈", "赤裸", "剥开", ) ROAD_SIGNS = ( "更微妙的是", "还有一层", "只说对了一半", "值得注意的是", "需要指出的是", "从某种意义上说", ) ROAD_STRIP_CHARS = "。!?!? \n" FORBIDDEN_PUNCTUATION = { ":": "中文冒号", ":": "英文冒号", "—": "破折号", "–": "连接号式破折号", } PIVOT_PATTERNS = ( re.compile(r"(?:并)?不是[^。!?\n]{0,90}而是"), re.compile(r"并非[^。!?\n]{0,90}而是"), re.compile(r"不在于[^。!?\n]{0,90}而在于"), re.compile(r"与其说[^。!?\n]{0,90}(?:不如|毋宁|倒不如)"), re.compile(r"[。!?!?]\s*而是"), re.compile(r"表面(?:上)?[^。!?\n]{0,90}(?:其实|实际|实则)"), re.compile(r"看似[^。!?\n]{0,90}(?:其实|实际|实则)"), ) SEMANTIC_PIVOT_PATTERNS = ( re.compile(r"(?:总|一直|曾|都)?以为[^!?\n]{2,60}?(?:其实|才发现|才明白|才知道|后来才)"), re.compile(r"(?:总|都|一直)以为[^!?\n]{2,60}?[。,](?:可|但|其实)"), re.compile(r"回头(?:看|一看)?才(?:发现|明白|知道)"), re.compile(r"(?:并)?不是[^。!?\n]{1,40},(?:更|才)?是[^,。!?\n]"), re.compile(r"从来(?:都)?(?:不是|与[^。!?,\n]{1,12}无关)"), re.compile(r"答案(?:是否定的|恰恰相反)|恰恰相反"), re.compile(r"表面(?:上)?[^!?\n]{0,60}。[^!?\n]{0,12}(?:其实|实际|实则)"), re.compile(r"看似[^!?\n]{0,60}。[^!?\n]{0,12}(?:其实|实际|实则)"), re.compile(r"[^,。!?\n]{1,12}不重要,(?:重要|要紧)的是"), re.compile(r"真正[^,。!?\n]{0,16}的(?:,)?是"), re.compile(r"不只(?:是)?[^。!?\n]{0,90}(?:还|也)"), ) NOMINALIZATION_PATTERNS = ( re.compile(r"进行(?:了|一次|一场|着)?[^。,!?\n]{0,10}(?:调整|优化|升级|分析|讨论|沟通|梳理|复盘|迭代|探索|尝试|思考|规划|布局)"), re.compile(r"实现了?[^。,!?\n]{0,14}的?[^。,!?\n]{0,6}(?:提升|增长|突破|转变|跃升|落地)"), re.compile(r"完成了?对[^。,!?\n]{0,16}的"), re.compile(r"起到了?[^。,!?\n]{0,12}的?作用"), re.compile(r"具有[^。,!?\n]{0,10}(?:意义|价值)"), ) CONJUNCTIONS = ( "因为", "所以", "但是", "然而", "同时", "此外", "而且", "并且", "因此", "不仅", ) ROAD_SIGN_PATTERNS = ( re.compile( rf"(?:^|[。!?!?]\s*){re.escape(ROAD_SIGNS[0])}[^。!?!?\n]{{0,24}}", re.MULTILINE, ), re.compile( rf"(?:^|[。!?!?]\s*){re.escape(ROAD_SIGNS[1])}(?=(?:更|原因|问题|意思|考虑|变化|逻辑|价值|作用|风险|影响|值得|很少|不容易|常被|往往))[^。!?!?\n]{{0,24}}", re.MULTILINE, ), *( re.compile( rf"(?:^|[。!?!?]\s*){re.escape(phrase)}[^。!?!?\n]{{0,24}}", re.MULTILINE, ) for phrase in ROAD_SIGNS[2:] ), ) SOFT_MARKERS = ( "真正", "本质上", "更深层次", "归根结底", "换句话说", "不可否认", "核心是", "关键在于", "这意味着", ) REPEATED_OPENERS = ( "其实", "不过", "当然", "所以", "但是", "后来", "当时", "很多人", "问题是", "更重要的是", "说到这里", ) LEFT_BRANCH_PATTERNS = ( re.compile(r"(?:^|[。!?]\s*)在[^,。!?\n]{12,70}(?:以后|之后|之前|以前|过程中|情况下|背景下),"), re.compile(r"(?:^|[。!?]\s*)那些[^,。!?\n]{10,60}的[^,。!?\n]{2,30}[,。]"), re.compile(r"(?:^|[。!?]\s*)(?:真正|最终|最后)让[^,。!?\n]{8,70}的,是"), ) METAPHOR_FIELDS = { "温度": ("降温", "升温", "冷却", "余温", "温度最高"), "生死战争": ("杀死", "死因", "枪响", "开火", "战场", "引爆", "弹药"), "建筑灾害": ("坍塌", "崩塌", "地基", "砖头", "支柱", "废墟"), "仓储租赁": ("仓库", "库房", "租金", "取货", "入库", "库存"), "道路竞赛": ("赛道", "跑道", "岔路", "十字路口", "终点线", "门票"), "机器器官": ("齿轮", "引擎", "发动机", "血管", "骨架", "肌肉"), "海洋航行": ("蓝海", "浪潮", "潮水", "航船", "灯塔", "彼岸"), } @dataclass class Paragraph: position: int text: str han: int sentences: int def han_count(text: str) -> int: return len(re.findall(r"[\u4e00-\u9fff]", text)) def line_number(text: str, position: int) -> int: return text.count("\n", 0, position) + 1 def excerpt(value: str, width: int = 72) -> str: value = re.sub(r"\s+", " ", value).strip() return value if len(value) <= width else value[: width - 1] + "…" def mask_non_prose(text: str) -> str: """屏蔽代码、网址和机器元数据,同时保留字符位置与换行。""" def mask(match: re.Match[str]) -> str: return "".join("\n" if char == "\n" else " " for char in match.group()) patterns = ( re.compile(r"\A---\s*\n.*?\n---\s*(?:\n|\Z)", re.DOTALL), re.compile(r"```.*?```", re.DOTALL), re.compile(r"`[^`\n]*`"), re.compile(r"\]\([^\n)]*\)"), re.compile(r"https?://[^\s)>]+"), re.compile(r"<[^>\n]+>"), ) masked = text for pattern in patterns: masked = pattern.sub(mask, masked) return masked def non_overlapping_terms(text: str, terms: tuple[str, ...]): matches = [] occupied = [] for term in sorted(terms, key=len, reverse=True): for match in re.finditer(re.escape(term), text): start, end = match.span() if any(start < old_end and end > old_start for old_start, old_end in occupied): continue matches.append((start, term)) occupied.append((start, end)) return sorted(matches) def all_matches(text: str, patterns: tuple[re.Pattern[str], ...]): matches = [] for pattern in patterns: matches.extend(pattern.finditer(text)) return sorted(matches, key=lambda match: match.start()) def heavy_de_sentences(text: str): """找出主干可能被多个“的”压到后面的长句。""" matches = [] pattern = re.compile(r"[^。!?!?\n]+(?:[。!?!?]|$)") for match in pattern.finditer(text): value = match.group() if han_count(value) >= 38 and value.count("的") >= 4: matches.append(match) return matches def anaphora_runs(text: str, minimum: int = 3): """找出同一句里三个以上小句用同一个开头的排比。""" matches = [] for sentence in re.finditer(r"[^。!?!?\n]+(?:[。!?!?]|$)", text): clauses = [ clause.strip() for clause in re.split(r"[,、;,;]", sentence.group()) if han_count(clause) >= 3 ] if len(clauses) < minimum: continue run = 1 for previous, current in zip(clauses, clauses[1:]): if previous[:2] == current[:2] and re.match(r"[一-鿿]{2}", current): run += 1 if run >= minimum: matches.append(sentence) break else: run = 1 return matches def sentence_length_cv(text: str): """句长变异系数。人写的长短句差距大,模型的句长彼此接近。""" lengths = [ han_count(match.group()) for match in re.finditer(r"[^。!?!?\n]+[。!?!?]", text) if han_count(match.group()) >= 4 ] if len(lengths) < 12: return None mean = sum(lengths) / len(lengths) if mean == 0: return None variance = sum((value - mean) ** 2 for value in lengths) / len(lengths) return (variance ** 0.5) / mean, len(lengths) def bracket_highlights(text: str): """「」括起来的短语。太密说明在批量造金句。""" return list(re.finditer(r"[「『][^」』\n]{1,6}[」』]", text)) def prose_paragraphs(text: str) -> list[Paragraph]: paragraphs = [] cursor = 0 for block in re.split(r"\n\s*\n", text): position = text.find(block, cursor) cursor = max(position + len(block), cursor) clean = re.sub(r"[>*_`]", "", block).strip() if not clean or clean.startswith(("#", "http", "![", "```")): continue if re.match(r"^(?:[-+*]|\d+[.、])\s", clean): continue count = han_count(clean) if count < 4: continue sentences = max(1, len(re.findall(r"[。!?!?]", clean))) paragraphs.append(Paragraph(position, clean, count, sentences)) return paragraphs def metaphor_cluster(text: str, distance: int = 800): hits = [] for field, words in METAPHOR_FIELDS.items(): for word in words: for match in re.finditer(re.escape(word), text): hits.append((match.start(), field, word)) hits.sort() for index, (start, _, _) in enumerate(hits): window = [hit for hit in hits[index:] if hit[0] - start <= distance] fields = {hit[1] for hit in window} if len(fields) >= 3: return window, fields return None def short_streak(paragraphs: list[Paragraph], limit: int = 4): streak = [] for paragraph in paragraphs: if paragraph.han <= 24 and paragraph.sentences <= 1: streak.append(paragraph) if len(streak) >= limit: return streak else: streak = [] return None def opener_counts(paragraphs: list[Paragraph]): counts = collections.Counter() examples = {} for paragraph in paragraphs: value = paragraph.text.lstrip("“‘\"((") for opener in REPEATED_OPENERS: if value.startswith(opener): counts[opener] += 1 examples.setdefault(opener, paragraph.position) break return counts, examples def read_text(path: str) -> str: if path == "-": return sys.stdin.read() return Path(path).read_text(encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser(description="检查中文成稿的硬禁令与模型化形状") parser.add_argument("path", help="Markdown 或文本文件路径。使用 - 从标准输入读取") args = parser.parse_args() try: text = read_text(args.path) except (OSError, UnicodeError) as error: print(f"无法读取稿件。{error}", file=sys.stderr) return 2 prose = mask_non_prose(text) total_han = han_count(prose) if total_han == 0: print("没有检测到汉字。", file=sys.stderr) return 2 failures = [] warnings = [] quote_colons = [] for symbol, label in FORBIDDEN_PUNCTUATION.items(): matches = list(re.finditer(re.escape(symbol), prose)) if symbol in (":", ":"): hard = [] for match in matches: tail = prose[match.end() : match.end() + 2].lstrip() if tail[:1] in ("「", "『", "“", "‘", '"'): quote_colons.append(match) else: hard.append(match) matches = hard if matches: lines = "、".join(str(line_number(text, match.start())) for match in matches[:8]) failures.append(f"{label}共 {len(matches)} 处,出现在第 {lines} 行。") if quote_colons: lines = "、".join( str(line_number(text, match.start())) for match in quote_colons[:8] ) warnings.append( f"引出原话的冒号 {len(quote_colons)} 处,第 {lines} 行。确认引号里确实是原话,且不是提示性用法。" ) stop_matches = non_overlapping_terms(prose, HARD_STOPS) for position, phrase in stop_matches: failures.append(f"硬停词,第 {line_number(text, position)} 行,{phrase}") jargon_matches = non_overlapping_terms(prose, HARD_JARGON) for position, phrase in jargon_matches: failures.append(f"黑话,第 {line_number(text, position)} 行,{phrase}") context_jargon_matches = non_overlapping_terms(prose, CONTEXT_JARGON) hard_spans = [ (position, position + len(phrase)) for position, phrase in jargon_matches ] context_jargon_matches = [ (position, phrase) for position, phrase in context_jargon_matches if not any( position < end and position + len(phrase) > start for start, end in hard_spans ) ] if context_jargon_matches: samples = "、".join( dict.fromkeys(phrase for _, phrase in context_jargon_matches) ) lines = "、".join( dict.fromkeys( str(line_number(text, position)) for position, _ in context_jargon_matches[:8] ) ) warnings.append( f"有 {len(context_jargon_matches)} 处词语需要结合语境判断。" f"第 {lines} 行出现 {samples}。本义准确时保留,用来抬价时改写。" ) road_signs = all_matches(prose, ROAD_SIGN_PATTERNS) for match in road_signs: failures.append( f"模型路标,第 {line_number(text, match.start())} 行," f"“{excerpt(match.group().lstrip(ROAD_STRIP_CHARS))}”" ) pivots = all_matches(prose, PIVOT_PATTERNS) for match in pivots: failures.append( f"禁用翻案句,第 {line_number(text, match.start())} 行," f"“{excerpt(match.group())}”" ) occupied_spans = [match.span() for match in pivots] semantic_pivots = [] for match in all_matches(prose, SEMANTIC_PIVOT_PATTERNS): if any( match.start() < end and match.end() > start for start, end in occupied_spans ): continue semantic_pivots.append(match) occupied_spans.append(match.span()) for match in semantic_pivots: warnings.append( f"疑似翻案腔变形,第 {line_number(text, match.start())} 行," f"“{excerpt(match.group(), 44)}”。先立误解再推翻就改成正面陈述,正常用法保留。" ) anaphoras = anaphora_runs(prose) for match in anaphoras[:4]: warnings.append( f"三连以上同构排比,第 {line_number(text, match.start())} 行," f"“{excerpt(match.group(), 44)}”。留两项,第三项换说法或删掉。" ) lyric_matches = non_overlapping_terms(prose, LYRIC_WORDS) if len(lyric_matches) >= 2: samples = "、".join(dict.fromkeys(term for _, term in lyric_matches)) warnings.append( f"模型偏爱的抒情词 {len(lyric_matches)} 处。{samples}。" "写具体事物时保留,给抽象概念穿衣服时删掉。" ) nominalizations = all_matches(prose, NOMINALIZATION_PATTERNS) for match in nominalizations[:4]: warnings.append( f"名词化句式,第 {line_number(text, match.start())} 行," f"“{excerpt(match.group(), 36)}”。还原成直接的动词。" ) conjunction_hits = non_overlapping_terms(prose, CONJUNCTIONS) if total_han >= 600 and len(conjunction_hits) * 1000 / total_han > 7: samples = "、".join( f"{term} {count} 次" for term, count in collections.Counter( term for _, term in conjunction_hits ).most_common(4) ) warnings.append( f"连词密度偏高,每千字 {len(conjunction_hits) * 1000 // total_han} 个。{samples}。" "中文小句靠语序和事理相接,删掉一半试试。" ) highlights = bracket_highlights(prose) highlight_limit = max(3, total_han // 700) if len(highlights) > highlight_limit: samples = "、".join(dict.fromkeys(match.group() for match in highlights[:6])) warnings.append( f"「」括起的短语共 {len(highlights)} 处。{samples}。太密说明在批量造金句。" ) cv_result = sentence_length_cv(prose) if cv_result and cv_result[0] < 0.42: warnings.append( f"全文 {cv_result[1]} 个句子长度过于接近(变异系数 {cv_result[0]:.2f})。" "人写的段落里十个字的句子会挨着四十个字的句子,放开几句,压短几句。" ) marker_matches = non_overlapping_terms(prose, SOFT_MARKERS) marker_limit = max(2, total_han // 900) if len(marker_matches) > marker_limit: samples = "、".join(dict.fromkeys(term for _, term in marker_matches)) warnings.append( f"洞察路标共 {len(marker_matches)} 处,当前提醒线为 {marker_limit} 处。" f"重点检查 {samples}。" ) left_branches = all_matches(prose, LEFT_BRANCH_PATTERNS) left_limit = max(2, total_han // 1200) if len(left_branches) > left_limit: samples = ";".join( f"第 {line_number(text, match.start())} 行“{excerpt(match.group(), 44)}”" for match in left_branches[:4] ) warnings.append( f"长前置成分共 {len(left_branches)} 处,可能让主干来得太晚。{samples}" ) dense_de = heavy_de_sentences(prose) dense_de_limit = max(1, total_han // 1500) if len(dense_de) > dense_de_limit: samples = ";".join( f"第 {line_number(text, match.start())} 行“{excerpt(match.group(), 44)}”" for match in dense_de[:4] ) warnings.append( f"有 {len(dense_de)} 个长句包含四个以上的“的”,可能要先交代人和动作。{samples}" ) paragraphs = prose_paragraphs(prose) if len(paragraphs) >= 10: one_sentence = sum(paragraph.sentences <= 1 for paragraph in paragraphs) ratio = one_sentence / len(paragraphs) if ratio >= 0.75: warnings.append( f"可识别段落中有 {ratio:.0%} 只有一句话,可能形成统一的短段鼓点。" ) streak = short_streak(paragraphs) if streak: first = streak[0] warnings.append( f"从第 {line_number(text, first.position)} 行起连续出现 {len(streak)} 个短促单句段," "检查是否在排队喊结论。" ) counts, opener_examples = opener_counts(paragraphs) repeated = [(opener, count) for opener, count in counts.items() if count >= 4] if repeated: details = "、".join(f"{opener} {count} 次" for opener, count in repeated) first_position = min(opener_examples[opener] for opener, _ in repeated) warnings.append( f"段落开场重复,从第 {line_number(text, first_position)} 行附近开始。{details}。" ) metaphors = metaphor_cluster(prose) if metaphors: window, fields = metaphors samples = "、".join(dict.fromkeys(hit[2] for hit in window)) warnings.append( f"八百字内出现 {len(fields)} 套借喻。{'、'.join(sorted(fields))}。" f"例词有 {samples}。" ) print(f"汉字数 {total_han}") print( f"翻案句 {len(pivots)},翻案腔变形 {len(semantic_pivots)}," f"同构排比 {len(anaphoras)},名词化 {len(nominalizations)}," f"黑话 {len(jargon_matches)},硬停词 {len(stop_matches)}," f"模型路标 {len(road_signs)},需辨语境词 {len(context_jargon_matches)}," f"抒情词 {len(lyric_matches)},洞察路标 {len(marker_matches)}," f"长前置成分 {len(left_branches)},重定语句 {len(dense_de)}" ) if failures: print("\n需要修改") for item in failures: print(f"- {item}") if warnings: print("\n需要人工判断") for item in warnings: print(f"- {item}") if not failures and not warnings: print("\n未发现这份检查器覆盖的问题。") return 1 if failures else 0 if __name__ == "__main__": raise SystemExit(main())