le-wechat-article v1.3:融合 human-writing 活人感写法(材料关 / 说话位置 / 活人感审读 + check_prose.py 双自检);附 check_article front matter 剥离修复

This commit is contained in:
leroy
2026-10-06 21:30:36 +08:00
parent 500d8224db
commit 3af717a76d
10 changed files with 788 additions and 19 deletions
@@ -14,7 +14,7 @@ TYPE_RANGES = {
"简报": (1500, 3000), "测评": (2500, 5000), "复盘": (2500, 6000),
"技术": (2500, 6000), "叙事": (2000, 5000),
}
FILLER_PATTERNS = ["在当今", "随着{}的发展", "综上所述", "总而言之", "值得注意的是",
FILLER_PATTERNS = ["在当今", "随着({})的发展", "综上所述", "总而言之", "值得注意的是",
"不难发现", "让我们来看看", "下面让我们", "众所周知"]
HAN = r"\u4e00-\u9fff"
@@ -38,7 +38,8 @@ def split_blocks(md):
def check(path, art_type, strict, allow_placeholder=False):
errors, warns, infos = [], [], []
md = open(path, encoding="utf-8").read()
blocks = split_blocks(md)
fm_pre = re.match(r"^---\n.*?\n---\n", md, re.S)
blocks = split_blocks(md[fm_pre.end():] if fm_pre else md)
# ---- 标题层级 ----
h1 = [ln for ln, f in blocks if not f and re.match(r"^# ", ln)]
+644
View File
@@ -0,0 +1,644 @@
#!/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())