Files

645 lines
21 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())