wewrite 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wewrite/__init__.py +3 -0
- wewrite/__main__.py +4 -0
- wewrite/cli.py +82 -0
- wewrite/commands/__init__.py +0 -0
- wewrite/commands/build_playbook.py +198 -0
- wewrite/commands/diagnose.py +398 -0
- wewrite/commands/extract_exemplar.py +373 -0
- wewrite/commands/fetch_article.py +361 -0
- wewrite/commands/fetch_hotspots.py +212 -0
- wewrite/commands/fetch_stats.py +207 -0
- wewrite/commands/humanness_score.py +640 -0
- wewrite/commands/learn_edits.py +515 -0
- wewrite/commands/learn_theme.py +640 -0
- wewrite/commands/llm_write.py +139 -0
- wewrite/commands/seo_keywords.py +119 -0
- wewrite/commands/similarity_check.py +66 -0
- wewrite/commands/validate_html.py +130 -0
- wewrite/data/anti-ai-writing-system.md +20 -0
- wewrite/migrate.py +76 -0
- wewrite/paths.py +67 -0
- wewrite/toolkit/__init__.py +0 -0
- wewrite/toolkit/cli.py +432 -0
- wewrite/toolkit/config.py +90 -0
- wewrite/toolkit/converter.py +786 -0
- wewrite/toolkit/image_gen.py +946 -0
- wewrite/toolkit/publisher.py +182 -0
- wewrite/toolkit/theme.py +208 -0
- wewrite/toolkit/themes/bauhaus.yaml +207 -0
- wewrite/toolkit/themes/bold-green.yaml +198 -0
- wewrite/toolkit/themes/bold-navy.yaml +197 -0
- wewrite/toolkit/themes/bytedance.yaml +199 -0
- wewrite/toolkit/themes/elegant-rose.yaml +198 -0
- wewrite/toolkit/themes/focus-red.yaml +197 -0
- wewrite/toolkit/themes/github.yaml +198 -0
- wewrite/toolkit/themes/impeccable.yaml +220 -0
- wewrite/toolkit/themes/ink.yaml +204 -0
- wewrite/toolkit/themes/lobster-notes.yaml +216 -0
- wewrite/toolkit/themes/midnight.yaml +197 -0
- wewrite/toolkit/themes/minimal-gold.yaml +202 -0
- wewrite/toolkit/themes/minimal.yaml +195 -0
- wewrite/toolkit/themes/newspaper.yaml +206 -0
- wewrite/toolkit/themes/professional-clean.yaml +197 -0
- wewrite/toolkit/themes/sspai.yaml +198 -0
- wewrite/toolkit/themes/tech-modern.yaml +205 -0
- wewrite/toolkit/themes/warm-editorial.yaml +197 -0
- wewrite/toolkit/wechat_api.py +140 -0
- wewrite-3.2.0.dist-info/METADATA +392 -0
- wewrite-3.2.0.dist-info/RECORD +52 -0
- wewrite-3.2.0.dist-info/WHEEL +5 -0
- wewrite-3.2.0.dist-info/entry_points.txt +2 -0
- wewrite-3.2.0.dist-info/licenses/LICENSE +21 -0
- wewrite-3.2.0.dist-info/top_level.txt +1 -0
wewrite/__init__.py
ADDED
wewrite/__main__.py
ADDED
wewrite/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""`wewrite` CLI 调度器 —— 把子命令分发到 commands/ 与 toolkit/ 的既有 main()。
|
|
2
|
+
|
|
3
|
+
设计约束:各命令模块保持独立 argparse(历史上是独立脚本),调度器只做
|
|
4
|
+
「子命令名 → 模块」映射并透传其余参数,不重复定义参数。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .paths import home
|
|
12
|
+
|
|
13
|
+
# 子命令 → (模块, 一句话说明)。模块须提供 main(argv=None) 或 main()。
|
|
14
|
+
_COMMANDS = {
|
|
15
|
+
"diagnose": ("wewrite.commands.diagnose", "环境 + 配置自检(降级标记 JSON)"),
|
|
16
|
+
"score": ("wewrite.commands.humanness_score", "写作质量评分(11 项检测)"),
|
|
17
|
+
"hotspots": ("wewrite.commands.fetch_hotspots", "多平台热点抓取"),
|
|
18
|
+
"seo": ("wewrite.commands.seo_keywords", "SEO 关键词分析"),
|
|
19
|
+
"stats": ("wewrite.commands.fetch_stats", "微信文章数据回填 history"),
|
|
20
|
+
"learn-edits": ("wewrite.commands.learn_edits", "学习人工修改(diff → lessons)"),
|
|
21
|
+
"learn-theme": ("wewrite.commands.learn_theme", "从公众号文章 URL 学排版主题"),
|
|
22
|
+
"exemplar": ("wewrite.commands.extract_exemplar", "范文风格库(导入 / --list)"),
|
|
23
|
+
"fetch-article": ("wewrite.commands.fetch_article", "公众号文章 URL → Markdown"),
|
|
24
|
+
"llm-write": ("wewrite.commands.llm_write", "混合路由写作(DeepSeek 等出稿)"),
|
|
25
|
+
"similarity": ("wewrite.commands.similarity_check", "多平台版本原创度检查"),
|
|
26
|
+
"build-playbook": ("wewrite.commands.build_playbook", "从历史语料生成 playbook"),
|
|
27
|
+
"image-gen": ("wewrite.toolkit.image_gen", "AI 图片生成(多 provider fallback)"),
|
|
28
|
+
"validate": ("wewrite.commands.validate_html", "HTML 微信兼容性校验"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# toolkit/cli.py 自带子命令(preview/publish/gallery/themes/image-post/learn-theme),
|
|
32
|
+
# 这些名字直接整体透传给它。
|
|
33
|
+
_TOOLKIT_PASSTHROUGH = {"preview", "publish", "gallery", "themes", "image-post"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _usage() -> str:
|
|
37
|
+
lines = [f"wewrite {__version__} — 公众号内容管道 CLI(状态目录: {home()})", "", "用法: wewrite <命令> [参数…]", "", "命令:"]
|
|
38
|
+
for name, (_, desc) in _COMMANDS.items():
|
|
39
|
+
lines.append(f" {name:<16}{desc}")
|
|
40
|
+
for name in sorted(_TOOLKIT_PASSTHROUGH):
|
|
41
|
+
lines.append(f" {name:<16}排版工具链(toolkit)")
|
|
42
|
+
lines += [
|
|
43
|
+
" home 输出状态目录路径",
|
|
44
|
+
" migrate 迁移旧版仓库内状态到状态目录",
|
|
45
|
+
"",
|
|
46
|
+
"任意命令加 --help 看详细参数。",
|
|
47
|
+
]
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
argv = sys.argv[1:]
|
|
53
|
+
if not argv or argv[0] in {"-h", "--help"}:
|
|
54
|
+
print(_usage())
|
|
55
|
+
return
|
|
56
|
+
if argv[0] in {"-V", "--version"}:
|
|
57
|
+
print(__version__)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
cmd, rest = argv[0], argv[1:]
|
|
61
|
+
|
|
62
|
+
if cmd == "home":
|
|
63
|
+
print(home())
|
|
64
|
+
return
|
|
65
|
+
if cmd == "migrate":
|
|
66
|
+
from .migrate import main as migrate_main
|
|
67
|
+
migrate_main(rest)
|
|
68
|
+
return
|
|
69
|
+
if cmd in _TOOLKIT_PASSTHROUGH:
|
|
70
|
+
from .toolkit import cli as toolkit_cli
|
|
71
|
+
sys.argv = ["wewrite", cmd, *rest]
|
|
72
|
+
toolkit_cli.main()
|
|
73
|
+
return
|
|
74
|
+
if cmd in _COMMANDS:
|
|
75
|
+
module_name, _ = _COMMANDS[cmd]
|
|
76
|
+
module = importlib.import_module(module_name)
|
|
77
|
+
sys.argv = [f"wewrite {cmd}", *rest]
|
|
78
|
+
module.main()
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
print(f"未知命令: {cmd}\n\n{_usage()}", file=sys.stderr)
|
|
82
|
+
sys.exit(2)
|
|
File without changes
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Build a writing playbook from historical articles.
|
|
4
|
+
|
|
5
|
+
Reads all .md files in corpus/, analyzes writing patterns
|
|
6
|
+
in batches via LLM, and outputs a structured playbook.md.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
wewrite build-playbook
|
|
10
|
+
wewrite build-playbook --batch-size 10
|
|
11
|
+
|
|
12
|
+
Requires: ANTHROPIC_API_KEY or ARK API key in environment/config.
|
|
13
|
+
This script outputs analysis prompts to stdout for the Agent (LLM) to process.
|
|
14
|
+
The Agent reads the output and generates playbook.md.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..paths import corpus_dir as _corpus_dir
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_corpus() -> list[dict]:
|
|
26
|
+
"""Load all markdown files from corpus directory ($WEWRITE_HOME/corpus)."""
|
|
27
|
+
corpus_dir = _corpus_dir()
|
|
28
|
+
if not corpus_dir.exists():
|
|
29
|
+
print(f"Error: corpus directory not found: {corpus_dir}", file=sys.stderr)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
articles = []
|
|
33
|
+
for md_file in sorted(corpus_dir.glob("*.md")):
|
|
34
|
+
text = md_file.read_text(encoding="utf-8")
|
|
35
|
+
if not text.strip():
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# Extract title (first H1)
|
|
39
|
+
title = ""
|
|
40
|
+
for line in text.split("\n"):
|
|
41
|
+
if line.strip().startswith("# ") and not line.strip().startswith("## "):
|
|
42
|
+
title = line.strip()[2:].strip()
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
# Basic stats
|
|
46
|
+
lines = [l for l in text.split("\n") if l.strip()]
|
|
47
|
+
paragraphs = text.split("\n\n")
|
|
48
|
+
h2_count = sum(1 for l in text.split("\n") if l.strip().startswith("## "))
|
|
49
|
+
char_count = len(text.replace("\n", "").replace(" ", ""))
|
|
50
|
+
|
|
51
|
+
articles.append({
|
|
52
|
+
"filename": md_file.name,
|
|
53
|
+
"title": title,
|
|
54
|
+
"char_count": char_count,
|
|
55
|
+
"paragraph_count": len([p for p in paragraphs if p.strip()]),
|
|
56
|
+
"h2_count": h2_count,
|
|
57
|
+
"text": text,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return articles
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compute_corpus_stats(articles: list[dict]) -> dict:
|
|
64
|
+
"""Compute aggregate statistics from the corpus."""
|
|
65
|
+
if not articles:
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
titles = [a["title"] for a in articles if a["title"]]
|
|
69
|
+
title_lengths = [len(t) for t in titles]
|
|
70
|
+
char_counts = [a["char_count"] for a in articles]
|
|
71
|
+
para_counts = [a["paragraph_count"] for a in articles]
|
|
72
|
+
h2_counts = [a["h2_count"] for a in articles]
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"total_articles": len(articles),
|
|
76
|
+
"avg_char_count": round(sum(char_counts) / len(char_counts)),
|
|
77
|
+
"avg_title_length": round(sum(title_lengths) / len(title_lengths), 1) if title_lengths else 0,
|
|
78
|
+
"title_length_range": f"{min(title_lengths)}-{max(title_lengths)}" if title_lengths else "N/A",
|
|
79
|
+
"avg_paragraphs": round(sum(para_counts) / len(para_counts), 1),
|
|
80
|
+
"avg_h2_count": round(sum(h2_counts) / len(h2_counts), 1),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_analysis_batches(articles: list[dict], batch_size: int) -> list[list[dict]]:
|
|
85
|
+
"""Split articles into batches for LLM analysis."""
|
|
86
|
+
batches = []
|
|
87
|
+
for i in range(0, len(articles), batch_size):
|
|
88
|
+
batch = articles[i:i + batch_size]
|
|
89
|
+
batches.append(batch)
|
|
90
|
+
return batches
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def output_analysis_prompt(articles: list[dict], stats: dict, batch_idx: int, total_batches: int):
|
|
94
|
+
"""Output a structured analysis prompt for the Agent to process."""
|
|
95
|
+
print(f"\n{'='*60}")
|
|
96
|
+
print(f"BATCH {batch_idx + 1}/{total_batches} — {len(articles)} articles")
|
|
97
|
+
print(f"{'='*60}\n")
|
|
98
|
+
|
|
99
|
+
for i, article in enumerate(articles):
|
|
100
|
+
print(f"--- Article {i+1}: {article['title']} ({article['char_count']}字) ---")
|
|
101
|
+
# Truncate very long articles to first 2000 chars for analysis
|
|
102
|
+
text = article["text"]
|
|
103
|
+
if len(text) > 3000:
|
|
104
|
+
text = text[:3000] + "\n\n[...truncated...]"
|
|
105
|
+
print(text)
|
|
106
|
+
print()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
parser = argparse.ArgumentParser(description="Build writing playbook from corpus")
|
|
111
|
+
parser.add_argument("--batch-size", type=int, default=10, help="Articles per batch")
|
|
112
|
+
parser.add_argument("--stats-only", action="store_true", help="Only show corpus stats")
|
|
113
|
+
args = parser.parse_args()
|
|
114
|
+
|
|
115
|
+
# Load corpus
|
|
116
|
+
articles = load_corpus()
|
|
117
|
+
if not articles:
|
|
118
|
+
print("Error: no articles found in corpus/", file=sys.stderr)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
# Compute stats
|
|
122
|
+
stats = compute_corpus_stats(articles)
|
|
123
|
+
|
|
124
|
+
print("=" * 60)
|
|
125
|
+
print("CORPUS ANALYSIS")
|
|
126
|
+
print("=" * 60)
|
|
127
|
+
print(json.dumps(stats, ensure_ascii=False, indent=2))
|
|
128
|
+
|
|
129
|
+
if args.stats_only:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Build batches
|
|
133
|
+
batches = build_analysis_batches(articles, args.batch_size)
|
|
134
|
+
|
|
135
|
+
print(f"\nTotal: {stats['total_articles']} articles in {len(batches)} batch(es)")
|
|
136
|
+
print(f"Average: {stats['avg_char_count']} chars, {stats['avg_title_length']} char titles, {stats['avg_h2_count']} H2s")
|
|
137
|
+
|
|
138
|
+
# Output analysis instructions
|
|
139
|
+
print(f"""
|
|
140
|
+
{'='*60}
|
|
141
|
+
ANALYSIS INSTRUCTIONS FOR AGENT
|
|
142
|
+
{'='*60}
|
|
143
|
+
|
|
144
|
+
Read all articles below, then generate playbook.md with these sections:
|
|
145
|
+
|
|
146
|
+
## 标题模式
|
|
147
|
+
- 平均字数和范围
|
|
148
|
+
- 常用策略分布(数字/反直觉/痛点/疑问/陈述,给百分比)
|
|
149
|
+
- 标点习惯(逗号断句?问号?感叹号?)
|
|
150
|
+
- 示例:列出 3 个最典型的标题
|
|
151
|
+
|
|
152
|
+
## 开头模式
|
|
153
|
+
- 最常用的开头方式(场景/数据/反问/新闻引述/个人经历)
|
|
154
|
+
- 第一段平均长度
|
|
155
|
+
- 从不出现的开头方式
|
|
156
|
+
- 示例:列出 3 个典型开头的第一段
|
|
157
|
+
|
|
158
|
+
## 段落节奏
|
|
159
|
+
- 平均段落长度(字数)
|
|
160
|
+
- 短段(<30字)占比
|
|
161
|
+
- 最长段落上限
|
|
162
|
+
- 长短交替规律
|
|
163
|
+
|
|
164
|
+
## 用词指纹
|
|
165
|
+
- 高频标志词/口头禅(出现 3 次以上的特征性表达)
|
|
166
|
+
- 禁用词(从未使用的常见 AI 用语)
|
|
167
|
+
- 英文/专业术语使用习惯
|
|
168
|
+
- 语气词偏好
|
|
169
|
+
|
|
170
|
+
## H2 命名习惯
|
|
171
|
+
- 用问句?短语?数字编号?
|
|
172
|
+
- 平均长度
|
|
173
|
+
- 示例
|
|
174
|
+
|
|
175
|
+
## 结尾模式
|
|
176
|
+
- 收尾方式(个人观点/开放提问/行动建议/金句)
|
|
177
|
+
- CTA 风格
|
|
178
|
+
- 示例
|
|
179
|
+
|
|
180
|
+
## 情绪基调
|
|
181
|
+
- 理性 vs 感性的比例
|
|
182
|
+
- 幽默频率
|
|
183
|
+
- 批判性强度
|
|
184
|
+
|
|
185
|
+
## 配图风格(如果历史文章有配图描述)
|
|
186
|
+
- 色调偏好
|
|
187
|
+
- 风格关键词
|
|
188
|
+
|
|
189
|
+
请用量化数据(百分比、平均值、范围)支撑每个结论,不要只做定性描述。
|
|
190
|
+
""")
|
|
191
|
+
|
|
192
|
+
# Output article batches
|
|
193
|
+
for i, batch in enumerate(batches):
|
|
194
|
+
output_analysis_prompt(batch, stats, i, len(batches))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Diagnose which writing-quality (humanness) measures are active in this WeWrite installation.
|
|
4
|
+
|
|
5
|
+
Checks: Python deps, config.yaml, style.yaml, enhancement files, dimension variance.
|
|
6
|
+
Outputs a human-readable report or structured JSON.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
wewrite diagnose # text report
|
|
10
|
+
wewrite diagnose --json # JSON for agent consumption
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import importlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from .. import paths
|
|
23
|
+
|
|
24
|
+
# 内置写作人格预设(prompt 层数据,随 skill 仓库分发;CLI 只做名字校验)
|
|
25
|
+
BUILTIN_PERSONAS = {
|
|
26
|
+
"cold-analyst", "humor-storyteller", "industry-observer",
|
|
27
|
+
"midnight-friend", "sharp-journalist", "tech-coder", "warm-editor",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Modules to check (import_name, package_name_for_pip)
|
|
31
|
+
REQUIRED_MODULES = [
|
|
32
|
+
("markdown", "markdown"),
|
|
33
|
+
("bs4", "beautifulsoup4"),
|
|
34
|
+
("cssutils", "cssutils"),
|
|
35
|
+
("requests", "requests"),
|
|
36
|
+
("yaml", "pyyaml"),
|
|
37
|
+
("pygments", "Pygments"),
|
|
38
|
+
("PIL", "Pillow"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Humanness weight per check (0 = no humanness impact, higher = more important)
|
|
42
|
+
# JSON keys keep the legacy anti_ai_* names for compatibility.
|
|
43
|
+
WEIGHTS = {
|
|
44
|
+
"style_file": 3,
|
|
45
|
+
"writing_persona": 3,
|
|
46
|
+
"persona_file": 2,
|
|
47
|
+
"writing_config": 1,
|
|
48
|
+
"playbook": 2,
|
|
49
|
+
"history_articles": 1,
|
|
50
|
+
"dimension_variance": 1,
|
|
51
|
+
# These have 0 weight (no humanness impact)
|
|
52
|
+
"python_packages": 0,
|
|
53
|
+
"config_file": 0,
|
|
54
|
+
"wechat_credentials": 0,
|
|
55
|
+
"image_api_key": 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
MAX_ANTI_AI_SCORE = sum(v for v in WEIGHTS.values() if v > 0) # 13
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_check(group, name, status, detail=None, impact=None):
|
|
62
|
+
"""Create a check result dict."""
|
|
63
|
+
c = {"group": group, "name": name, "status": status}
|
|
64
|
+
if detail is not None:
|
|
65
|
+
c["detail"] = detail
|
|
66
|
+
if impact is not None:
|
|
67
|
+
c["impact"] = impact
|
|
68
|
+
return c
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_dependencies():
|
|
72
|
+
"""Group 1: Check Python package imports."""
|
|
73
|
+
missing = []
|
|
74
|
+
for mod_name, pip_name in REQUIRED_MODULES:
|
|
75
|
+
try:
|
|
76
|
+
importlib.import_module(mod_name)
|
|
77
|
+
except ImportError:
|
|
78
|
+
missing.append(pip_name)
|
|
79
|
+
|
|
80
|
+
if not missing:
|
|
81
|
+
return [make_check("dependencies", "python_packages", "pass", "all installed")]
|
|
82
|
+
return [make_check(
|
|
83
|
+
"dependencies", "python_packages", "fail",
|
|
84
|
+
f"missing: {', '.join(missing)}. Reinstall CLI (uv tool install --force wewrite) "
|
|
85
|
+
f"or: pip install {' '.join(missing)}",
|
|
86
|
+
)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _env_set(*names):
|
|
90
|
+
"""True if all named env vars are non-empty (container injects secrets via env)."""
|
|
91
|
+
return all(os.environ.get(n, "").strip() for n in names)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_config():
|
|
95
|
+
"""Group 2: Check config.yaml + env-injected secrets.
|
|
96
|
+
|
|
97
|
+
线上容器把密钥经环境变量注入(WECHAT_*/WEWRITE_IMAGE_*/WEWRITE_WRITER_*),
|
|
98
|
+
config.yaml 往往只有非敏感默认值。所以这里 config 与 env **任一**满足即算配置好,
|
|
99
|
+
避免把已注入的密钥误判成缺失而错误置 skip_*。
|
|
100
|
+
"""
|
|
101
|
+
checks = []
|
|
102
|
+
config_path = paths.config_path()
|
|
103
|
+
cfg = {}
|
|
104
|
+
if config_path.exists():
|
|
105
|
+
checks.append(make_check("config", "config_file", "pass", "found"))
|
|
106
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
107
|
+
cfg = yaml.safe_load(f) or {}
|
|
108
|
+
else:
|
|
109
|
+
checks.append(make_check("config", "config_file", "warn",
|
|
110
|
+
"no config.yaml → 仅按环境变量判断"))
|
|
111
|
+
|
|
112
|
+
# WeChat credentials: config.yaml 或 env 任一齐全即可
|
|
113
|
+
wechat = cfg.get("wechat", {}) or {}
|
|
114
|
+
if (wechat.get("appid") and wechat.get("secret")) or _env_set("WECHAT_APPID", "WECHAT_SECRET"):
|
|
115
|
+
checks.append(make_check("config", "wechat_credentials", "pass", "configured"))
|
|
116
|
+
else:
|
|
117
|
+
checks.append(make_check("config", "wechat_credentials", "warn",
|
|
118
|
+
"missing appid/secret", impact="skip_publish"))
|
|
119
|
+
|
|
120
|
+
# Image: config.image.api_key 或 env WEWRITE_IMAGE_API_KEY 任一即可
|
|
121
|
+
image = cfg.get("image", {}) or {}
|
|
122
|
+
if image.get("api_key") or _env_set("WEWRITE_IMAGE_API_KEY"):
|
|
123
|
+
checks.append(make_check("config", "image_api_key", "pass", "configured"))
|
|
124
|
+
else:
|
|
125
|
+
checks.append(make_check("config", "image_api_key", "warn",
|
|
126
|
+
"missing → image generation will be skipped", impact="skip_image_gen"))
|
|
127
|
+
|
|
128
|
+
return checks
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def runtime_flags(checks):
|
|
132
|
+
"""管道运行标记,供 SKILL.md Step 1 一次性读取(env 优先,与 check_config 同源)。"""
|
|
133
|
+
def warned(name):
|
|
134
|
+
return any(c["name"] == name and c["status"] != "pass" for c in checks)
|
|
135
|
+
return {
|
|
136
|
+
# 没有微信凭证 → 跳过发布;没有图片 key → 跳过生图
|
|
137
|
+
"skip_publish": warned("wechat_credentials"),
|
|
138
|
+
"skip_image_gen": warned("image_api_key"),
|
|
139
|
+
# 配了写作模型(混合路由)→ Step 4 走 llm_write.py;否则编排器自写
|
|
140
|
+
"use_writer_model": _env_set("WEWRITE_WRITER_API_KEY"),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_style():
|
|
145
|
+
"""Group 3: Check style.yaml and persona configuration."""
|
|
146
|
+
checks = []
|
|
147
|
+
style_path = paths.style_path()
|
|
148
|
+
|
|
149
|
+
if not style_path.exists():
|
|
150
|
+
checks.append(make_check("style", "style_file", "fail", "not found → run onboard first"))
|
|
151
|
+
return checks
|
|
152
|
+
|
|
153
|
+
checks.append(make_check("style", "style_file", "pass", "found"))
|
|
154
|
+
|
|
155
|
+
with open(style_path, "r", encoding="utf-8") as f:
|
|
156
|
+
style = yaml.safe_load(f) or {}
|
|
157
|
+
|
|
158
|
+
# writing_persona field
|
|
159
|
+
persona_name = style.get("writing_persona")
|
|
160
|
+
if persona_name:
|
|
161
|
+
checks.append(make_check("style", "writing_persona", "pass", persona_name))
|
|
162
|
+
else:
|
|
163
|
+
persona_name = "midnight-friend"
|
|
164
|
+
checks.append(make_check("style", "writing_persona", "warn", "not set → defaults to midnight-friend"))
|
|
165
|
+
|
|
166
|
+
# Persona 存在性:内置预设名单,或 $WEWRITE_HOME/personas/ 下的自定义人格
|
|
167
|
+
custom = paths.home() / "personas" / f"{persona_name}.yaml"
|
|
168
|
+
if persona_name in BUILTIN_PERSONAS:
|
|
169
|
+
checks.append(make_check("style", "persona_file", "pass", f"{persona_name}(内置预设)"))
|
|
170
|
+
elif custom.exists():
|
|
171
|
+
checks.append(make_check("style", "persona_file", "pass", f"{custom}(自定义)"))
|
|
172
|
+
else:
|
|
173
|
+
checks.append(make_check("style", "persona_file", "fail",
|
|
174
|
+
f"{persona_name} 不是内置人格,且 {custom} 不存在"))
|
|
175
|
+
|
|
176
|
+
return checks
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def check_enhancements():
|
|
180
|
+
"""Group 4: Check writing-config, playbook, history."""
|
|
181
|
+
checks = []
|
|
182
|
+
|
|
183
|
+
# writing-config.yaml
|
|
184
|
+
if paths.writing_config_path().exists():
|
|
185
|
+
checks.append(make_check("enhancement", "writing_config", "pass", "found"))
|
|
186
|
+
else:
|
|
187
|
+
checks.append(make_check(
|
|
188
|
+
"enhancement", "writing_config", "warn",
|
|
189
|
+
"not found → using defaults (say '优化参数' to tune)",
|
|
190
|
+
))
|
|
191
|
+
|
|
192
|
+
# playbook.md
|
|
193
|
+
if paths.playbook_path().exists():
|
|
194
|
+
checks.append(make_check("enhancement", "playbook", "pass", "found"))
|
|
195
|
+
else:
|
|
196
|
+
checks.append(make_check(
|
|
197
|
+
"enhancement", "playbook", "warn",
|
|
198
|
+
'not found → no learned style (say "学习我的修改" after editing)',
|
|
199
|
+
))
|
|
200
|
+
|
|
201
|
+
# history.yaml
|
|
202
|
+
history_path = paths.history_path()
|
|
203
|
+
if history_path.exists():
|
|
204
|
+
with open(history_path, "r", encoding="utf-8") as f:
|
|
205
|
+
data = yaml.safe_load(f)
|
|
206
|
+
articles = data if isinstance(data, list) else (data or {}).get("articles", [])
|
|
207
|
+
if articles:
|
|
208
|
+
checks.append(make_check("enhancement", "history_articles", "pass", f"{len(articles)} articles"))
|
|
209
|
+
else:
|
|
210
|
+
checks.append(make_check("enhancement", "history_articles", "warn", "file exists but empty"))
|
|
211
|
+
else:
|
|
212
|
+
checks.append(make_check("enhancement", "history_articles", "warn", "not found → no dedup, no dimension tracking"))
|
|
213
|
+
|
|
214
|
+
return checks
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_dimensions():
|
|
218
|
+
"""Group 5: Check dimension diversity across recent articles."""
|
|
219
|
+
history_path = paths.history_path()
|
|
220
|
+
if not history_path.exists():
|
|
221
|
+
return [make_check("dimensions", "dimension_variance", "skip", "no history.yaml")]
|
|
222
|
+
|
|
223
|
+
with open(history_path, "r", encoding="utf-8") as f:
|
|
224
|
+
data = yaml.safe_load(f)
|
|
225
|
+
|
|
226
|
+
articles = data if isinstance(data, list) else (data or {}).get("articles", [])
|
|
227
|
+
# Get last 3 articles that have dimensions
|
|
228
|
+
recent = [a for a in articles if a.get("dimensions")][-3:]
|
|
229
|
+
|
|
230
|
+
if len(recent) < 3:
|
|
231
|
+
return [make_check("dimensions", "dimension_variance", "skip", f"only {len(recent)} articles with dimensions (need 3)")]
|
|
232
|
+
|
|
233
|
+
# Compare dimension sets — stringify and check uniqueness
|
|
234
|
+
dim_sets = [tuple(sorted(a["dimensions"])) for a in recent]
|
|
235
|
+
if len(set(dim_sets)) == len(dim_sets):
|
|
236
|
+
return [make_check("dimensions", "dimension_variance", "pass", "last 3 articles have distinct dimensions")]
|
|
237
|
+
|
|
238
|
+
return [make_check("dimensions", "dimension_variance", "warn", "dimension overlap in recent articles → cross-article fingerprint risk")]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def compute_summary(checks):
|
|
242
|
+
"""Compute pass/warn/fail counts, humanness score, and recommendations."""
|
|
243
|
+
passed = sum(1 for c in checks if c["status"] == "pass")
|
|
244
|
+
warnings = sum(1 for c in checks if c["status"] == "warn")
|
|
245
|
+
failures = sum(1 for c in checks if c["status"] == "fail")
|
|
246
|
+
skipped = sum(1 for c in checks if c["status"] == "skip")
|
|
247
|
+
|
|
248
|
+
score = sum(WEIGHTS.get(c["name"], 0) for c in checks if c["status"] == "pass")
|
|
249
|
+
pct = score / MAX_ANTI_AI_SCORE if MAX_ANTI_AI_SCORE else 0
|
|
250
|
+
if pct >= 0.76:
|
|
251
|
+
level = "HIGH"
|
|
252
|
+
elif pct >= 0.41:
|
|
253
|
+
level = "MODERATE"
|
|
254
|
+
else:
|
|
255
|
+
level = "LOW"
|
|
256
|
+
|
|
257
|
+
# Build recommendations ordered by weight (highest first)
|
|
258
|
+
recs = []
|
|
259
|
+
non_pass = [c for c in checks if c["status"] in ("warn", "fail") and WEIGHTS.get(c["name"], 0) > 0]
|
|
260
|
+
non_pass.sort(key=lambda c: WEIGHTS.get(c["name"], 0), reverse=True)
|
|
261
|
+
for c in non_pass:
|
|
262
|
+
name = c["name"]
|
|
263
|
+
if name == "style_file":
|
|
264
|
+
recs.append('Run the skill once to trigger onboard(重新设置风格)')
|
|
265
|
+
elif name == "writing_persona":
|
|
266
|
+
recs.append('Add writing_persona: "midnight-friend" to style.yaml (best humanness rate)')
|
|
267
|
+
elif name == "persona_file":
|
|
268
|
+
recs.append(f'Persona file missing — check personas/ directory')
|
|
269
|
+
elif name == "playbook":
|
|
270
|
+
recs.append('Edit a generated article, then say "学习我的修改" to build playbook.md')
|
|
271
|
+
elif name == "writing_config":
|
|
272
|
+
recs.append('Say "优化参数" to run the optimization loop')
|
|
273
|
+
elif name == "history_articles":
|
|
274
|
+
recs.append("Generate your first article to start building history")
|
|
275
|
+
elif name == "dimension_variance":
|
|
276
|
+
recs.append("Recent articles reuse same dimensions — the pipeline will auto-fix on next run")
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
"passed": passed,
|
|
280
|
+
"warnings": warnings,
|
|
281
|
+
"failures": failures,
|
|
282
|
+
"skipped": skipped,
|
|
283
|
+
"anti_ai_score": score,
|
|
284
|
+
"anti_ai_max": MAX_ANTI_AI_SCORE,
|
|
285
|
+
"anti_ai_level": level,
|
|
286
|
+
}, recs
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def file_status_map(checks):
|
|
290
|
+
"""Build a quick file-existence map for agent use."""
|
|
291
|
+
# Extract persona name from checks instead of re-reading style.yaml
|
|
292
|
+
persona_name = "midnight-friend"
|
|
293
|
+
for c in checks:
|
|
294
|
+
if c["name"] == "writing_persona" and c["status"] == "pass" and c.get("detail"):
|
|
295
|
+
persona_name = c["detail"]
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
exemplars = sorted(paths.exemplars_dir().glob("*.md")) if paths.exemplars_dir().is_dir() else []
|
|
299
|
+
return {
|
|
300
|
+
"home": str(paths.home()),
|
|
301
|
+
"config_yaml": paths.config_path().exists(),
|
|
302
|
+
"style_yaml": paths.style_path().exists(),
|
|
303
|
+
"writing_config_yaml": paths.writing_config_path().exists(),
|
|
304
|
+
"playbook_md": paths.playbook_path().exists(),
|
|
305
|
+
"history_yaml": paths.history_path().exists(),
|
|
306
|
+
"persona": persona_name,
|
|
307
|
+
"exemplars": [f.name for f in exemplars],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def format_text(checks, summary, recs):
|
|
312
|
+
"""Format human-readable text report."""
|
|
313
|
+
lines = ["WeWrite Writing-Quality Diagnostic", "=" * 33, ""]
|
|
314
|
+
|
|
315
|
+
current_group = None
|
|
316
|
+
group_labels = {
|
|
317
|
+
"dependencies": "Dependencies",
|
|
318
|
+
"config": "Config",
|
|
319
|
+
"style": "Style",
|
|
320
|
+
"enhancement": "Enhancement",
|
|
321
|
+
"dimensions": "Dimension Variance",
|
|
322
|
+
}
|
|
323
|
+
for c in checks:
|
|
324
|
+
if c["group"] != current_group:
|
|
325
|
+
if current_group is not None:
|
|
326
|
+
lines.append("")
|
|
327
|
+
current_group = c["group"]
|
|
328
|
+
lines.append(group_labels.get(current_group, current_group))
|
|
329
|
+
tag = c["status"].upper()
|
|
330
|
+
label = c["name"].replace("_", " ").title()
|
|
331
|
+
detail = f": {c['detail']}" if c.get("detail") else ""
|
|
332
|
+
lines.append(f" [{tag:4s}] {label}{detail}")
|
|
333
|
+
lines.append("")
|
|
334
|
+
|
|
335
|
+
p, w, f_ = summary["passed"], summary["warnings"], summary["failures"]
|
|
336
|
+
sk = summary.get("skipped", 0)
|
|
337
|
+
skipped_part = f", {sk} skipped" if sk > 0 else ""
|
|
338
|
+
lines.append(f"Summary: {p} passed, {w} warnings, {f_} failures{skipped_part}")
|
|
339
|
+
|
|
340
|
+
score = summary["anti_ai_score"]
|
|
341
|
+
mx = summary["anti_ai_max"]
|
|
342
|
+
filled = round(score / mx * 12) if mx else 0
|
|
343
|
+
bar = "\u2588" * filled + "\u2591" * (12 - filled)
|
|
344
|
+
lines.append(f"Humanness level: {bar} {summary['anti_ai_level']} ({score}/{mx})")
|
|
345
|
+
|
|
346
|
+
if recs:
|
|
347
|
+
lines.append("")
|
|
348
|
+
lines.append("Top recommendations:")
|
|
349
|
+
for i, r in enumerate(recs, 1):
|
|
350
|
+
lines.append(f" {i}. {r}")
|
|
351
|
+
|
|
352
|
+
return "\n".join(lines)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def format_json(checks, summary, recs):
|
|
356
|
+
"""Format JSON output."""
|
|
357
|
+
return json.dumps({
|
|
358
|
+
"checks": checks,
|
|
359
|
+
"summary": summary,
|
|
360
|
+
"recommendations": recs,
|
|
361
|
+
"files": file_status_map(checks),
|
|
362
|
+
"flags": runtime_flags(checks),
|
|
363
|
+
}, ensure_ascii=False, indent=2)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def run_all_checks():
|
|
367
|
+
"""Run all check groups and return combined list."""
|
|
368
|
+
checks = []
|
|
369
|
+
checks.extend(check_dependencies())
|
|
370
|
+
checks.extend(check_config())
|
|
371
|
+
checks.extend(check_style())
|
|
372
|
+
checks.extend(check_enhancements())
|
|
373
|
+
checks.extend(check_dimensions())
|
|
374
|
+
return checks
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def main():
|
|
378
|
+
parser = argparse.ArgumentParser(
|
|
379
|
+
description="Diagnose which writing-quality (humanness) measures are active in this WeWrite installation.",
|
|
380
|
+
)
|
|
381
|
+
parser.add_argument("--json", action="store_true", help="Output structured JSON")
|
|
382
|
+
args = parser.parse_args()
|
|
383
|
+
|
|
384
|
+
paths.ensure_home() # 首个管道命令,顺手把状态目录立起来
|
|
385
|
+
checks = run_all_checks()
|
|
386
|
+
summary, recs = compute_summary(checks)
|
|
387
|
+
|
|
388
|
+
if args.json:
|
|
389
|
+
print(format_json(checks, summary, recs))
|
|
390
|
+
else:
|
|
391
|
+
print(format_text(checks, summary, recs))
|
|
392
|
+
|
|
393
|
+
# Exit code: 1 if any failures, 0 otherwise
|
|
394
|
+
sys.exit(1 if summary["failures"] > 0 else 0)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|