writer-offline-perspective

目录
- 项目特性
- 技术栈
- 项目结构
- 快速开始
- 核心功能
- 踩坑记录
- 扩展方向
- 部署
项目特性
- 双 profile 结构:把"思维立场"(认知框架/价值排序/逻辑结构/情感基调/边界意识)和"表达风格"(句长/词频/四字格/标点分布)拆成两个文件分别管理,避免混在一起难维护
- 关键词聚类(不用 TF-IDF/KMeans):用可解释、可调的关键词规则把范文按主题域分组,每个主题域自动选 top-5 代表作生成 best-practices.md
- 起草强制三版本:每次起草输出 A 稳健 / B 进取 / C 平衡 三版,用户挑选后再走反馈闭环
- --materials 必填:起草命令必须传素材文件,防止"凭空起草"产生幻觉数据
- 大纲预演:起草前先输出推荐大纲让用户确认,骨架错了就别浪费时间写正文
- 多作者隔离:每位作者一个独立 profile 目录,互不影响
技术栈
| 技术 | 用途 |
|---|---|
| Python 3.8+ | 主语言 |
| python-docx | 读写 docx 文件 |
| jieba | 中文分词 + 关键词提取 |
| pyyaml | YAML frontmatter 解析 |
| argparse | CLI 子命令 |
项目结构
writer-offline-perspective/
├── writer.py # CLI 入口(learn / list / draft / draft-package)
├── requirements.txt
├── SKILL.md # 工作流 skill(供 Claude 调用)
├── CLAUDE.md # 项目规则
├── core/ # 核心模块
│ ├── article_splitter.py # docx → 单篇 .md
│ ├── structure_extractor.py # 提取每篇标题层级
│ ├── expression_profiler.py # 句长/词频/四字格统计
│ ├── topic_cluster.py # 关键词聚类
│ ├── profile_manager.py # profile 目录 CRUD + _index.json
│ ├── draft_preparer.py # 起草上下文打包
│ └── draft_packager.py # 3 版本 → docx + comparison
├── templates/ # 文稿模板(speech/report/summary)
├── docs/superpowers/specs/ # 设计文档
└── .claude/ # 项目自进化结构(规则/记忆/skill)
运行时产生:
profiles/<author>/
├── style-profile.md # 数据驱动生成
├── stance-profile.md # 5 段式思维立场
├── samples/original/ # N 篇 .md 范文
├── topics/<主题域>/ # 主题聚类 + best-practices.md
└── _index.json
drafts/<author>/draft_<ts>_<topic>/
├── context.md # 起草上下文(给 Claude)
├── materials.md # 素材原文
├── draft_A/B/C.md # 三版本起草
├── draft_A/B/C.docx # 打包后 docx
└── comparison.md # 三版本对比摘要
模块数据流(学习路径)
#mermaid-svg-VkX6sScYK5S5Gl0v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VkX6sScYK5S5Gl0v .error-icon{fill:#552222;}#mermaid-svg-VkX6sScYK5S5Gl0v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VkX6sScYK5S5Gl0v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VkX6sScYK5S5Gl0v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VkX6sScYK5S5Gl0v .marker.cross{stroke:#333333;}#mermaid-svg-VkX6sScYK5S5Gl0v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VkX6sScYK5S5Gl0v p{margin:0;}#mermaid-svg-VkX6sScYK5S5Gl0v .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v .cluster-label text{fill:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v .cluster-label span{color:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v .cluster-label span p{background-color:transparent;}#mermaid-svg-VkX6sScYK5S5Gl0v .label text,#mermaid-svg-VkX6sScYK5S5Gl0v span{fill:#333;color:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v .node rect,#mermaid-svg-VkX6sScYK5S5Gl0v .node circle,#mermaid-svg-VkX6sScYK5S5Gl0v .node ellipse,#mermaid-svg-VkX6sScYK5S5Gl0v .node polygon,#mermaid-svg-VkX6sScYK5S5Gl0v .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VkX6sScYK5S5Gl0v .rough-node .label text,#mermaid-svg-VkX6sScYK5S5Gl0v .node .label text,#mermaid-svg-VkX6sScYK5S5Gl0v .image-shape .label,#mermaid-svg-VkX6sScYK5S5Gl0v .icon-shape .label{text-anchor:middle;}#mermaid-svg-VkX6sScYK5S5Gl0v .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VkX6sScYK5S5Gl0v .rough-node .label,#mermaid-svg-VkX6sScYK5S5Gl0v .node .label,#mermaid-svg-VkX6sScYK5S5Gl0v .image-shape .label,#mermaid-svg-VkX6sScYK5S5Gl0v .icon-shape .label{text-align:center;}#mermaid-svg-VkX6sScYK5S5Gl0v .node.clickable{cursor:pointer;}#mermaid-svg-VkX6sScYK5S5Gl0v .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VkX6sScYK5S5Gl0v .arrowheadPath{fill:#333333;}#mermaid-svg-VkX6sScYK5S5Gl0v .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VkX6sScYK5S5Gl0v .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VkX6sScYK5S5Gl0v .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VkX6sScYK5S5Gl0v .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VkX6sScYK5S5Gl0v .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VkX6sScYK5S5Gl0v .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VkX6sScYK5S5Gl0v .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VkX6sScYK5S5Gl0v .cluster text{fill:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v .cluster span{color:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VkX6sScYK5S5Gl0v .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VkX6sScYK5S5Gl0v rect.text{fill:none;stroke-width:0;}#mermaid-svg-VkX6sScYK5S5Gl0v .icon-shape,#mermaid-svg-VkX6sScYK5S5Gl0v .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VkX6sScYK5S5Gl0v .icon-shape p,#mermaid-svg-VkX6sScYK5S5Gl0v .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VkX6sScYK5S5Gl0v .icon-shape .label rect,#mermaid-svg-VkX6sScYK5S5Gl0v .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VkX6sScYK5S5Gl0v .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VkX6sScYK5S5Gl0v .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VkX6sScYK5S5Gl0v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 作者汇编 docx
ArticleSplitter
居中标题 + 元信息过滤
N 篇 .md
含 frontmatter
StructureExtractor
标题层级
ExpressionProfiler
句长/词频/四字格
TopicCluster
关键词聚类
structure-summary.md
style-profile.md
expression-stats.json
topics/<主题域>/
best-practices.md
profiles/<author>/
stance-profile.md
由 Claude 用 SKILL 补全
起草流程(用户视角)
┌─────────┐ ┌──────────────────┐
│ 用户 │ │ writer.py │
└────┬────┘ └────────┬─────────┘
│ │
│ --topic + --materials │
├──────────────────────────────►
│ │
│ prepare 阶段 │
│ ┌───────────────────────────┤
│ │ • 读 profile (style+stance)│
│ │ • 读 materials │
│ │ • 匹配最相关主题域 top-2 │
│ │ • 写 context.md │
│ └───────────────────────────►
│ │
│ Claude 读 context.md │
│ ┌────────────────────────────┤
│ │ 起草 draft_A.md (稳健) │
│ │ 起草 draft_B.md (进取) │
│ │ 起草 draft_C.md (平衡) │
│ └────────────────────────────►
│ │
│ package 阶段 │
│ ┌────────────────────────────┤
│ │ • 统计每版本指标 │
│ │ • 生成 3 个 docx │
│ │ • 写 comparison.md │
│ └────────────────────────────►
│ │
│ 挑选最优版本 │
◄──────────────────────────────┤
│ │
▼ ▼
快速开始
bash
# 安装依赖
pip install -r requirements.txt
# 1. 学习一位作者(从作者汇编 docx 提取风格)
python writer.py learn 李世泽 --source /path/to/李世泽文章汇编.docx
# 2. 列出已学习的作者
python writer.py list
# 3. 起草新文稿(必须传素材)
python writer.py draft 李世泽 \
--topic "做大做强平陆运河文旅产业 打造广西向海经济新名片" \
--materials /path/to/materials.md
# 输出:drafts/李世泽/draft_<ts>_<topic>/context.md
# 然后 Claude 按 context.md 写 draft_A/B/C.md 到该目录
# 4. 打包成 docx + 对比摘要
python writer.py draft-package drafts/李世泽/draft_<ts>_<topic>
list 子命令查看已学到的作者:

draft-package 打包后输出三版本对比:

核心功能
1. ArticleSplitter(docx 拆分)
从作者汇编 docx 按"居中标题 + 日期行 + 正文段"的模式拆分单篇 .md。关键是头部元信息("XX文章汇编"、"(共95篇...)"、"汇编生成: ...")也是居中的,必须用 META_PATTERNS 内容匹配过滤:
python
META_PATTERNS = [
re.compile(r'^.{2,8}文章汇编$'),
re.compile(r'^(共\d+篇.*)$'),
re.compile(r'^汇编生成[::]'),
re.compile(r'^目\s*录$'),
]
2. TopicCluster(关键词聚类)
按"先命中者优先"的顺序把范文归入主题域。每个主题的关键词表设计原则:
- 避免宽泛词("广西/发展/开放"),用专属词("一带一路/RCEP/平陆运河")
- 新增细分主题域放 topic_order 末位作为兜底,不会抢错已正确归类的篇目
- 匹配范围 = 标题 + 首段前 200 字,避免正文里引述其他主题时误命中
3. ExpressionProfiler(表达统计)
用 jieba 分词,统计:
- 句长分布(短 < 15 / 中 15-30 / 长 30-50 / 超长 50+)
- 段长分布(同上四档)
- Top 30 高频词(去停用词、单字)
- Top 20 四字格 + 密度
- 引号/问号/感叹号/逗号/分号密度
输出双文件:expression-stats.json(原始数据)+ style-profile.md(人类可读表格)。
4. DraftPreparer(起草上下文打包)
接收 --topic 和 --materials,输出 context.md 给 Claude 看,含:
- 风格 DNA 摘要(平均句长目标、四字格密度目标、Top 词、Top 四字格)
- stance-profile 摘要(5 段思维立场约束)
- 主题最相关的 best-practices 摘录(按关键词匹配 top 2 主题域)
- 三版本起草指引(A 稳健 / B 进取 / C 平衡)
- 素材原文(强制约束:"所有事实/数据/引文必须出自此处")
5. DraftPackager(三版本打包)
读 Claude 写好的 draft_A/B/C.md,对每版本:
- 用 ExpressionProfiler.analyze_text 计算句长/四字格/引文密度/估算阅读时长
- 生成对应 .docx(# 标题 → Heading,其它 → 普通段落)
- 输出一页
comparison.md(指标对比 + 挑选建议)
踩坑记录
1. docx 头部元信息恰好是居中段,过滤算法没拦截
现象:拆分李世泽 95 篇文章时多出 3 篇假文章------"李世泽文章汇编"、"(共95篇 | 当代广西网·当代论坛)"、"汇编生成: 2026年06月08日"。
原因 :原始 _segment 函数只用"非居中"判定跳过元信息,但这 3 段恰好是居中的、字数也在标题范围内("李世泽文章汇编" 6 个字),完全混进去。
解决 :加 META_PATTERNS 内容匹配,在"居中段 + 长度合规"基础上叠加正则模式检查。
2. _extract_meta 收到 strip 后的正文,title 永远是空
现象 :聚类时打印调试发现 title: 全是空字符串,导致关键词匹配范围只剩首段,36 篇误归"其他"。
原因 :_extract_meta(md_file, text) 的契约是"从 frontmatter 提取 title/date",但调用方传的是 _strip_frontmatter(text) 后的正文,里面已经没有 --- 了,所有判断都失效,return 空。
解决 :让 _extract_meta 自包含------自己 split frontmatter 不依赖外部,把"已经处理过的状态"这种隐式契约去掉:
python
def _extract_meta(self, md_file, text_with_frontmatter):
"""text_with_frontmatter 必须是原始全文。函数内自己 split。"""
if text_with_frontmatter.startswith("---"):
parts = text_with_frontmatter.split("---", 2)
fm, body = parts[1], parts[2]
...
3. 关键词表只为一个作者优化,跨作者迁移就崩
现象:李世泽跑出 "其他" 0% 很漂亮,换林柏成 26 篇就变成 27%。
原因 :初版 DEFAULT_KEYWORDS 是看着李世泽数据反推的关键词组合("一带一路"/"RCEP"/"平陆运河"),林柏成主题域是"家风家教/文化自信/全过程人民民主",完全不命中。
解决 :短期接受作者特征差异;长期 TopicCluster 需要支持 per-author keywords overlay 而非全局单表。
4. 派了 8 个 agent 并行填 best-practices.md,6 个清掉标题里的占位 2 个没清
现象 :让 8 个 general-purpose agent 各填一个主题的 best-practices.md,最后 grep 发现还有 2 个文件残留 ## 主题特征(待 Claude 补全) 标题。
原因:prompt 没明确"H2 标题里的'(待 Claude 补全)'后缀也要清掉",6 个 agent 自己理解到了,2 个没有。多 agent 输出有细微差异是常态。
解决:派 N 个 agent 后必须主线程 grep 验证完成状态,不能假设 agent 都做了同样的事。
5. nuwa-offline-perspective 包名跟自己的 core 冲突
现象 :想复用兄弟项目 nuwa 的 outline_extractor.py,常规 from nuwa.core import outline_extractor 走不通------nuwa 不是 Python 包(没有 __init__.py),且本项目自己有 core 包名,sys.path 加进去会冲突。
解决 :用 importlib.util.spec_from_file_location 直接按文件路径加载,绕开包名机制:
python
spec = importlib.util.spec_from_file_location(
"nuwa_outline_extractor",
"/path/to/nuwa-offline-perspective/core/outline_extractor.py",
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
6. learned-rules.md 跟 core-invariants.md 重复了 6 条规则,几个月没人发现
现象 :/evolve 时清点学到的规则,发现前 6 条(list[-1]、nohup 重定向、service ready 验证、WPS TOC、LLM both constraints、hybrid mode)跟 ~/.claude/rules/core-invariants.md 完全重复。
原因:当时把规则加进 learned-rules.md 时没检查 core-invariants 是否已有;每次 /evolve 也没做重复检查。两套规则平行存在,浪费 30+ 行不说,还有"一份更新另一份没更新"的腐烂风险。
解决 :给 /evolve 命令加 "Step 0: Duplicate Check" 强制重复检查,并在 Current State 节自动报告 learned-rules.md 行数 + rules/ 目录现状。
扩展方向
- Phase 5 反馈闭环 :
writer.py feedback <draft_dir> --selected A --polished polished.md计算 polished vs draft 的段落+句子双粒度 diff,写入iterations/v<n>-feedback.md,反向更新 style/stance - 8 维度主观打分(来自 SKILL.md Phase 5.3):观点鲜明度/结构清晰度/语言精准度/情感共鸣/逻辑严密/广西特色/创新性/可读性,每维 1-10 分 + 客观指标对比
- per-author keywords overlay :让
TopicCluster支持每位作者自定义关键词表叠加在 DEFAULT_KEYWORDS 之上,解决跨作者"其他"占比上升问题 - draft 风格违规扫描:在 draft-package 阶段加"风格违规检查"------如果起草版本含感叹号/语气助词且 stance-profile 明确禁止,输出警告(不阻塞)
- 跨作者风格借鉴:从作者 A 学到的"开篇模板"应用到作者 B 的起草,做风格 A/B 测试
部署
无服务端、纯 CLI,无需部署。本地有 Python 3.8+ 即可:
bash
git clone <repo>
cd writer-offline-perspective
pip install -r requirements.txt
python writer.py --help
如果要发布给团队用,可以把核心模块打包成 PyPI wheel:
bash
# 加 setup.py / pyproject.toml 后
python -m build
twine upload dist/*