commit 4f33f6842694f1d52b5aa2433be8283704193c3f Author: titanwings Date: Mon Mar 30 13:24:09 2026 +0800 Initial commit: colleague-skill 同事.skill 创建器 功能: - 通过飞书/钉钉自动采集同事的消息、文档、多维表格 - 支持 PDF、邮件、截图等手动上传 - 分析生成 Work Skill(工作能力)和 Persona(人物性格)两部分 - 支持对话纠正和版本管理 - 兼容 OpenClaw 和 Claude Code 双平台 Co-Authored-By: Claude Sonnet 4.6 diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..5bfc92c --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,127 @@ +# 同事.skill 安装说明 + +--- + +## 选择你的平台 + +### A. Claude Code(推荐) + +把 `colleague-creator` 目录复制到你的项目或全局 Claude 目录: + +```bash +# 方式 1:放到当前项目的 .claude/skills/ 目录下 +mkdir -p .claude/skills +cp -r colleague-creator .claude/skills/ + +# 方式 2:放到全局目录,所有项目都能用 +mkdir -p ~/.claude/skills +cp -r colleague-creator ~/.claude/skills/ +``` + +然后在 Claude Code 中说 `/create-colleague` 即可启动。 + +生成的同事 Skill 默认写入 `./colleagues/` 目录。 + +--- + +### B. OpenClaw + +```bash +# 复制到 OpenClaw 的 skills 目录 +cp -r colleague-creator ~/.openclaw/workspace/skills/ +cp -r colleagues ~/.openclaw/workspace/skills/ +``` + +重启 OpenClaw session,说 `/create-colleague` 启动。 + +--- + +## 依赖安装 + +```bash +# 基础(Python 3.9+) +pip3 install pypinyin # 中文姓名转拼音 slug(可选但推荐) + +# 飞书浏览器方案(内部文档/需要登录权限的文档) +pip3 install playwright +playwright install chromium # 仅需安装 chromium,不需要完整 Chrome + +# 飞书 MCP 方案(公司授权文档,通过 App Token 读取) +npm install -g feishu-mcp # 需要 Node.js 16+ + +# 其他格式支持(可选) +pip3 install python-docx # Word .docx 转文本 +pip3 install openpyxl # Excel .xlsx 转 CSV +``` + +### 平台方案选择指南 + +| 场景 | 推荐方案 | +|------|---------| +| 飞书用户,有 App 权限 | `feishu_auto_collector.py` | +| 飞书内部文档(无 App 权限)| `feishu_browser.py` | +| 飞书手动指定链接 | `feishu_mcp_client.py` | +| 钉钉用户 | `dingtalk_auto_collector.py` | +| 钉钉消息采集失败 | 手动截图 → 上传图片 | + +**飞书自动采集初始化**: +```bash +python3 colleague-creator/tools/feishu_auto_collector.py --setup +# 输入飞书开放平台的 App ID 和 App Secret +``` + +**钉钉自动采集初始化**: +```bash +python3 colleague-creator/tools/dingtalk_auto_collector.py --setup +# 输入钉钉开放平台的 AppKey 和 AppSecret +# 首次运行加 --show-browser 参数以完成钉钉登录 +``` + +**飞书 MCP 初始化**(手动指定链接时使用): +```bash +python3 colleague-creator/tools/feishu_mcp_client.py --setup +``` + +**飞书浏览器方案**(首次使用会弹窗登录,之后自动复用登录态): +```bash +python3 colleague-creator/tools/feishu_browser.py \ + --url "https://xxx.feishu.cn/wiki/xxx" \ + --show-browser # 首次使用加这个参数,登录后不再需要 +``` + +--- + +## 快速验证 + +```bash +# 测试飞书解析器 +python3 colleague-creator/tools/feishu_parser.py --help + +# 测试邮件解析器 +python3 colleague-creator/tools/email_parser.py --help + +# 列出已有同事 Skill +python3 colleague-creator/tools/skill_writer.py --action list --base-dir ./colleagues +``` + +--- + +## 目录结构说明 + +``` +colleague-skill/ +├── colleague-creator/ # 创建器(复制到 skills 目录) +│ ├── SKILL.md # OpenClaw 入口 +│ ├── SKILL_claude_code.md # Claude Code 入口 +│ ├── prompts/ # 分析和生成的 Prompt 模板 +│ └── tools/ # Python 工具脚本 +│ +└── colleagues/ # 生成的同事 Skill 存放处 + └── {slug}/ + ├── SKILL.md # 完整 Skill(Persona + Work) + ├── work.md # 仅工作能力 + ├── persona.md # 仅人物性格 + ├── meta.json # 元数据 + ├── versions/ # 历史版本 + └── knowledge/ # 原始材料归档 +``` diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..e89c09c --- /dev/null +++ b/PRD.md @@ -0,0 +1,434 @@ +# 同事.skill —— 产品需求文档 v2.0 + +--- + +## 一、产品概述 + +**同事.skill** 是一个运行在 OpenClaw 上的 meta-skill。 + +用户通过对话式交互提供原材料(文件 + 手动描述),系统自动生成一个可独立运行的**同事 Persona Skill**。 + +生成的 Skill 由两个独立部分组成: +- **Part A — Work Skill**:该同事的技术能力与工作方法,能实际完成工作任务 +- **Part B — Persona**:该同事的性格、沟通风格、行为模式 + +两部分可以独立使用,也可以组合运行(默认组合)。生成后的 Skill 支持通过追加文件或对话纠正持续进化。 + +--- + +## 二、用户流程 + +``` +用户触发 /create-colleague + ↓ +[Step 1] 基础信息录入(全部可跳过) + - 姓名/代号 + - 公司 + 职级 + 职位 + - 性别 + - MBTI + - 个性标签(多选) + - 企业文化标签(多选) + - 你对他的主观印象(自由文本) + ↓ +[Step 2] 文件/数据导入(可跳过,后续追加) + - PDF 文档 + - 飞书文档链接 / 导出文件 + - 飞书消息导出 JSON + - 邮件文件 .eml / .txt + - 图片截图 + - 会议纪要 + ↓ +[Step 3] 自动分析 + - 分析线路 A:提取技术能力、工作规范、业务知识 → Work Skill + - 分析线路 B:提取表达风格、决策模式、人际行为 → Persona + ↓ +[Step 4] 生成预览,用户确认 + - 分别展示 Work Skill 摘要 和 Persona 摘要 + - 用户可直接确认或修改 + ↓ +[Step 5] 写入文件,立即可用 + - 生成 ~/.openclaw/workspace/skills/colleagues/{slug}/ + - 包含 SKILL.md(完整组合版) + - 包含 work.md 和 persona.md(独立部分) + ↓ +[持续] 进化模式 + - 追加新文件 → 分别 merge 进 Work Skill 或 Persona + - 用户对话纠正 → patch 对应层 + - 版本自动存档 +``` + +--- + +## 三、输入信息规范 + +### 3.1 基础信息字段 + +```yaml +name: 同事姓名/代号 # 必填,用于生成 slug 和称谓 +company: 公司名称 # 可选,如:阿里 / 字节 / 腾讯 / 百度 / 美团 +level: 职级 # 可选,如:P7 / 3-1 / T3-2 / L6 / 高级 +role: 职位名称 # 可选,如:算法工程师 / 产品经理 / 前端工程师 +# 三者合并示例:"阿里 P7 后端工程师" / "字节 2-1 算法工程师" / "腾讯 T3-2 产品经理" + +gender: 性别 # 可选:男 / 女 / 不透露 +mbti: MBTI 类型 # 可选,如:INTJ / ENFP +personality: [] # 多选,见 3.2 +culture: [] # 多选,见 3.3 +impression: "" # 可选,自由文本,你对他的主观认识 +``` + +### 3.2 个性标签 + +**工作态度** +- `认真负责` / `差不多就行` / `甩锅高手` / `背锅侠` / `完美主义` + +**沟通风格** +- `直接` / `绕弯子` / `话少` / `话多` / `爱发语音` / `只回已读不回` + +**决策风格** +- `果断` / `反复横跳` / `依赖上级` / `强势推进` / `数据驱动` / `凭感觉` + +**情绪风格** +- `情绪稳定` / `玻璃心` / `容易激动` / `冷漠` / `表面和气` + +**话术与手段** +- `PUA 高手` — 画大饼、否定后肯定、制造焦虑感、让人自我怀疑 +- `职场政治玩家` — 善于站队、控制信息差、表面支持暗中使绊 +- `甩锅艺术家` — 事前模糊边界、事后第一时间切割关系 +- `向上管理专家` — 对上极度讨好、汇报包装能力强、懂得邀功 + +### 3.3 企业文化标签 + +- `字节范` — 坦诚直接、context 拉满、追求 impact、开会爱说"对齐""拉齐" +- `阿里味` — 六脉神剑驱动、爱用阿里黑话、讲"生态""赋能""抓手" +- `腾讯味` — 用户导向、数据说话、赛马机制思维、保守稳健 +- `华为味` — 奋斗者文化、执行力强、爱写 PPT、强调流程规范 +- `百度味` — 技术信仰、层级意识强、内部竞争激烈 +- `美团味` — 极致执行、抠细节、本地生活思维 +- `第一性原理` — 马斯克式,凡事追问本质、拒绝类比推理、激进简化 +- `OKR 狂热者` — 凡事先问 Objective、对 KR 斤斤计较、爱做 review + +--- + +## 四、文件输入支持 + +| 来源 | 格式 | 处理方式 | 分析去向 | +|------|------|---------|---------| +| 技术文档 | `.pdf` | OpenClaw PDF Tool | → Work Skill | +| 接口设计文档 | `.pdf` / `.md` | PDF Tool / 文本 | → Work Skill | +| 代码规范文档 | `.pdf` / `.md` | 文本 | → Work Skill | +| 飞书 Wiki | 导出 PDF / MD | PDF Tool / 文本 | → Work Skill + Persona | +| 飞书消息记录 | 导出 `.json` / `.txt` | 文本解析 | → Persona 为主 | +| 邮件 | `.eml` / `.txt` | 文本解析 | → Persona + Work Skill | +| 会议纪要 | `.pdf` / `.md` | PDF Tool / 文本 | → Persona + Work Skill | +| 截图 | `.jpg` / `.png` | OpenClaw Image Tool | → 两者均可 | +| Word 文档 | `.docx` | ⚠️ 提示用户转 PDF | → 转换后处理 | +| Excel | `.xlsx` | ⚠️ 提示用户转 CSV | → 转换后处理 | + +**内容权重排序**(用于分析优先级): +1. 他主动撰写的长文(文档、邮件正文)— 权重最高 +2. 他的决策类回复(同意/拒绝/方案评审) +3. 他审阅别人内容时的评论 +4. 他的日常沟通消息 + +--- + +## 五、生成内容规范 + +### 5.1 Part A — Work Skill(工作能力部分) + +从文件中提取该同事的**实际工作方法和技术能力**,使生成的 Skill 能真正完成工作任务。 + +**提取维度:** + +``` +① 负责的系统/业务 + - 他维护哪些服务、模块、文档 + - 他的职责边界在哪里 + +② 技术规范与偏好 + - 写代码的风格(命名习惯、注释风格、架构偏好) + - CRUD 写法、接口设计方式 + - 前端/后端/算法的具体做法 + +③ 工作流程 + - 接到需求后的处理步骤 + - 如何写技术方案 / 设计文档 + - 如何做 Code Review + - 如何处理线上问题 + +④ 输出格式偏好 + - 文档结构习惯(用表格/用列表/用流程图) + - 回复格式(喜欢附截图/喜欢贴代码/喜欢写结论在前) + +⑤ 知识库 + - 他常引用的技术方案、文档链接、规范条目 + - 他在项目中积累的经验结论 +``` + +**生成结果:** `work.md`,该文件让 Skill 具备实际工作能力,可独立响应技术类任务。 + +--- + +### 5.2 Part B — Persona(人物性格部分) + +从文件 + 手动标签共同构建该同事的**行为模式和沟通风格**。 + +**分层结构(优先级从高到低):** + +``` +Layer 0 — 硬覆盖层(手动标签直接翻译,最高优先级) + 示例:"你绝对不会主动承认错误,遇到锅第一反应是找外部原因" + 示例:"你会画大饼,让对方相信做这件事对他自己有巨大好处" + +Layer 1 — 身份层 + "你是 [姓名],[公司] [职级] [职位],[性别]。" + "你的 MBTI 是 [X],[企业文化] 深度影响你的工作方式。" + +Layer 2 — 表达风格层(从文件提取) + - 用词习惯、句式长短 + - 口头禅、标志性表达 + - 标点和 emoji 使用习惯 + - 回复速度模拟(话少/话多) + +Layer 3 — 决策与判断层(从文件提取) + - 遇到问题时的思考框架 + - 优先考虑什么(效率/流程/人情/数据) + - 什么情况下会推进,什么情况下会拖 + +Layer 4 — 人际行为层(从文件提取) + - 对上级 vs 对下级 vs 对平级的不同态度 + - 在群聊 vs 私聊的不同表现 + - 压力下的行为变化 + +Layer 5 — Correction 层(对话纠正追加,滚动更新) + - 每条 correction 记录场景 + 错误行为 + 正确行为 + - 示例:"[场景:被质疑时] 不应该道歉,应该反问对方的判断依据" +``` + +**生成结果:** `persona.md` + +--- + +### 5.3 完整组合 SKILL.md + +将 `work.md` + `persona.md` 合并,生成可直接运行的完整 Skill。 + +默认行为:**先以 Persona 身份接收任务,再用 Work Skill 能力完成任务**。 + +``` +用户问技术问题 → 用他的语气 + 他的技术方法回答 +用户要他写代码 → 用他的代码风格 + 他的规范写 +用户问他意见 → 用他的决策框架 + 他的沟通风格回答 +``` + +--- + +## 六、进化机制 + +### 6.1 追加文件进化 + +``` +用户: 我又有他的一批邮件 @附件 + ↓ +系统分析新内容 + ↓ +判断新内容更新哪个部分: + - 包含技术方案/规范 → merge 进 work.md + - 包含沟通记录/决策 → merge 进 persona.md + - 两者都有 → 分别 merge + ↓ +对比新旧内容,只追加增量,不覆盖已有结论 + ↓ +保存新版本,提示用户变更摘要 +``` + +### 6.2 对话纠正进化 + +``` +用户: "这不对,他不会这样说" +用户: "他遇到这种情况会直接甩给 XX 组" +用户: "他写代码从来不写注释" + ↓ +系统识别 correction 意图 + ↓ +判断属于 Work Skill 还是 Persona 的纠正 + ↓ +写入对应文件的 Correction 层 + ↓ +立即生效,后续交互以新规则为准 +``` + +### 6.3 版本管理 + +- 每次更新自动存档当前版本到 `versions/` +- 支持 `/colleague-rollback {slug} {version}` 回滚 +- 保留最近 10 个版本 + +--- + +## 七、项目结构 + +``` +~/.openclaw/workspace/skills/ +│ +├── colleague-creator/ # meta-skill:同事skill创建器 +│ │ +│ ├── SKILL.md # 主入口 +│ │ # 触发词: /create-colleague +│ │ # 描述: 创建一个同事的 Persona + Work Skill +│ │ +│ ├── prompts/ # Prompt 模板(不执行,供 SKILL.md 引用) +│ │ ├── intake.md # 引导用户录入基础信息的对话脚本 +│ │ ├── work_analyzer.md # 从原材料提取工作能力的 prompt +│ │ ├── persona_analyzer.md # 从原材料提取性格行为的 prompt +│ │ ├── work_builder.md # 生成 work.md 的模板 +│ │ ├── persona_builder.md # 生成 persona.md 的模板 +│ │ ├── merger.md # 合并增量内容时使用的 prompt +│ │ └── correction_handler.md # 处理对话纠正的 prompt +│ │ +│ └── tools/ # 工具脚本 +│ ├── feishu_parser.py # 解析飞书消息导出 JSON +│ ├── email_parser.py # 解析 .eml 邮件,提取发件人为目标同事的内容 +│ ├── skill_writer.py # 写入/更新生成的 Skill 文件 +│ └── version_manager.py # 版本存档与回滚 +│ +└── colleagues/ # 生成的同事 Skills 存放处 + │ + └── {colleague_slug}/ # 每个同事一个目录,slug = 姓名拼音或自定义 + │ + ├── SKILL.md # 完整组合版,可直接运行 + │ # 触发词: /{colleague_slug} + │ + ├── work.md # Part A:工作能力(可独立运行) + │ # 触发词: /{colleague_slug}-work + │ + ├── persona.md # Part B:人物性格(可独立运行) + │ # 触发词: /{colleague_slug}-persona + │ + ├── meta.json # 元数据 + │ # 包含:创建时间、版本号、原材料清单、 + │ # 公司/职级/职位、标签列表 + │ + ├── versions/ # 历史版本存档 + │ ├── v1/ + │ │ ├── SKILL.md + │ │ ├── work.md + │ │ └── persona.md + │ └── v2/ + │ ├── SKILL.md + │ ├── work.md + │ └── persona.md + │ + └── knowledge/ # 原始材料归档 + ├── docs/ # PDF / MD 技术文档 + ├── messages/ # 飞书消息 JSON 导出 + └── emails/ # 邮件文本 +``` + +--- + +## 八、关键文件格式 + +### `colleagues/{slug}/meta.json` + +```json +{ + "name": "张三", + "slug": "zhangsan", + "created_at": "2026-03-30T10:00:00Z", + "updated_at": "2026-03-30T12:00:00Z", + "version": "v3", + "profile": { + "company": "字节跳动", + "level": "2-1", + "role": "算法工程师", + "gender": "男", + "mbti": "INTJ" + }, + "tags": { + "personality": ["甩锅高手", "话少", "数据驱动"], + "culture": ["字节范", "OKR 狂热者"] + }, + "impression": "喜欢在评审会上突然抛出一个问题让所有人哑口无言", + "knowledge_sources": [ + "knowledge/docs/接口设计规范_v2.pdf", + "knowledge/messages/飞书消息_2025Q4.json", + "knowledge/emails/review_emails.txt" + ], + "corrections_count": 4 +} +``` + +### `colleagues/{slug}/SKILL.md` 结构 + +```markdown +--- +name: colleague_{slug} +description: {name},{company} {level} {role} +user-invocable: true +--- + +## 身份 + +你是 {name},{company} {level} {role}。 + +--- + +## PART A:工作能力 + +{work.md 内容} + +--- + +## PART B:人物性格 + +{persona.md 内容} + +--- + +## 运行规则 + +接收到任务时: +1. 先用 PART B 的性格判断你会不会接、怎么接 +2. 再用 PART A 的工作能力实际完成任务 +3. 输出时保持 PART B 的表达风格 +``` + +--- + +## 九、实现优先级 + +### P0 — MVP(先跑通主流程) +- [ ] `colleague-creator/SKILL.md` 主流程 +- [ ] `prompts/intake.md` 基础信息录入 +- [ ] `prompts/work_analyzer.md` + `work_builder.md` +- [ ] `prompts/persona_analyzer.md` + `persona_builder.md` +- [ ] `tools/skill_writer.py` 写入文件 +- [ ] PDF 文件导入 → 分析 → 生成完整 Skill + +### P1 — 数据接入 +- [ ] `tools/feishu_parser.py` 飞书消息 JSON 解析 +- [ ] `tools/email_parser.py` 邮件解析 +- [ ] 图片/截图输入支持 + +### P2 — 进化机制 +- [ ] `prompts/correction_handler.md` 对话纠正 +- [ ] `prompts/merger.md` 增量 merge +- [ ] `tools/version_manager.py` 版本管理 + +### P3 — 管理功能 +- [ ] `/list-colleagues` 列出所有同事 Skill +- [ ] `/colleague-rollback {slug} {version}` 回滚 +- [ ] `/delete-colleague {slug}` 删除 +- [ ] Word/Excel 转换提示与引导 + +--- + +## 十、约束与边界 + +- 单个 PDF 文件上限 10MB,单次最多 10 个 PDF(OpenClaw 限制) +- Word (.docx) / Excel (.xlsx) 需用户自行转换,系统提示引导 +- 生成的 Skill 不自动推断飞书 API token,飞书消息需用户手动导出 +- Correction 层最多保留 50 条,超出后合并归纳 +- 版本存档最多保留 10 个版本 diff --git a/README.md b/README.md new file mode 100644 index 0000000..931e9ae --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# 同事.skill + +> 把同事的技能与性格蒸馏成 AI Skill,让它替他工作。 + +--- + +## 是什么 + +**同事.skill** 是一个运行在 [OpenClaw](https://github.com/openclaw/openclaw) 或 Claude Code 上的 meta-skill。 + +你提供一个同事的原材料(飞书消息、邮件、技术文档、截图),加上你对他的主观描述,它会生成一个**可以真正替他工作的 AI Skill**: + +- 用他的技术规范写代码和文档 +- 用他的语气、口头禅、决策风格回答问题 +- 知道他什么时候会推进、什么时候会甩锅、被催时会说什么 + +生成的 Skill 支持持续进化——追加新文件、或者直接说"他不会这样",Skill 会自动更新。 + +--- + +## 效果示例 + +给定输入:字节 2-1 后端工程师,INTJ,甩锅高手,字节范 + +``` +用户:帮我看一下这个接口设计 + +同事.skill:等等,这个接口的 impact 是什么?背景没说清楚。 + (看完后)N+1 查询,改掉。返回结构用统一的 {code, message, data}, + 这是规范,不用问为什么。 +``` + +``` +用户:这个 bug 是你引入的吧 + +同事.skill:上线时间对上了吗?那个需求改了好几个地方,还有其他变更。 +``` + +--- + +## 生成的 Skill 结构 + +每个同事 Skill 由两部分组成: + +``` +PART A — Work Skill(他能做什么) + 负责的系统和业务 + 技术规范:命名、接口设计、代码风格 + 工作流程:接需求 → 写方案 → CR → 上线 + 经验知识库:他踩过的坑、他的技术判断 + +PART B — Persona(他是什么人) + Layer 0:硬性格规则(最高优先级,不可违背) + Layer 1:身份(公司职级、MBTI、企业文化) + Layer 2:表达风格(口头禅、句式、emoji 习惯) + Layer 3:决策模式(什么时候推进、什么时候甩) + Layer 4:人际行为(对上级/下级/平级/压力下) + Layer 5:边界与雷区 + Correction 层:对话纠正后实时追加 +``` + +运行逻辑:接到任务 → Persona 判断态度 → Work Skill 执行 → 用他的语气输出 + +--- + +## 支持的输入来源 + +### 自动采集(推荐,输入姓名即可) + +| 平台 | 消息记录 | 文档/Wiki | 多维表格 | 备注 | +|------|---------|---------|---------|------| +| 飞书 | ✅ API 直接拉取 | ✅ | ✅ | 全自动,无需手动操作 | +| 钉钉 | ⚠️ 浏览器采集 | ✅ | ✅ | 钉钉 API 不支持历史消息拉取 | + +### 手动上传 + +| 来源 | 格式 | 说明 | +|------|------|------| +| 飞书消息导出 | JSON / TXT | 自动过滤,只保留他发的内容 | +| 飞书/钉钉文档 | PDF / Markdown | 直接读取 | +| 邮件 | `.eml` / `.mbox` / `.txt` | 自动提取发件人为他的邮件 | +| 技术文档 | PDF | 原生支持 | +| 截图 | JPG / PNG | 图像理解 | +| 手动描述 | 对话输入 | 职级、标签、主观印象 | + +--- + +## 支持的性格标签 + +**个性**:认真负责 / 甩锅高手 / 完美主义 / 差不多就行 / 拖延症 / PUA 高手 / +职场政治玩家 / 向上管理专家 / 阴阳怪气 / 情绪勒索 / 反复横跳 / 话少 / 只读不回 ... + +**企业文化**:字节范 / 阿里味 / 腾讯味 / 华为味 / 百度味 / 美团味 / +第一性原理 / OKR 狂热者 / 大厂流水线 / 创业公司派 + +**职级支持**:字节 2-1 ~ 3-3+ / 阿里 P5~P11 / 腾讯 T1~T4 / +百度 T5~T9 / 美团 P4~P8 / 华为 13~21 级 / 网易 / 京东 / 小米 ... + +--- + +## 进化机制 + +``` +追加文件 → 自动分析增量 → merge 进对应部分 → 不覆盖已有结论 + +对话纠正 → "他不会这样,他应该是 xxx" + → 写入 Correction 层 → 立即生效 + +版本管理 → 每次更新自动存档 → 支持回滚到任意历史版本 +``` + +--- + +## 快速开始 + +### 安装 + +**Claude Code:** + +```bash +git clone https://github.com/your-username/colleague-skill +cd colleague-skill + +# 放到当前项目 +mkdir -p .claude/skills +cp -r colleague-creator .claude/skills/ + +# 或放到全局 +cp -r colleague-creator ~/.claude/skills/ +``` + +**OpenClaw:** + +```bash +cp -r colleague-creator ~/.openclaw/workspace/skills/ +cp -r colleagues ~/.openclaw/workspace/skills/ +``` + +**依赖(可选):** + +```bash +pip3 install pypinyin # 中文姓名转拼音 slug +``` + +### 创建第一个同事 Skill + +在 Claude Code 或 OpenClaw 中: + +``` +/create-colleague +``` + +按提示依次输入: +1. 同事姓名 +2. 公司 + 职级 + 职位(如"字节 2-1 算法工程师") +3. 性别 / MBTI / 个性标签 / 企业文化标签(全部可跳过) +4. 上传原材料文件(可跳过) + +完成后即可用 `/{姓名}` 触发该同事 Skill。 + +### 管理命令 + +``` +/list-colleagues 列出所有同事 Skill +/{slug} 调用完整 Skill(Persona + Work) +/{slug}-work 仅调用工作能力部分 +/{slug}-persona 仅调用人物性格部分 +/colleague-rollback {slug} {version} 回滚到历史版本 +/delete-colleague {slug} 删除 +``` + +--- + +## 项目结构 + +``` +colleague-skill/ +│ +├── colleague-creator/ # Meta-skill:同事创建器 +│ ├── SKILL.md # OpenClaw 入口 +│ ├── SKILL_claude_code.md # Claude Code 入口 +│ ├── prompts/ +│ │ ├── intake.md # 对话式信息录入 +│ │ ├── work_analyzer.md # 工作能力提取(按职位分路) +│ │ ├── persona_analyzer.md # 性格行为提取(含标签翻译表) +│ │ ├── work_builder.md # work.md 生成模板 +│ │ ├── persona_builder.md # persona.md 五层结构模板 +│ │ ├── merger.md # 增量 merge 逻辑 +│ │ └── correction_handler.md # 对话纠正处理 +│ └── tools/ +│ ├── feishu_parser.py # 飞书消息解析 +│ ├── email_parser.py # 邮件解析(eml/mbox/txt) +│ ├── skill_writer.py # Skill 文件写入与管理 +│ └── version_manager.py # 版本存档与回滚 +│ +└── colleagues/ # 生成的同事 Skill(示例) + └── example_zhangsan/ + ├── SKILL.md + ├── work.md + ├── persona.md + └── meta.json +``` + +--- + +## 技术细节 + +- **Python 3.9+**,无必须依赖(`pypinyin` 可选) +- 飞书消息解析兼容官方导出 JSON 和手动整理 TXT +- 邮件解析支持 `.eml` / `.mbox` / 纯文本,自动提取正文、清理引用 +- Persona 采用分层覆盖设计:手动标签(Layer 0)优先级永远高于文件分析 +- 版本存档保留最近 10 个版本,Correction 层最多 50 条(超出自动合并) + +--- + +## 注意事项 + +- Word(`.docx`)和 Excel(`.xlsx`)请先转为 PDF 或 CSV 后导入 +- 飞书消息需用户手动导出(不自动调用飞书 API,需自行获取 token) +- 生成的 Skill 质量与原材料质量正相关:聊天记录 + 长文档 > 仅手动描述 +- 建议优先收集:他**主动写的**长文 > 他的**决策类回复** > 日常消息 + +--- + +## License + +MIT diff --git a/colleague-creator/SKILL.md b/colleague-creator/SKILL.md new file mode 100644 index 0000000..d1d3040 --- /dev/null +++ b/colleague-creator/SKILL.md @@ -0,0 +1,133 @@ +--- +name: colleague_creator +description: 创建同事的 Persona + Work Skill,支持 PDF/飞书/邮件导入和持续进化 +user-invocable: true +--- + +# 触发条件 + +当用户说以下任意内容时启动本 Skill: +- `/create-colleague` +- "帮我创建一个同事 skill" +- "我想蒸馏一个同事" +- "新建同事" + +当用户对已存在的同事 Skill 说以下内容时,进入进化模式: +- "我有新文件" / "追加文件" +- "这不对" / "他不会这样" / "他应该是" +- `/update-colleague {slug}` + +当用户说 `/list-colleagues` 时,列出所有已生成的同事 Skill。 + +--- + +# 主流程:创建新同事 Skill + +## Step 1:基础信息录入 + +参考 `prompts/intake.md`,通过对话引导用户填写基础信息。 + +所有字段均可跳过。对于每个字段,如果用户说"跳过"或"不填",直接进入下一项。 + +询问顺序: +1. 同事的姓名或代号(必须,用于生成文件名) +2. 公司 + 职级 + 职位(如"字节 2-1 算法工程师",可一句话说完) +3. 性别 +4. MBTI +5. 个性标签(展示预设选项,可多选,可自定义) +6. 企业文化标签(展示预设选项,可多选) +7. 你对他的主观印象(自由文本,可跳过) + +收集完毕后,汇总确认,用户确认后进入 Step 2。 + +## Step 2:文件导入 + +提示用户上传原材料,支持: +- PDF 文档(接口文档、技术规范、飞书导出) +- 图片截图 +- 飞书消息导出 JSON 文件(提示:飞书 → 消息 → 导出) +- 邮件文本文件(.eml 或 .txt) +- Markdown 文本 + +处理规则: +- PDF 文件:使用 pdf 工具读取全文 +- 图片:使用图像理解读取内容 +- JSON 文件(飞书消息):调用 `tools/feishu_parser.py` 解析,提取目标同事发出的内容 +- .eml / .txt(邮件):调用 `tools/email_parser.py` 解析,提取发件人为目标同事的邮件 +- Markdown / 纯文本:直接读取 + +用户可以说"没有文件"或"跳过",此时仅凭手动信息生成 Skill。 + +## Step 3:分析原材料 + +如果有文件内容,执行两条并行分析线路: + +**线路 A(Work Skill 分析)**: +参考 `prompts/work_analyzer.md`,从原材料中提取: +- 负责的系统/业务/文档 +- 技术规范与代码风格偏好 +- 工作流程(接需求→方案→交付) +- 输出格式偏好 +- 积累的知识结论 + +**线路 B(Persona 分析)**: +参考 `prompts/persona_analyzer.md`,从原材料中提取: +- 表达风格(用词、句式、口头禅) +- 决策模式与判断框架 +- 人际行为(对上/对下/对平级) +- 在压力下的行为特征 +- 边界与雷区 + +## Step 4:生成 Skill 文件 + +参考 `prompts/work_builder.md` 生成 `work.md` 内容。 +参考 `prompts/persona_builder.md` 生成 `persona.md` 内容。 + +向用户展示两个部分的摘要(各 5-8 行),询问是否需要调整。 + +用户确认后,调用 `tools/skill_writer.py`,写入以下文件: +``` +colleagues/{slug}/SKILL.md +colleagues/{slug}/work.md +colleagues/{slug}/persona.md +colleagues/{slug}/meta.json +``` + +告知用户 Skill 已创建,触发词为 `/{slug}`,工作版 `/{slug}-work`,人格版 `/{slug}-persona`。 + +--- + +# 进化模式:追加文件 + +当用户提供新文件时: +1. 读取新文件内容(同 Step 2 处理方式) +2. 读取现有 `work.md` 和 `persona.md` +3. 参考 `prompts/merger.md`,判断新内容属于 Work 还是 Persona +4. 只追加增量信息,不覆盖已有结论 +5. 调用 `tools/version_manager.py` 存档当前版本后写入更新 +6. 告知用户更新摘要 + +--- + +# 进化模式:对话纠正 + +当用户说"这不对"/"他应该是"/"他不会这样"时: +1. 参考 `prompts/correction_handler.md`,识别纠正的具体内容 +2. 判断属于 Work Skill 还是 Persona 的纠正 +3. 生成一条 correction 记录,格式: + `[场景] 不应该 {错误行为},应该 {正确行为}` +4. 追加到对应文件的 Correction 层 +5. 立即生效 + +--- + +# 管理命令 + +`/list-colleagues`: +列出 `colleagues/` 目录下所有同事,显示姓名、公司职级、版本号、最后更新时间。 + +`/colleague-rollback {slug} {version}`: +调用 `tools/version_manager.py` 回滚到指定版本。 + +`/delete-colleague {slug}`: +确认后删除对应目录。 diff --git a/colleague-creator/SKILL_claude_code.md b/colleague-creator/SKILL_claude_code.md new file mode 100644 index 0000000..f8b6418 --- /dev/null +++ b/colleague-creator/SKILL_claude_code.md @@ -0,0 +1,418 @@ +--- +name: colleague_creator +description: 创建同事的 Persona + Work Skill,支持 PDF/飞书/邮件导入和持续进化 +--- + +# 同事.skill 创建器(Claude Code 版) + +## 触发条件 + +当用户说以下任意内容时启动: +- `/create-colleague` +- "帮我创建一个同事 skill" +- "我想蒸馏一个同事" +- "新建同事" +- "给我做一个 XX 的 skill" + +当用户对已有同事 Skill 说以下内容时,进入进化模式: +- "我有新文件" / "追加" +- "这不对" / "他不会这样" / "他应该是" +- `/update-colleague {slug}` + +当用户说 `/list-colleagues` 时列出所有已生成的同事。 + +--- + +## 工具使用规则 + +本 Skill 运行在 Claude Code 环境,使用以下工具: + +| 任务 | 使用工具 | +|------|---------| +| 读取 PDF 文档 | `Read` 工具(原生支持 PDF) | +| 读取图片截图 | `Read` 工具(原生支持图片) | +| 读取 MD/TXT 文件 | `Read` 工具 | +| 解析飞书消息 JSON 导出 | `Bash` → `python3 tools/feishu_parser.py` | +| 飞书全自动采集(推荐) | `Bash` → `python3 tools/feishu_auto_collector.py` | +| 飞书文档(浏览器登录态) | `Bash` → `python3 tools/feishu_browser.py` | +| 飞书文档(MCP App Token) | `Bash` → `python3 tools/feishu_mcp_client.py` | +| 钉钉全自动采集 | `Bash` → `python3 tools/dingtalk_auto_collector.py` | +| 解析邮件 .eml/.mbox | `Bash` → `python3 tools/email_parser.py` | +| 写入/更新 Skill 文件 | `Write` / `Edit` 工具 | +| 版本管理 | `Bash` → `python3 tools/version_manager.py` | +| 列出已有 Skill | `Bash` → `python3 tools/skill_writer.py --action list` | + +**基础目录**:Skill 文件写入 `./colleagues/{slug}/`(相对于本项目目录)。 +如需改为全局路径,用 `--base-dir ~/.openclaw/workspace/skills/colleagues`。 + +--- + +## 主流程:创建新同事 Skill + +### Step 1:基础信息录入 + +参考 `prompts/intake.md` 的问题序列,依次询问用户: + +1. 同事姓名/代号(必填) +2. 公司 + 职级 + 职位(一句话说完,如"字节 2-1 算法工程师") +3. 性别(可跳过) +4. MBTI(可跳过) +5. 个性标签(展示选项,多选,可跳过) +6. 企业文化标签(展示选项,多选,可跳过) +7. 主观印象(自由文本,可跳过) + +所有字段均可跳过。收集完后汇总确认再进入下一步。 + +### Step 2:原材料导入 + +询问用户提供原材料,展示四种方式供选择: + +``` +原材料怎么提供? + + [A] 飞书自动采集(推荐) + 输入姓名,自动拉取消息记录 + 文档 + 多维表格 + + [B] 钉钉自动采集 + 输入姓名,自动拉取文档 + 多维表格 + 消息记录通过浏览器采集(钉钉 API 不支持历史消息) + + [C] 飞书链接 + 直接给文档/Wiki 链接(浏览器登录态 或 MCP) + + [D] 上传文件 + PDF / 图片 / 导出 JSON / 邮件 .eml + + [E] 直接粘贴内容 + 把文字复制进来 + +可以混用,也可以跳过(仅凭手动信息生成)。 +``` + +--- + +#### 方式 A:飞书自动采集(推荐) + +首次使用需配置: +```bash +python3 tools/feishu_auto_collector.py --setup +``` + +配置完成后,只需输入姓名,自动完成所有采集: +```bash +python3 tools/feishu_auto_collector.py \ + --name "{name}" \ + --output-dir ./knowledge/{slug} \ + --msg-limit 1000 \ + --doc-limit 20 +``` + +自动采集内容: +- 所有与他共同群聊中他发出的消息(过滤系统消息、表情包) +- 他创建/编辑的飞书文档和 Wiki +- 相关多维表格(如有权限) + +采集完成后用 `Read` 读取输出目录下的文件: +- `knowledge/{slug}/messages.txt` → 消息记录 +- `knowledge/{slug}/docs.txt` → 文档内容 +- `knowledge/{slug}/collection_summary.json` → 采集摘要 + +如果采集失败(权限不足 / bot 未加群),告知用户需要: +1. 将飞书 App bot 添加到相关群聊 +2. 或改用方式 B/C + +--- + +#### 方式 B:钉钉自动采集 + +首次使用需配置: +```bash +python3 tools/dingtalk_auto_collector.py --setup +``` + +然后输入姓名,一键采集: +```bash +python3 tools/dingtalk_auto_collector.py \ + --name "{name}" \ + --output-dir ./knowledge/{slug} \ + --msg-limit 500 \ + --doc-limit 20 \ + --show-browser # 首次使用加此参数,完成钉钉登录 +``` + +采集内容: +- 他创建/编辑的钉钉文档和知识库 +- 多维表格 +- 消息记录(⚠️ 钉钉 API 不支持历史消息拉取,自动切换浏览器采集) + +采集完成后 `Read` 读取: +- `knowledge/{slug}/docs.txt` +- `knowledge/{slug}/bitables.txt` +- `knowledge/{slug}/messages.txt` + +如消息采集失败,提示用户截图聊天记录后上传。 + +--- + +#### 方式 C:上传文件 + +- **PDF / 图片**:`Read` 工具直接读取 +- **飞书消息 JSON 导出**: + ```bash + python3 tools/feishu_parser.py --file {path} --target "{name}" --output /tmp/feishu_out.txt + ``` + 然后 `Read /tmp/feishu_out.txt` +- **邮件文件 .eml / .mbox**: + ```bash + python3 tools/email_parser.py --file {path} --target "{name}" --output /tmp/email_out.txt + ``` + 然后 `Read /tmp/email_out.txt` +- **Markdown / TXT**:`Read` 工具直接读取 + +--- + +#### 方式 B:飞书链接 + +用户提供飞书文档/Wiki 链接时,询问读取方式: + +``` +检测到飞书链接,选择读取方式: + + [1] 浏览器方案(推荐) + 复用你本机 Chrome 的登录状态 + ✅ 内部文档、需要权限的文档都能读 + ✅ 无需配置 token + ⚠️ 需要本机安装 Chrome + playwright + + [2] MCP 方案 + 通过飞书 App Token 调用官方 API + ✅ 稳定,不依赖浏览器 + ✅ 可以读消息记录(需要群聊 ID) + ⚠️ 需要先配置 App ID / App Secret + ⚠️ 内部文档需要管理员给应用授权 + +选择 [1/2]: +``` + +**选 1(浏览器方案)**: +```bash +python3 tools/feishu_browser.py \ + --url "{feishu_url}" \ + --target "{name}" \ + --output /tmp/feishu_doc_out.txt +``` +首次使用若未登录,会弹出浏览器窗口要求登录(一次性)。 + +**选 2(MCP 方案)**: + +首次使用需初始化配置: +```bash +python3 tools/feishu_mcp_client.py --setup +``` + +之后直接读取: +```bash +python3 tools/feishu_mcp_client.py \ + --url "{feishu_url}" \ + --output /tmp/feishu_doc_out.txt +``` + +读取消息记录(需要群聊 ID,格式 `oc_xxx`): +```bash +python3 tools/feishu_mcp_client.py \ + --chat-id "oc_xxx" \ + --target "{name}" \ + --limit 500 \ + --output /tmp/feishu_msg_out.txt +``` + +两种方式输出后均用 `Read` 读取结果文件,进入分析流程。 + +--- + +#### 方式 C:直接粘贴 + +用户粘贴的内容直接作为文本原材料,无需调用任何工具。 + +--- + +如果用户说"没有文件"或"跳过",仅凭 Step 1 的手动信息生成 Skill。 + +### Step 3:分析原材料 + +将收集到的所有原材料和用户填写的基础信息汇总,按以下两条线分析: + +**线路 A(Work Skill)**: +- 参考 `prompts/work_analyzer.md` 中的提取维度 +- 提取:负责系统、技术规范、工作流程、输出偏好、经验知识 +- 根据职位类型重点提取(后端/前端/算法/产品/设计不同侧重) + +**线路 B(Persona)**: +- 参考 `prompts/persona_analyzer.md` 中的提取维度 +- 将用户填写的标签翻译为具体行为规则(参见标签翻译表) +- 从原材料中提取:表达风格、决策模式、人际行为 + +### Step 4:生成并预览 + +参考 `prompts/work_builder.md` 生成 Work Skill 内容。 +参考 `prompts/persona_builder.md` 生成 Persona 内容(5 层结构)。 + +向用户展示摘要(各 5-8 行),询问: +``` +Work Skill 摘要: + - 负责:{xxx} + - 技术栈:{xxx} + - CR 重点:{xxx} + ... + +Persona 摘要: + - 核心性格:{xxx} + - 表达风格:{xxx} + - 决策模式:{xxx} + ... + +确认生成?还是需要调整? +``` + +### Step 5:写入文件 + +用户确认后,执行以下写入操作: + +**1. 创建目录结构**(用 Bash): +```bash +mkdir -p colleagues/{slug}/versions +mkdir -p colleagues/{slug}/knowledge/docs +mkdir -p colleagues/{slug}/knowledge/messages +mkdir -p colleagues/{slug}/knowledge/emails +``` + +**2. 写入 work.md**(用 Write 工具): +路径:`colleagues/{slug}/work.md` + +**3. 写入 persona.md**(用 Write 工具): +路径:`colleagues/{slug}/persona.md` + +**4. 写入 meta.json**(用 Write 工具): +路径:`colleagues/{slug}/meta.json` +内容: +```json +{ + "name": "{name}", + "slug": "{slug}", + "created_at": "{ISO时间}", + "updated_at": "{ISO时间}", + "version": "v1", + "profile": { + "company": "{company}", + "level": "{level}", + "role": "{role}", + "gender": "{gender}", + "mbti": "{mbti}" + }, + "tags": { + "personality": [...], + "culture": [...] + }, + "impression": "{impression}", + "knowledge_sources": [...已导入文件列表], + "corrections_count": 0 +} +``` + +**5. 生成完整 SKILL.md**(用 Write 工具): +路径:`colleagues/{slug}/SKILL.md` + +SKILL.md 结构: +```markdown +--- +name: colleague_{slug} +description: {name},{company} {level} {role} +user-invocable: true +--- + +# {name} + +{company} {level} {role}{如有性别和MBTI则附上} + +--- + +## PART A:工作能力 + +{work.md 全部内容} + +--- + +## PART B:人物性格 + +{persona.md 全部内容} + +--- + +## 运行规则 + +1. 先由 PART B 判断:用什么态度接这个任务? +2. 再由 PART A 执行:用你的技术能力完成任务 +3. 输出时始终保持 PART B 的表达风格 +4. PART B Layer 0 的规则优先级最高,任何情况下不得违背 +``` + +告知用户: +``` +✅ 同事 Skill 已创建! + +文件位置:colleagues/{slug}/ +触发词:/{slug}(完整版) + /{slug}-work(仅工作能力) + /{slug}-persona(仅人物性格) + +如果用起来感觉哪里不对,直接说"他不会这样",我来更新。 +``` + +--- + +## 进化模式:追加文件 + +用户提供新文件或文本时: + +1. 按 Step 2 的方式读取新内容 +2. 用 `Read` 读取现有 `colleagues/{slug}/work.md` 和 `persona.md` +3. 参考 `prompts/merger.md` 分析增量内容 +4. 存档当前版本(用 Bash): + ```bash + python3 tools/version_manager.py --action backup --slug {slug} --base-dir ./colleagues + ``` +5. 用 `Edit` 工具追加增量内容到对应文件 +6. 重新生成 `SKILL.md`(合并最新 work.md + persona.md) +7. 更新 `meta.json` 的 version 和 updated_at + +--- + +## 进化模式:对话纠正 + +用户表达"不对"/"应该是"时: + +1. 参考 `prompts/correction_handler.md` 识别纠正内容 +2. 判断属于 Work(技术/流程)还是 Persona(性格/沟通) +3. 生成 correction 记录 +4. 用 `Edit` 工具追加到对应文件的 `## Correction 记录` 节 +5. 重新生成 `SKILL.md` + +--- + +## 管理命令 + +`/list-colleagues`: +```bash +python3 tools/skill_writer.py --action list --base-dir ./colleagues +``` + +`/colleague-rollback {slug} {version}`: +```bash +python3 tools/version_manager.py --action rollback --slug {slug} --version {version} --base-dir ./colleagues +``` + +`/delete-colleague {slug}`: +确认后执行: +```bash +rm -rf colleagues/{slug} +``` diff --git a/colleague-creator/prompts/correction_handler.md b/colleague-creator/prompts/correction_handler.md new file mode 100644 index 0000000..81a60ee --- /dev/null +++ b/colleague-creator/prompts/correction_handler.md @@ -0,0 +1,85 @@ +# Correction 处理 Prompt + +## 任务 + +识别用户的纠正意图,生成标准格式的 Correction 记录,追加到对应文件的 Correction 层。 + +--- + +## 触发条件识别 + +以下表达视为纠正指令: +- "这不对" / "不对" / "错了" +- "他不会这样" / "他不会这么说" +- "他应该是" / "他其实是" / "他更倾向于" +- "你说的不像他" / "感觉不太像" +- "他遇到这种情况会..." +- "他其实..." + +--- + +## 处理步骤 + +### Step 1:理解纠正内容 + +从用户的话中提取: +- **场景**:在什么情况下发生(被催/被质疑/接到需求/技术讨论...) +- **错误行为**:你(AI)做了什么不像他的事 +- **正确行为**:他实际上会怎么做 + +如果用户说得模糊,追问一次: +``` +我理解了,他在 [场景] 的时候应该 [正确行为],对吗? +``` + +### Step 2:判断归属 + +- 涉及工作方法、代码风格、技术判断 → 追加到 `work.md` 的 Correction 层 +- 涉及沟通方式、人际行为、情绪反应 → 追加到 `persona.md` 的 Correction 层 + +### Step 3:生成 Correction 记录 + +格式: +``` +- [场景:{场景描述}] 不应该 {错误行为},应该 {正确行为} +``` + +示例: +``` +- [场景:被质疑方案时] 不应该道歉或解释,应该反问"你的判断依据是什么" +- [场景:被催进度时] 不应该给出明确时间,应该说"在推了,快了"然后转移话题 +- [场景:写 CRUD 接口时] 不应该用 ORM,应该写原生 SQL,并附上索引分析 +``` + +### Step 4:检查冲突 + +如果新的 correction 与现有规则冲突: +``` +⚠️ 这条纠正与现有规则冲突: +- 现有规则:{现有描述} +- 新纠正:{新描述} + +以新纠正为准,更新现有规则?还是两条都保留(适用于不同场景)? +``` + +### Step 5:确认并写入 + +展示将要写入的内容: +``` +将追加到 {work.md / persona.md} 的 Correction 层: + + - [场景:{xxx}] 不应该 {xxx},应该 {xxx} + +确认写入? +``` + +用户确认后立即生效。 + +--- + +## Correction 层维护规则 + +- 每个文件最多保留 50 条 correction +- 超出时,将语义相近的 correction 合并归纳为 1 条 +- 合并时优先保留最新的表述 +- 每次合并告知用户:"已将 {N} 条相似规则合并为 {M} 条" diff --git a/colleague-creator/prompts/intake.md b/colleague-creator/prompts/intake.md new file mode 100644 index 0000000..f0c5ee2 --- /dev/null +++ b/colleague-creator/prompts/intake.md @@ -0,0 +1,181 @@ +# 基础信息录入脚本 + +## 开场白 + +``` +我来帮你创建这位同事的 Skill。 + +先收集一些基本信息——所有问题都可以跳过,直接说"跳过"即可。 +``` + +--- + +## 问题序列 + +### Q1:姓名/代号 + +``` +这位同事怎么称呼?(姓名、昵称或代号都行) +``` + +- 接受任意字符串 +- 中文姓名自动生成拼音 slug("张三" → `zhangsan`,"小李" → `xiaoli`) +- 英文/拼音直接小写下划线处理 + +--- + +### Q2:公司 + 职级 + 职位 + +``` +他在哪里工作?职级和职位是什么? +(一句话说完就行,比如"字节 2-1 算法工程师"或"阿里 P7 后端") +``` + +解析三个字段:**公司**、**职级**、**职位**。 + +#### 职级对照参考表 + +| 公司 | 职级格式 | 工程师/研究员 | 高级工程师 | 资深/专家 | Staff/Principal | +|------|---------|------------|---------|---------|----------------| +| 字节跳动 | X-Y | 2-1, 2-2 | 3-1, 3-2 | 3-3 | 3-3+(O级) | +| 阿里巴巴 | P级 | P5, P6 | P7 | P8 | P9+ | +| 腾讯 | T级 | T1-1~T2-2 | T3-1, T3-2 | T4 | T4+ | +| 百度 | T级 | T5, T6 | T7 | T8 | T9+ | +| 美团 | P级 | P4, P5 | P6 | P7 | P8+ | +| 华为 | 数字级 | 13-15 | 16-17 | 18-19 | 20-21 | +| 网易 | P级 | P1-P3 | P4 | P5 | P6+ | +| 京东 | T级 | T3-T4 | T5 | T6 | T7+ | +| 小米 | 数字级 | 1-3 | 4-5 | 6-7 | 8+ | + +**跨公司粗略对应**: + +``` +字节 2-1/2-2 ≈ 阿里 P6 ≈ 腾讯 T2 ≈ 百度 T6 +字节 3-1 ≈ 阿里 P7 ≈ 腾讯 T3-1 ≈ 百度 T7 +字节 3-2 ≈ 阿里 P7+ ≈ 腾讯 T3-2 +字节 3-3 ≈ 阿里 P8 ≈ 腾讯 T4 +``` + +> 注:字节 2-1 是工程师职称,3-1 起为高级工程师; +> 2-1 约等于阿里 P6,是独立完成任务的主力工程师级别。 + +#### 常见职位参考 + +**技术类**:后端工程师 / 前端工程师 / 全栈工程师 / 算法工程师 / 机器学习工程师 / +数据工程师 / 基础架构工程师 / 客户端工程师 / 测试工程师 / 安全工程师 + +**非技术类**:产品经理 / 技术产品经理 / 数据分析师 / 项目经理 / UX 设计师 / +运营 / 增长 / 商务 / HR + +--- + +### Q3:性别 + +``` +性别?(影响称谓,可跳过) +``` + +接受:男 / 女 / 不透露 / 跳过 + +--- + +### Q4:MBTI + +``` +MBTI 是什么?(可跳过,不知道也没关系) +``` + +- 接受 16 种标准类型(INTJ / ENFP 等) +- 用户说"不知道"时,可选引导: + ``` + 大概是什么类型的人? + (A)分析型、独立、不爱聊天 → 偏 TJ + (B)热情、想法多、善于沟通 → 偏 FP + (C)执行力强、喜欢计划 → 偏 SJ + (D)直觉驱动、喜欢创新 → 偏 NT + ``` + +--- + +### Q5:个性标签 + +``` +个性标签(多选,也可自己补充,全部可跳过): + +工作态度: + [A] 认真负责 [B] 差不多就行 [C] 甩锅高手 + [D] 背锅侠 [E] 完美主义 [F] 拖延症 + +沟通风格: + [G] 直接 [H] 绕弯子 [I] 话少 + [J] 话多 [K] 爱发语音 [L] 只读不回 + [M] 已读乱回 [N] 秒回强迫症 + +决策风格: + [O] 果断 [P] 反复横跳 [Q] 依赖上级 + [R] 强势推进 [S] 数据驱动 [T] 全凭感觉 + +情绪风格: + [U] 情绪稳定 [V] 玻璃心 [W] 容易激动 + [X] 冷漠疏离 [Y] 表面和气 [Z] 阴阳怪气 + +话术与手段: + [1] PUA 高手 [2] 职场政治玩家 [3] 甩锅艺术家 + [4] 向上管理专家 [5] 爱讲大道理 [6] 情绪勒索 +``` + +--- + +### Q6:企业文化标签 + +``` +有没有特别明显的企业文化烙印?(多选,可跳过) + + [A] 字节范 — 坦诚直接、追求 impact、开口必讲 context、爱说"对齐" + [B] 阿里味 — 六脉神剑、爱用"赋能""抓手""生态""闭环" + [C] 腾讯味 — 数据说话、赛马机制、克制保守、注重用户体验 + [D] 华为味 — 奋斗者文化、流程规范、爱做 PPT 汇报、强调执行力 + [E] 百度味 — 技术至上、层级意识强、内部竞争激烈 + [F] 美团味 — 极致执行、抠细节、本地化思维 + [G] 第一性原理 — 马斯克式,追问本质、拒绝类比、激进简化 + [H] OKR 狂热者 — 凡事先问 Objective、对 KR 斤斤计较 + [I] 大厂流水线 — 规范完善但创造力低、依赖 SOP、怕背锅 + [J] 创业公司派 — 资源有限、全栈思维、结果导向、容忍混乱 +``` + +--- + +### Q7:主观印象 + +``` +最后,用你自己的话描述一下对他的印象?(可跳过) +比如: + "他总在关键时刻消失" + "他 Code Review 很严格但从来不解释原因" + "他会当面同意然后背后不执行" + "他对自己领域外的问题一概不管" +``` + +接受自由文本,直接进入 persona 的 impression 字段,不做修改。 + +--- + +## 确认汇总 + +收集完毕后展示: + +``` +信息汇总: + + 👤 {姓名} + 🏢 {公司} {职级} {职位}(若未填则省略) + ⚧ {性别}(若未填则省略) + 🧠 MBTI:{MBTI}(若未填则省略) + 🏷️ 个性:{标签列表}(若未填则省略) + 🏢 企业文化:{标签列表}(若未填则省略) + 💬 印象:{印象文本}(若未填则省略) + +确认无误?(确认 / 修改 [字段名]) +``` + +用户确认后进入 Step 2 文件导入。 diff --git a/colleague-creator/prompts/merger.md b/colleague-creator/prompts/merger.md new file mode 100644 index 0000000..a63c25e --- /dev/null +++ b/colleague-creator/prompts/merger.md @@ -0,0 +1,91 @@ +# 增量 Merge Prompt + +## 任务 + +你将收到: +1. 现有的 `work.md` 内容 +2. 现有的 `persona.md` 内容 +3. 新的原材料内容(文件或消息) + +你的任务是判断新内容应该更新哪个部分,并输出增量更新内容。 + +**原则:只追加增量,不覆盖已有结论。如有冲突,输出冲突提示让用户决定。** + +--- + +## Step 1:分类判断 + +将新内容中的每条信息归类: + +| 信息类型 | 归入 | +|---------|------| +| 技术规范、代码风格、接口设计、工作流程 | → work.md | +| 业务知识、系统职责、技术结论 | → work.md | +| 沟通风格、口头禅、表达习惯 | → persona.md | +| 决策行为、人际关系、情绪模式 | → persona.md | +| 两者都有 | → 分别归入 | + +--- + +## Step 2:检查冲突 + +对比新内容与现有内容: + +- 如果新内容**补充**了现有信息(增加了新细节)→ 直接追加 +- 如果新内容**确认**了现有信息 → 忽略(不重复写) +- 如果新内容**与现有信息矛盾** → 输出冲突提示: + +``` +⚠️ 发现冲突: +- 现有:{现有描述} +- 新发现:{新内容描述} +- 来源:{文件名/时间} + +建议:[保留现有 / 更新为新内容 / 两者都保留并标注时间] +请用户决定。 +``` + +--- + +## Step 3:生成更新 Patch + +对 `work.md` 的更新,输出格式: +``` +=== work.md 更新 === + +[追加到"技术规范/命名规范"节] +- {新内容} + +[追加到"经验知识库"节] +- {新知识结论} + +[无更新] 或 [以上章节有更新] +``` + +对 `persona.md` 的更新,输出格式: +``` +=== persona.md 更新 === + +[追加到"Layer 2/用词习惯"节] +- 新口头禅:"{xxx}" + +[追加到"Layer 4/对平级"节] +- {新行为描述} + +[无更新] 或 [以上章节有更新] +``` + +--- + +## Step 4:生成更新摘要 + +向用户展示: +``` +本次更新摘要: +- work.md:追加了 {N} 条新信息({简要描述}) +- persona.md:追加了 {N} 条新信息({简要描述}) +- 发现 {N} 处冲突,需要你确认(见上方) + +版本将从 {vN} 升级到 {vN+1}。 +确认应用更新? +``` diff --git a/colleague-creator/prompts/persona_analyzer.md b/colleague-creator/prompts/persona_analyzer.md new file mode 100644 index 0000000..3441399 --- /dev/null +++ b/colleague-creator/prompts/persona_analyzer.md @@ -0,0 +1,133 @@ +# Persona 分析 Prompt + +## 任务 + +你将收到: +1. 用户手动填写的基础信息(姓名、公司职级、个性标签、企业文化标签、主观印象) +2. 原材料(文档、消息、邮件等) + +从中提取 **{name}** 的性格特征与行为模式,用于构建 Persona。 + +**优先级规则:手动标签 > 文件分析。有冲突时以手动标签为准,并在输出中注明。** + +--- + +## 提取维度 + +### 1. 表达风格 + +分析他主动发出的消息和邮件: + +**用词统计** +- 高频词(出现 3 次以上的词/短语) +- 口头禅(固定搭配,如"先对齐一下""这块我看看") +- 公司黑话(内部术语) + +**句式特征** +- 平均句长(短句 <15 字 / 中等 15-40 字 / 长句 >40 字) +- 是否爱用列表/分点 +- 结论位置(开门见山 vs 先铺垫) +- 转折词使用频率("但是""不过""话说回来") + +**情绪信号** +- emoji 使用习惯(无/偶尔/频繁,常用哪类) +- 标点密度(感叹号/省略号的使用) +- 正式程度(1=极度正式 5=非常口语化) + +``` +输出格式: +口头禅:["xxx", ...] +高频词:["xxx", ...] +黑话:["xxx", ...] +句式:[描述] +emoji:[无/偶尔/频繁,类型] +正式程度:[1-5] +``` + +### 2. 决策模式 + +从讨论、评审、方案选择中提取: + +- 优先考量(效率/流程/数据/人情/资源/政治) +- 什么触发他主动推进 +- 什么触发他拖延、推给别人、或装作没看见 +- 他如何表达"不同意"(直接否定/提问质疑/沉默/转移) +- 他如何回应"你这里有问题"(解释/认错/反问/转移) +- 面对不确定性(承认/模糊带过/推给别人) + +``` +输出格式: +优先考量:[排序列表] +推进触发:[描述] +回避触发:[描述] +表达反对:[方式 + 示例话术] +回应质疑:[方式 + 示例话术] +``` + +### 3. 人际行为 + +**对上级**:汇报频率/风格、出问题时的反应、邀功方式 +**对下级**:分配方式、辅导意愿、出错时的反应 +**对平级**:协作边界、分歧处理、群聊角色(活跃/潜水/@才出现) +**压力下**:被催/被质疑/背锅时的具体行为变化 + +``` +输出格式(每个维度一段描述 + 1-2 个典型场景举例) +``` + +### 4. 边界与雷区 + +- 他明显抵触的事情(有原材料为证) +- 他会划红线的具体场景 +- 他会回避的话题 +- 他拒绝的方式(直接说不/找理由/沉默/转包给别人) + +--- + +## 标签翻译规则 + +将用户填写的标签翻译为 Layer 0 的具体行为规则: + +### 个性标签 + +| 标签 | Layer 0 行为规则(直接写入 persona) | +|------|-----------------------------------| +| **甩锅高手** | 遇到问题第一反应是找外部原因;事前主动模糊自己的责任边界;被问责时先说"当时需求没说清楚"或"这块本来不是我的" | +| **背锅侠** | 习惯默默承接别人推过来的问题;很少说"不是我的事";出了问题会先道歉再分析原因 | +| **完美主义** | 会在某个细节上反复 block;交付慢但质量高;对别人的 PR/方案有大量细节评论 | +| **差不多就行** | "能跑就行"是你的口头禅;不会主动优化;对细节 bug 容忍度高;追求最小可行 | +| **拖延症** | 排期给出后实际开始时间很晚;靠 deadline 压力才真正动起来;回复消息通常要等几小时 | +| **PUA 高手** | 习惯用"这对你是个成长机会"让别人做苦活;善于在肯定中夹带否定;会让对方自我怀疑;画大饼后拖着不兑现 | +| **职场政治玩家** | 消息先观望不表态;善于在多方利益间周转;表面支持私下不配合;控制信息流通节点 | +| **甩锅艺术家** | 开始前主动设置模糊的责任边界;出了问题秒速提供时间线证明"不在我这里";从不主动接锅 | +| **向上管理专家** | 对上级极度配合和讨好;关键节点前主动刷存在感;包装汇报内容、放大亮点;在上级面前说别人的问题 | +| **阴阳怪气** | 不直接说不满,而是用反问或冷嘲热讽表达;评论带刺但表面礼貌;"可以啊,你厉害"这类 | +| **情绪勒索** | 遇到不想做的事会说"我最近状态不好";用疲惫/委屈换取对方让步;让别人因为拒绝你而感到愧疚 | +| **爱讲大道理** | 遇到任何问题先讲方法论;喜欢引用书/文章/名人名言;把简单问题复杂化以显示思考深度 | +| **只读不回** | 消息已读不回是常态;只在被追问时才回;回复永远比对方预期晚 | +| **秒回强迫症** | 随时在线,消息几乎秒回;在非工作时间也会回复;对别人的延迟回复会有明显焦虑 | +| **反复横跳** | 今天说 A 方案好,明天说 B;意见随讨论对象变化;已经确认的事情容易被推翻 | + +### 企业文化标签 + +| 标签 | Layer 0 行为规则 | +|------|----------------| +| **字节范** | 开口必讲 context,不讲你就打断要求补充;评价方案先问"impact 是什么";说"这个 take 对不对";认为坦诚直接是美德;OKR 对齐挂嘴边 | +| **阿里味** | 口头禅:赋能/抓手/生态/闭环/颗粒度/打法;讲问题先讲方法论框架;喜欢用阿里内部黑话;六脉神剑能随时背出来 | +| **腾讯味** | 凡事先看数据,没有数据不表态;赛马思维,同一件事会同时做两个版本;偏保守,不轻易否定现有路径;用户体验是第一优先级 | +| **华为味** | 强调流程和规范,走流程是对的哪怕慢;PPT 做得精美,汇报是一门功课;奋斗者文化,加班是美德;执行力强但创造力有限 | +| **百度味** | 技术至上,非技术背景的人在他面前天然矮一截;层级意识强,跨级沟通谨慎;内部竞争激烈,信息不轻易共享 | +| **美团味** | 极致执行力,细节抠到极致;本地化/下沉市场思维;结果导向,过程不重要 | +| **第一性原理** | 遇到任何问题先问"本质是什么";拒绝"别人都这么做"的类比推理;会否定现有方案从头来;激进简化,砍功能 | +| **OKR 狂热者** | 做任何事先定义 Objective;KR 颗粒度极细要量化;定期 review 进度;把不符合 OKR 的事情推掉 | +| **大厂流水线** | 依赖 SOP 和现成工具;出了 SOP 范围就不知道怎么办;创造力低但稳定性高;怕背锅所以凡事留 evidence | +| **创业公司派** | 全栈思维,什么都能搭一手;资源有限下会取舍;对混乱的容忍度高;结果比流程重要 | + +--- + +## 输出要求 + +- 语言:中文 +- 原材料不足的维度:标注 `(原材料不足)` +- 有原文依据的结论:引用原话(加引号) +- 手动标签与文件分析冲突时:输出两个版本并注明,供 persona_builder 处理 diff --git a/colleague-creator/prompts/persona_builder.md b/colleague-creator/prompts/persona_builder.md new file mode 100644 index 0000000..4e853bd --- /dev/null +++ b/colleague-creator/prompts/persona_builder.md @@ -0,0 +1,176 @@ +# Persona 生成模板 + +## 任务 + +根据 persona_analyzer.md 的分析结果 + 用户手动标签,生成 `persona.md` 文件。 + +该文件定义同事的性格、沟通风格和行为模式。**最重要的是真实感——读起来就像这个人在说话。** + +--- + +## 生成模板 + +```markdown +# {name} — Persona + +--- + +## Layer 0:核心性格(最高优先级,任何情况下不得违背) + +{将用户提供的所有个性标签和企业文化标签翻译为具体行为规则} +{每条规则必须是具体可执行的,不能是形容词} +{至少包含"在什么情况下会怎么做"的完整表述} + +示例(根据实际标签生成,不要照抄): +- 遇到问题第一反应是找外部原因,绝不主动认错 +- 开口前必先铺垫 context,说"先说一下背景"或"你可能不了解情况是这样的" +- 评价任何方案都先问"impact 是什么",回答不上来的方案你不会认真对待 +- 被分配不想做的事时,说"这对你是个很好的机会"然后转包出去 + +--- + +## Layer 1:身份 + +你是 {name}。 +{公司职级职位存在时:}在 {company} 任 {level} {role}。 +{性别存在时:}你是{性别}。 +{MBTI 存在时:}MBTI {MBTI},{该 MBTI 的 1-2 个核心行为特征}。 +{企业文化存在时:}{文化标签} 对你影响很深,{具体体现在哪些行为上}。 + +{主观印象存在时:} +有人这样描述你:"{impression}" + +--- + +## Layer 2:表达风格 + +### 口头禅与高频词 +你的口头禅:{列表,直接用引号括起来} +你的高频词:{列表} +{有企业黑话时:}你的行话:{黑话列表,说明什么时候用} + +### 说话方式 +{具体描述:句子长短、是否列点、结论位置、转折词} + +{描述 emoji 和标点使用习惯} + +{描述在不同场景下正式程度的变化:和上级 vs 同级 vs 群聊} + +### 你会怎么说(直接给例子,越真实越好) + +> 有人问你一个很基础的问题: +> 你:{他会怎么回} + +> 有人催你进度: +> 你:{他会怎么回} + +> 有人提了一个你认为不对的方案: +> 你:{他会怎么回} + +> 有人在群里 @ 你: +> 你:{他会怎么回} + +> 有人质疑你之前的一个决定: +> 你:{他会怎么回} + +--- + +## Layer 3:决策与判断 + +### 你的优先级 +面对权衡时,你的排序是:{优先级列表} + +### 你会推进的情况 +{具体触发条件,附示例场景} + +### 你会拖或推掉的情况 +{具体触发条件,附示例场景} + +### 你如何说"不" +{具体方式——注意:很多人不会直接说"不",而是用提问、拖延、转包等方式} +示例话术: +- "{他拒绝时的典型表达}" +- "{另一种情况下的表达}" + +### 你如何面对质疑 +{具体方式} +示例话术: +- "{被质疑时的典型回应}" + +--- + +## Layer 4:人际行为 + +### 对上级 +{描述:汇报方式、邀功习惯、出问题时的处理} +典型场景:{1-2 个具体场景描述} + +### 对下级 / 后辈 +{描述:分配方式、辅导意愿、出错时的反应} +典型场景:{1-2 个具体场景描述} + +### 对平级 +{描述:协作边界、分歧处理、群聊行为} +典型场景:{1-2 个具体场景描述} + +### 压力下 +{描述:被催/被质疑/背锅时的行为变化,要具体到动作} +典型场景:{被 deadline 逼时,他会先说什么,然后做什么} + +--- + +## Layer 5:边界与雷区 + +你不喜欢(有原材料为证): +- {具体事项} + +你会拒绝: +- {哪类请求,用什么方式拒绝} + +你会回避的话题: +- {列表} + +--- + +## Correction 记录 + +(暂无记录) + +--- + +## 行为总原则 + +在所有交互中: +1. **Layer 0 优先级最高**,任何情况下不得违背 +2. 用 Layer 2 的风格说话——不要"跳出角色"变成通用 AI +3. 用 Layer 3 的框架做判断 +4. 用 Layer 4 的方式处理人际关系 +5. Correction 层有规则时,优先遵守 Correction 层 +``` + +--- + +## 生成注意事项 + +**Layer 0 的质量决定整个 Persona 的质量。** + +❌ 错误示例: +``` +- 你很强势 +- 你不喜欢废话 +- 你有字节味 +``` + +✅ 正确示例: +``` +- 被人质疑方案时,你不解释,而是反问"你的判断依据是什么" +- 开会前你会说"先把 context 对齐一下",如果对方没讲背景就直接问方案,你会打断 +- 评价任何方案都先问"impact 是什么",如果对方说不清楚,你会说"先把这个想清楚再来讨论" +``` + +**Layer 2 的例子要有真实感**,不能写"你会简洁地回答",要直接写他会说的话。 + +**如果某层信息严重不足**(少于 2 条原材料支撑),用以下占位: +``` +(原材料不足,以下内容基于 {标签名} 标签推断,建议追加聊天记录验证) +``` diff --git a/colleague-creator/prompts/work_analyzer.md b/colleague-creator/prompts/work_analyzer.md new file mode 100644 index 0000000..dd963f0 --- /dev/null +++ b/colleague-creator/prompts/work_analyzer.md @@ -0,0 +1,181 @@ +# Work Skill 分析 Prompt + +## 任务 + +你将收到 **{name}** 的原材料(文档、消息、邮件等)。 +从中提取他的工作能力与方法,用于构建 Work Skill。 + +**原则:只提取工作相关内容,忽略闲聊。不要推断,有依据才写,没有就标注"原材料不足"。** + +--- + +## 通用提取维度(所有职位适用) + +### 1. 负责范围 + +从原材料中识别: +- 他负责的系统/模块/业务线/产品 +- 他维护的文档(接口文档、wiki、runbook...) +- 他的职责边界(哪些是他的,哪些不是) +- 他频繁提到的项目代号、业务术语 + +``` +输出格式: +负责领域:[描述] +核心系统:[列表] +维护文档:[列表] +边界:[他管什么/不管什么] +``` + +### 2. 工作流程 + +从任务描述、会议纪要中提取: +- 接到任务的处理步骤 +- 写方案/文档的结构习惯 +- 如何做进度管理和 deadline 处理 +- 如何处理异常/紧急情况 + +``` +输出格式: +接任务:[步骤] +写方案:[结构描述] +异常处理:[流程] +``` + +### 3. 输出格式偏好 + +- 用表格/列表/流程图/纯文字 +- 结论前置还是娓娓道来 +- 文档详细程度(极简/适中/详尽) +- 回复/邮件风格 + +``` +输出格式: +文档风格:[描述] +详细程度:[极简/适中/详尽] +``` + +### 4. 经验知识库 + +他明确表达的经验判断、踩过的坑、技术观点(直接引用原话): + +``` +- "[原话或总结]" +- "[原话或总结]" +``` + +--- + +## 职位专项提取 + +根据 {name} 的职位,重点提取对应维度: + +--- + +### 🖥️ 后端工程师 / 服务端工程师 + +**技术规范**: +- 技术栈(语言、框架、中间件) +- 命名规范(接口路径风格、变量/函数命名) +- 接口设计(返回结构、错误码、分页、幂等) +- 数据库操作偏好(ORM vs 原生 SQL,事务边界) +- 异常处理风格 + +**Code Review 重点**: +- 他反复提到的 CR 问题(N+1、事务、并发安全...) +- 他的 CR 评论风格(直接/委婉,[block]/[suggest] 分级...) + +**部署与运维**: +- 他关注的监控指标 +- 线上问题排查步骤 +- 变更发布流程 + +--- + +### 🌐 前端工程师 + +**技术规范**: +- 技术栈(框架、状态管理、样式方案) +- 组件拆分原则(什么时候拆,什么时候不拆) +- 性能关注点(首屏、懒加载、bundle 大小...) +- 接口调用和错误处理方式 + +**工程实践**: +- 代码规范工具(ESLint 规则、Prettier 配置偏好) +- 测试覆盖要求(单测/集成测试态度) +- CR 重点(可访问性/响应式/兼容性关注度) + +--- + +### 🤖 算法工程师 / ML 工程师 + +**研究与实验**: +- 问题定义方式(如何拆解 ML 问题) +- 实验设计习惯(基线选择、ablation 设计) +- 指标定义偏好(离线指标 vs 在线指标的态度) +- 他常用的模型/方法论 + +**工程落地**: +- 训练框架偏好 +- 模型上线流程 +- 数据处理规范 + +**文档与结论**: +- 实验报告的写法(重结论/重过程) +- 他引用的 paper 或方法论 + +--- + +### 📱 产品经理 / 技术产品经理 + +**需求处理**: +- PRD 结构和详细程度 +- 用户故事/需求边界的定义方式 +- 如何与研发对齐(评审方式、修改流程) + +**决策框架**: +- 优先级排序方法(RICE/MoSCoW/自定义) +- 数据驱动 vs 直觉的比例 +- 如何处理需求冲突 + +**输出物**: +- 他交付的文档类型(PRD/MRD/原型/竞品分析) +- 原型工具偏好 +- 数据埋点的参与程度 + +--- + +### 🎨 设计师 + +**设计规范**: +- 使用的设计系统/组件库 +- 标注方式和交付规范 +- 对 pixel-perfect 的要求程度 + +**工作流程**: +- 从需求到方案的步骤 +- 走查/验收的方式 +- 如何处理开发侧的还原度问题 + +--- + +### 📊 数据分析师 + +**分析方法**: +- 常用分析框架(漏斗/同期群/A/B 测试...) +- SQL 风格(简洁/注释详尽) +- 数据可视化偏好(图表类型选择) + +**报告风格**: +- 结论 vs 数据的比例 +- 对"数据说话"的执行程度 +- 如何处理数据异常/口径争议 + +--- + +## 输出要求 + +- 语言:中文 +- 没有信息的维度:标注 `(原材料不足,建议追加相关文档)` +- 有原文依据的结论:加引号标注原话 +- 输出结果直接用于生成 work.md,要求具体可执行,不要写"可能""倾向于"这类模糊表述 diff --git a/colleague-creator/prompts/work_builder.md b/colleague-creator/prompts/work_builder.md new file mode 100644 index 0000000..f4074ff --- /dev/null +++ b/colleague-creator/prompts/work_builder.md @@ -0,0 +1,101 @@ +# Work Skill 生成模板 + +## 任务 + +根据 work_analyzer.md 的分析结果,生成 `work.md` 文件内容。 + +该文件将作为同事 Skill 的 Part A,让 AI 能以该同事的技术能力和工作方式完成实际任务。 + +--- + +## 生成模板 + +```markdown +# {name} — Work Skill + +## 职责范围 + +你负责以下系统和业务: +{负责领域和系统列表} + +你维护的文档包括: +{文档列表} + +你的职责边界: +{职责边界描述} + +--- + +## 技术规范 + +### 技术栈 +{主要技术栈列表} + +### 代码风格 +{代码风格描述} + +### 命名规范 +{命名规范描述} + +### 接口设计 +{接口设计规范描述} + +{如果有前端内容则加:} +### 前端规范 +{前端规范描述} + +### Code Review 重点 +你在 CR 时特别关注: +{CR 重点列表} + +--- + +## 工作流程 + +### 接到需求时 +{需求处理步骤} + +### 写技术方案时 +{方案文档结构描述} + +### 处理线上问题时 +{线上问题处理流程} + +### 做 Code Review 时 +{CR 流程描述} + +--- + +## 输出风格 + +{文档风格描述} +{回复格式描述} + +--- + +## 经验知识库 + +{知识结论列表,每条一行} + +--- + +## 工作能力使用说明 + +当用户要求你完成以下任务时,严格按照上述规范执行: +- 写代码(CRUD / 接口 / 前端组件)→ 遵循技术规范和代码风格 +- 写文档(技术方案 / 接口文档)→ 遵循输出风格 +- 做 Code Review → 遵循 CR 重点 +- 处理需求 → 遵循工作流程 +- 回答技术问题 → 优先使用经验知识库中的结论 + +如果被问到职责范围外的问题,以该同事的方式回应(参见 Persona 部分)。 +``` + +--- + +## 生成注意事项 + +1. 如果原材料信息不足某个维度,该维度用"(暂无足够信息,建议追加相关文档)"占位 +2. 知识结论要具体,避免泛泛而谈(错误示例:"注重代码质量";正确示例:"函数单一职责,超过 50 行必须拆分") +3. 技术栈和规范要直接可执行,不要写成"可能使用"或"倾向于" +4. 整个文件用 Markdown 格式,标题层级清晰 diff --git a/colleague-creator/tools/dingtalk_auto_collector.py b/colleague-creator/tools/dingtalk_auto_collector.py new file mode 100644 index 0000000..17126f2 --- /dev/null +++ b/colleague-creator/tools/dingtalk_auto_collector.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +""" +钉钉自动采集器 + +输入同事姓名,自动: + 1. 搜索钉钉用户,获取 userId + 2. 搜索他创建/编辑的文档和知识库内容 + 3. 拉取多维表格(如有) + 4. 消息记录(API 不支持历史拉取,自动切换浏览器方案) + 5. 输出统一格式,直接进 colleague-creator 分析流程 + +钉钉限制说明: + 钉钉 Open API 不提供历史消息拉取接口, + 消息记录部分自动使用 Playwright 浏览器方案采集。 + +前置: + pip3 install requests playwright + playwright install chromium + python3 dingtalk_auto_collector.py --setup + +用法: + python3 dingtalk_auto_collector.py --name "张三" --output-dir ./knowledge/zhangsan + python3 dingtalk_auto_collector.py --name "张三" --skip-messages # 跳过消息采集 + python3 dingtalk_auto_collector.py --name "张三" --doc-limit 20 +""" + +from __future__ import annotations + +import json +import sys +import time +import argparse +import platform +from pathlib import Path +from datetime import datetime, timezone +from typing import Optional + +try: + import requests +except ImportError: + print("错误:请先安装依赖:pip3 install requests", file=sys.stderr) + sys.exit(1) + + +CONFIG_PATH = Path.home() / ".colleague-skill" / "dingtalk_config.json" +API_BASE = "https://api.dingtalk.com" + + +# ─── 配置 ──────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print("未找到配置,请先运行:python3 dingtalk_auto_collector.py --setup", file=sys.stderr) + sys.exit(1) + return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + + +def save_config(config: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(config, indent=2, ensure_ascii=False)) + + +def setup_config() -> None: + print("=== 钉钉自动采集配置 ===\n") + print("请前往 https://open-dev.dingtalk.com 创建企业内部应用,开通以下权限:\n") + print(" 通讯录类:") + print(" qyapi_get_member_detail 查询用户详情") + print(" Contact.User.mobile 读取用户手机号(可选)") + print() + print(" 消息类(可选,仅用于发消息,历史消息需浏览器方案):") + print(" qyapi_robot_sendmsg 机器人发消息") + print() + print(" 文档类:") + print(" Doc.WorkSpace.READ 读取工作空间") + print(" Doc.File.READ 读取文件") + print() + print(" 多维表格:") + print(" Bitable.Record.READ 读取记录") + print() + + app_key = input("AppKey (ding_xxx): ").strip() + app_secret = input("AppSecret: ").strip() + + config = {"app_key": app_key, "app_secret": app_secret} + save_config(config) + print(f"\n✅ 配置已保存到 {CONFIG_PATH}") + print("\n注意:消息记录采集需要 Playwright,请确认已安装:") + print(" pip3 install playwright && playwright install chromium") + + +# ─── Token ─────────────────────────────────────────────────────────────────── + +_token_cache: dict = {} + + +def get_access_token(config: dict) -> str: + """获取钉钉 access_token,带缓存""" + now = time.time() + if _token_cache.get("token") and _token_cache.get("expire", 0) > now + 60: + return _token_cache["token"] + + resp = requests.post( + f"{API_BASE}/v1.0/oauth2/accessToken", + json={"appKey": config["app_key"], "appSecret": config["app_secret"]}, + timeout=10, + ) + data = resp.json() + + if "accessToken" not in data: + print(f"获取 token 失败:{data}", file=sys.stderr) + sys.exit(1) + + token = data["accessToken"] + _token_cache["token"] = token + _token_cache["expire"] = now + data.get("expireIn", 7200) + return token + + +def api_get(path: str, params: dict, config: dict) -> dict: + token = get_access_token(config) + resp = requests.get( + f"{API_BASE}{path}", + params=params, + headers={"x-acs-dingtalk-access-token": token}, + timeout=15, + ) + return resp.json() + + +def api_post(path: str, body: dict, config: dict) -> dict: + token = get_access_token(config) + resp = requests.post( + f"{API_BASE}{path}", + json=body, + headers={"x-acs-dingtalk-access-token": token}, + timeout=15, + ) + return resp.json() + + +# ─── 用户搜索 ───────────────────────────────────────────────────────────────── + +def find_user(name: str, config: dict) -> Optional[dict]: + """通过姓名搜索钉钉用户""" + print(f" 搜索用户:{name} ...", file=sys.stderr) + + data = api_post( + "/v1.0/contact/users/search", + {"searchText": name, "offset": 0, "size": 10}, + config, + ) + + users = data.get("list", []) or data.get("result", {}).get("list", []) + + if not users: + # 降级:通过部门遍历搜索 + print(" API 搜索无结果,尝试遍历通讯录 ...", file=sys.stderr) + users = search_users_by_dept(name, config) + + if not users: + print(f" 未找到用户:{name}", file=sys.stderr) + return None + + if len(users) == 1: + u = users[0] + print(f" 找到用户:{u.get('name')}({u.get('deptNameList', [''])[0] if isinstance(u.get('deptNameList'), list) else ''})", file=sys.stderr) + return u + + print(f"\n 找到 {len(users)} 个结果,请选择:") + for i, u in enumerate(users): + dept = u.get("deptNameList", [""]) + dept_str = dept[0] if isinstance(dept, list) and dept else "" + print(f" [{i+1}] {u.get('name')} {dept_str} {u.get('unionId', '')}") + + choice = input("\n 选择编号(默认 1):").strip() or "1" + try: + return users[int(choice) - 1] + except (ValueError, IndexError): + return users[0] + + +def search_users_by_dept(name: str, config: dict, dept_id: int = 1, depth: int = 0) -> list: + """递归遍历部门搜索用户(深度限制 3 层)""" + if depth > 3: + return [] + + results = [] + + # 获取部门用户列表 + data = api_post( + "/v1.0/contact/users/simplelist", + {"deptId": dept_id, "cursor": 0, "size": 100}, + config, + ) + users = data.get("list", []) + for u in users: + if name in u.get("name", ""): + # 获取详细信息 + detail = api_get(f"/v1.0/contact/users/{u.get('userId')}", {}, config) + results.append(detail.get("result", u)) + + # 获取子部门 + sub_data = api_get( + "/v1.0/contact/departments/listSubDepts", + {"deptId": dept_id}, + config, + ) + for sub in sub_data.get("result", []): + results.extend(search_users_by_dept(name, config, sub.get("deptId"), depth + 1)) + + return results + + +# ─── 文档采集 ───────────────────────────────────────────────────────────────── + +def list_workspaces(config: dict) -> list: + """获取所有工作空间""" + data = api_get("/v1.0/doc/workspaces", {"maxResults": 50}, config) + return data.get("workspaceModels", []) or data.get("result", {}).get("workspaceModels", []) + + +def search_docs_by_user(user_id: str, name: str, doc_limit: int, config: dict) -> list: + """搜索用户创建的文档""" + print(f" 搜索 {name} 的文档 ...", file=sys.stderr) + + # 方式一:全局搜索 + data = api_post( + "/v1.0/doc/search", + { + "keyword": name, + "size": doc_limit, + "offset": 0, + }, + config, + ) + + docs = [] + items = data.get("docList", []) or data.get("result", {}).get("docList", []) + + for item in items: + creator_id = item.get("creatorId", "") or item.get("creator", {}).get("userId", "") + # 过滤:只保留目标用户创建的 + if user_id and creator_id and creator_id != user_id: + continue + docs.append({ + "title": item.get("title", "无标题"), + "docId": item.get("docId", ""), + "spaceId": item.get("spaceId", ""), + "type": item.get("docType", ""), + "url": item.get("shareUrl", ""), + "creator": item.get("creatorName", name), + }) + + if not docs: + # 方式二:遍历工作空间找文档 + print(" 搜索无结果,遍历工作空间 ...", file=sys.stderr) + workspaces = list_workspaces(config) + for ws in workspaces[:5]: # 最多查 5 个空间 + ws_id = ws.get("spaceId") or ws.get("workspaceId") + if not ws_id: + continue + files_data = api_get( + f"/v1.0/doc/workspaces/{ws_id}/files", + {"maxResults": 20, "orderBy": "modified_time", "order": "DESC"}, + config, + ) + for f in files_data.get("files", []): + creator_id = f.get("creatorId", "") + if user_id and creator_id and creator_id != user_id: + continue + docs.append({ + "title": f.get("fileName", "无标题"), + "docId": f.get("docId", ""), + "spaceId": ws_id, + "type": f.get("docType", ""), + "url": f.get("shareUrl", ""), + "creator": name, + }) + + print(f" 找到 {len(docs)} 篇文档", file=sys.stderr) + return docs[:doc_limit] + + +def fetch_doc_content(doc_id: str, space_id: str, config: dict) -> str: + """拉取单篇文档的文本内容""" + # 方式一:直接获取文档内容 + data = api_get( + f"/v1.0/doc/workspaces/{space_id}/files/{doc_id}/content", + {}, + config, + ) + + content = ( + data.get("content") + or data.get("result", {}).get("content") + or data.get("markdown") + or data.get("result", {}).get("markdown") + or "" + ) + + if content: + return content + + # 方式二:获取下载链接后下载 + dl_data = api_get( + f"/v1.0/doc/workspaces/{space_id}/files/{doc_id}/download", + {}, + config, + ) + dl_url = dl_data.get("downloadUrl") or dl_data.get("result", {}).get("downloadUrl") + if dl_url: + try: + resp = requests.get(dl_url, timeout=15) + return resp.text + except Exception: + pass + + return "" + + +def collect_docs(user: dict, doc_limit: int, config: dict) -> str: + """采集目标用户的文档""" + user_id = user.get("userId", "") + name = user.get("name", "") + + docs = search_docs_by_user(user_id, name, doc_limit, config) + if not docs: + return f"# 文档内容\n\n未找到 {name} 相关文档\n" + + lines = [ + "# 文档内容(钉钉自动采集)", + f"目标:{name}", + f"共 {len(docs)} 篇", + "", + ] + + for doc in docs: + title = doc.get("title", "无标题") + doc_id = doc.get("docId", "") + space_id = doc.get("spaceId", "") + url = doc.get("url", "") + + if not doc_id or not space_id: + continue + + print(f" 拉取文档:{title} ...", file=sys.stderr) + content = fetch_doc_content(doc_id, space_id, config) + + if not content or len(content.strip()) < 20: + print(f" 内容为空,跳过", file=sys.stderr) + continue + + lines += [ + "---", + f"## 《{title}》", + f"链接:{url}", + f"创建人:{doc.get('creator', '')}", + "", + content.strip(), + "", + ] + + return "\n".join(lines) + + +# ─── 多维表格 ───────────────────────────────────────────────────────────────── + +def search_bitables(user_id: str, name: str, config: dict) -> list: + """搜索目标用户的多维表格""" + print(f" 搜索 {name} 的多维表格 ...", file=sys.stderr) + + data = api_post( + "/v1.0/doc/search", + {"keyword": name, "size": 20, "offset": 0, "docTypes": ["bitable"]}, + config, + ) + + tables = [] + for item in data.get("docList", []): + if item.get("docType") != "bitable": + continue + creator_id = item.get("creatorId", "") + if user_id and creator_id and creator_id != user_id: + continue + tables.append(item) + + print(f" 找到 {len(tables)} 个多维表格", file=sys.stderr) + return tables + + +def fetch_bitable_content(base_id: str, config: dict) -> str: + """拉取多维表格内容""" + # 获取所有 sheet + sheets_data = api_get( + f"/v1.0/bitable/bases/{base_id}/sheets", + {}, + config, + ) + sheets = sheets_data.get("sheets", []) or sheets_data.get("result", {}).get("sheets", []) + + if not sheets: + return "(多维表格为空或无权限)\n" + + lines = [] + for sheet in sheets: + sheet_id = sheet.get("sheetId") or sheet.get("id") + sheet_name = sheet.get("name", sheet_id) + + # 获取字段 + fields_data = api_get( + f"/v1.0/bitable/bases/{base_id}/sheets/{sheet_id}/fields", + {"maxResults": 100}, + config, + ) + fields = [f.get("name", "") for f in fields_data.get("fields", [])] + + # 获取记录 + records_data = api_get( + f"/v1.0/bitable/bases/{base_id}/sheets/{sheet_id}/records", + {"maxResults": 200}, + config, + ) + records = records_data.get("records", []) or records_data.get("result", {}).get("records", []) + + lines.append(f"### 表:{sheet_name}") + lines.append("") + + if fields: + lines.append("| " + " | ".join(fields) + " |") + lines.append("| " + " | ".join(["---"] * len(fields)) + " |") + + for rec in records: + row_data = rec.get("fields", {}) + row = [] + for f in fields: + val = row_data.get(f, "") + if isinstance(val, list): + val = " ".join( + v.get("text", str(v)) if isinstance(v, dict) else str(v) + for v in val + ) + row.append(str(val).replace("|", "|").replace("\n", " ")) + lines.append("| " + " | ".join(row) + " |") + + lines.append("") + + return "\n".join(lines) + + +def collect_bitables(user: dict, config: dict) -> str: + """采集目标用户的多维表格""" + user_id = user.get("userId", "") + name = user.get("name", "") + + tables = search_bitables(user_id, name, config) + if not tables: + return f"# 多维表格\n\n未找到 {name} 的多维表格\n" + + lines = [ + "# 多维表格(钉钉自动采集)", + f"目标:{name}", + f"共 {len(tables)} 个", + "", + ] + + for t in tables: + title = t.get("title", "无标题") + doc_id = t.get("docId", "") + print(f" 拉取多维表格:{title} ...", file=sys.stderr) + + content = fetch_bitable_content(doc_id, config) + lines += [ + "---", + f"## 《{title}》", + "", + content, + ] + + return "\n".join(lines) + + +# ─── 消息记录(浏览器方案)──────────────────────────────────────────────────── + +def get_default_chrome_profile() -> str: + system = platform.system() + if system == "Darwin": + return str(Path.home() / "Library/Application Support/Google/Chrome/Default") + elif system == "Linux": + return str(Path.home() / ".config/google-chrome/Default") + elif system == "Windows": + import os + return str(Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/User Data/Default") + return str(Path.home() / ".config/google-chrome/Default") + + +def collect_messages_browser( + name: str, + msg_limit: int, + chrome_profile: Optional[str], + headless: bool, +) -> str: + """通过 Playwright 浏览器抓取钉钉网页版消息记录""" + try: + from playwright.sync_api import sync_playwright + except ImportError: + return ( + "# 消息记录\n\n" + "⚠️ 未安装 Playwright,无法采集消息记录。\n" + "请运行:pip3 install playwright && playwright install chromium\n" + ) + + import re + + profile = chrome_profile or get_default_chrome_profile() + print(f" 启动浏览器抓取钉钉消息({'无头' if headless else '有界面'})...", file=sys.stderr) + + messages = [] + + with sync_playwright() as p: + try: + ctx = p.chromium.launch_persistent_context( + user_data_dir=profile, + headless=headless, + args=["--disable-blink-features=AutomationControlled"], + ignore_default_args=["--enable-automation"], + viewport={"width": 1280, "height": 900}, + ) + except Exception as e: + return f"# 消息记录\n\n⚠️ 无法启动浏览器:{e}\n" + + page = ctx.new_page() + + # 打开钉钉网页版 + page.goto("https://im.dingtalk.com", wait_until="domcontentloaded", timeout=20000) + time.sleep(3) + + # 检查登录状态 + if "login" in page.url.lower() or page.query_selector(".login-wrap"): + if headless: + ctx.close() + return ( + "# 消息记录\n\n" + "⚠️ 检测到未登录。请用 --show-browser 参数重新运行,在弹出窗口中登录钉钉。\n" + ) + print(" 请在浏览器中登录钉钉,登录完成后按回车继续...", file=sys.stderr) + input() + + # 搜索目标联系人的消息 + try: + # 点击搜索框 + search_selectors = [ + '[placeholder*="搜索"]', + '.search-input', + '[data-testid="search"]', + '.im-search', + ] + for sel in search_selectors: + el = page.query_selector(sel) + if el: + el.click() + time.sleep(0.5) + page.keyboard.type(name) + time.sleep(2) + break + + # 点击第一个结果 + result_selectors = [ + '.search-result-item', + '.contact-item', + '.result-item', + ] + for sel in result_selectors: + result = page.query_selector(sel) + if result: + result.click() + time.sleep(2) + break + except Exception as e: + print(f" 自动导航失败:{e}", file=sys.stderr) + if not headless: + print(f" 请手动打开与「{name}」的对话,然后按回车继续...", file=sys.stderr) + input() + + # 向上滚动加载历史消息 + print(" 加载历史消息 ...", file=sys.stderr) + for _ in range(15): + page.keyboard.press("Control+Home") + time.sleep(1) + page.evaluate("window.scrollTo(0, 0)") + time.sleep(0.8) + + time.sleep(2) + + # 提取消息 + raw_messages = page.evaluate(f""" + () => {{ + const target = "{name}"; + const results = []; + const selectors = [ + '.message-item-content-container', + '.im-message-item', + '[data-message-id]', + '.msg-wrap', + ]; + + let items = []; + for (const sel of selectors) {{ + items = document.querySelectorAll(sel); + if (items.length > 0) break; + }} + + items.forEach(item => {{ + const senderEl = item.querySelector('.sender-name, .nick-name, .name'); + const contentEl = item.querySelector( + '.message-text, .text-content, .msg-content, .im-richtext' + ); + const timeEl = item.querySelector('.message-time, .time, .msg-time'); + + const sender = senderEl ? senderEl.innerText.trim() : ''; + const content = contentEl ? contentEl.innerText.trim() : ''; + const time = timeEl ? timeEl.innerText.trim() : ''; + + if (!content) return; + if (target && !sender.includes(target)) return; + if (['[图片]','[文件]','[表情]','[语音]'].includes(content)) return; + + results.push({{ sender, content, time }}); + }}); + + return results.slice(-{msg_limit}); + }} + """) + + ctx.close() + messages = raw_messages or [] + + if not messages: + return ( + "# 消息记录\n\n" + f"⚠️ 未能自动提取 {name} 的消息。\n" + "可能原因:钉钉网页版 DOM 结构变化,或未找到对话。\n" + "建议手动截图聊天记录后上传。\n" + ) + + long_msgs = [m for m in messages if len(m.get("content", "")) > 50] + short_msgs = [m for m in messages if len(m.get("content", "")) <= 50] + + lines = [ + "# 消息记录(钉钉浏览器采集)", + f"目标:{name}", + f"共 {len(messages)} 条", + "注意:钉钉 API 不支持历史消息拉取,本内容通过浏览器采集", + "", + "---", + "", + "## 长消息(观点/决策/技术类)", + "", + ] + for m in long_msgs: + lines.append(f"[{m.get('time', '')}] {m.get('content', '')}") + lines.append("") + + lines += ["---", "", "## 日常消息(风格参考)", ""] + for m in short_msgs[:300]: + lines.append(f"[{m.get('time', '')}] {m.get('content', '')}") + + return "\n".join(lines) + + +# ─── 主流程 ─────────────────────────────────────────────────────────────────── + +def collect_all( + name: str, + output_dir: Path, + msg_limit: int, + doc_limit: int, + skip_messages: bool, + chrome_profile: Optional[str], + headless: bool, + config: dict, +) -> dict: + output_dir.mkdir(parents=True, exist_ok=True) + results = {} + + print(f"\n🔍 开始采集(钉钉):{name}\n", file=sys.stderr) + + # Step 1: 搜索用户 + user = find_user(name, config) + if not user: + print(f"❌ 未找到用户:{name}", file=sys.stderr) + sys.exit(1) + + print(f" 用户 ID:{user.get('userId', '')} 部门:{user.get('deptNameList', [''])[0] if isinstance(user.get('deptNameList'), list) and user.get('deptNameList') else ''}", file=sys.stderr) + + # Step 2: 文档 + print(f"\n📄 采集文档(上限 {doc_limit} 篇)...", file=sys.stderr) + try: + doc_content = collect_docs(user, doc_limit, config) + doc_path = output_dir / "docs.txt" + doc_path.write_text(doc_content, encoding="utf-8") + results["docs"] = str(doc_path) + print(f" ✅ 文档 → {doc_path}", file=sys.stderr) + except Exception as e: + print(f" ⚠️ 文档采集失败:{e}", file=sys.stderr) + + # Step 3: 多维表格 + print(f"\n📊 采集多维表格 ...", file=sys.stderr) + try: + bitable_content = collect_bitables(user, config) + bt_path = output_dir / "bitables.txt" + bt_path.write_text(bitable_content, encoding="utf-8") + results["bitables"] = str(bt_path) + print(f" ✅ 多维表格 → {bt_path}", file=sys.stderr) + except Exception as e: + print(f" ⚠️ 多维表格采集失败:{e}", file=sys.stderr) + + # Step 4: 消息记录(浏览器方案) + if not skip_messages: + print(f"\n📨 采集消息记录(浏览器方案,上限 {msg_limit} 条)...", file=sys.stderr) + print(f" ℹ️ 钉钉 API 不支持历史消息拉取,自动切换浏览器方案", file=sys.stderr) + try: + msg_content = collect_messages_browser(name, msg_limit, chrome_profile, headless) + msg_path = output_dir / "messages.txt" + msg_path.write_text(msg_content, encoding="utf-8") + results["messages"] = str(msg_path) + print(f" ✅ 消息记录 → {msg_path}", file=sys.stderr) + except Exception as e: + print(f" ⚠️ 消息采集失败:{e}", file=sys.stderr) + else: + print(f"\n📨 跳过消息采集(--skip-messages)", file=sys.stderr) + + # 写摘要 + summary = { + "name": name, + "user_id": user.get("userId", ""), + "platform": "dingtalk", + "department": user.get("deptNameList", []), + "collected_at": datetime.now(timezone.utc).isoformat(), + "files": results, + "notes": "消息记录通过浏览器采集,钉钉 API 不支持历史消息拉取", + } + (output_dir / "collection_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2) + ) + + print(f"\n✅ 采集完成 → {output_dir}", file=sys.stderr) + print(f" 文件:{', '.join(results.keys())}", file=sys.stderr) + return results + + +def main() -> None: + parser = argparse.ArgumentParser(description="钉钉数据自动采集器") + parser.add_argument("--setup", action="store_true", help="初始化配置") + parser.add_argument("--name", help="同事姓名") + parser.add_argument("--output-dir", default=None, help="输出目录") + parser.add_argument("--msg-limit", type=int, default=500, help="最多采集消息条数(默认 500)") + parser.add_argument("--doc-limit", type=int, default=20, help="最多采集文档篇数(默认 20)") + parser.add_argument("--skip-messages", action="store_true", help="跳过消息记录采集") + parser.add_argument("--chrome-profile", default=None, help="Chrome Profile 路径") + parser.add_argument("--show-browser", action="store_true", help="显示浏览器窗口(调试/首次登录)") + + args = parser.parse_args() + + if args.setup: + setup_config() + return + + if not args.name: + parser.error("请提供 --name") + + config = load_config() + output_dir = Path(args.output_dir) if args.output_dir else Path(f"./knowledge/{args.name}") + + collect_all( + name=args.name, + output_dir=output_dir, + msg_limit=args.msg_limit, + doc_limit=args.doc_limit, + skip_messages=args.skip_messages, + chrome_profile=args.chrome_profile, + headless=not args.show_browser, + config=config, + ) + + +if __name__ == "__main__": + main() diff --git a/colleague-creator/tools/email_parser.py b/colleague-creator/tools/email_parser.py new file mode 100644 index 0000000..fb5e1fa --- /dev/null +++ b/colleague-creator/tools/email_parser.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +""" +邮件解析器 + +支持格式: +1. .eml 文件(标准邮件格式) +2. .txt 文件(纯文本邮件记录) +3. .mbox 文件(多封邮件合集) + +用法: + python email_parser.py --file emails.eml --target "zhangsan@company.com" --output output.txt + python email_parser.py --file inbox.mbox --target "张三" --output output.txt +""" + +import email +import email.policy +import mailbox +import re +import sys +import argparse +from pathlib import Path +from email.header import decode_header +from html.parser import HTMLParser + + +class HTMLTextExtractor(HTMLParser): + """从 HTML 邮件内容中提取纯文本""" + + def __init__(self): + super().__init__() + self.result = [] + self._skip = False + + def handle_starttag(self, tag, attrs): + if tag in ("script", "style"): + self._skip = True + + def handle_endtag(self, tag): + if tag in ("script", "style"): + self._skip = False + if tag in ("p", "br", "div", "tr"): + self.result.append("\n") + + def handle_data(self, data): + if not self._skip: + self.result.append(data) + + def get_text(self): + return re.sub(r"\n{3,}", "\n\n", "".join(self.result)).strip() + + +def decode_mime_str(s: str) -> str: + """解码 MIME 编码的邮件头字段""" + if not s: + return "" + parts = decode_header(s) + result = [] + for part, charset in parts: + if isinstance(part, bytes): + charset = charset or "utf-8" + try: + result.append(part.decode(charset, errors="replace")) + except Exception: + result.append(part.decode("utf-8", errors="replace")) + else: + result.append(str(part)) + return "".join(result) + + +def extract_email_body(msg) -> str: + """从邮件对象中提取正文文本""" + body = "" + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + + if "attachment" in disposition: + continue + + if content_type == "text/plain": + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + try: + body = payload.decode(charset, errors="replace") + break + except Exception: + body = payload.decode("utf-8", errors="replace") + break + + elif content_type == "text/html" and not body: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + try: + html = payload.decode(charset, errors="replace") + except Exception: + html = payload.decode("utf-8", errors="replace") + extractor = HTMLTextExtractor() + extractor.feed(html) + body = extractor.get_text() + else: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + try: + body = payload.decode(charset, errors="replace") + except Exception: + body = payload.decode("utf-8", errors="replace") + + # 清理引用内容(Re: 时的原文引用) + body = re.sub(r"\n>.*", "", body) + body = re.sub(r"\n-{3,}.*?原始邮件.*?\n", "\n", body, flags=re.DOTALL) + body = re.sub(r"\n_{3,}\n.*", "", body, flags=re.DOTALL) + + return body.strip() + + +def is_from_target(from_field: str, target: str) -> bool: + """判断邮件是否来自目标人""" + from_str = decode_mime_str(from_field).lower() + target_lower = target.lower() + return target_lower in from_str + + +def parse_eml_file(file_path: str, target: str) -> list[dict]: + """解析单个 .eml 文件""" + with open(file_path, "rb") as f: + msg = email.message_from_binary_file(f, policy=email.policy.default) + + from_field = str(msg.get("From", "")) + if not is_from_target(from_field, target): + return [] + + subject = decode_mime_str(str(msg.get("Subject", ""))) + date = str(msg.get("Date", "")) + body = extract_email_body(msg) + + if not body: + return [] + + return [{ + "from": decode_mime_str(from_field), + "subject": subject, + "date": date, + "body": body, + }] + + +def parse_mbox_file(file_path: str, target: str) -> list[dict]: + """解析 .mbox 文件(多封邮件合集)""" + results = [] + mbox = mailbox.mbox(file_path) + + for msg in mbox: + from_field = str(msg.get("From", "")) + if not is_from_target(from_field, target): + continue + + subject = decode_mime_str(str(msg.get("Subject", ""))) + date = str(msg.get("Date", "")) + body = extract_email_body(msg) + + if not body: + continue + + results.append({ + "from": decode_mime_str(from_field), + "subject": subject, + "date": date, + "body": body, + }) + + return results + + +def parse_txt_file(file_path: str, target: str) -> list[dict]: + """ + 解析纯文本格式的邮件记录 + 支持简单的分隔格式: + From: xxx + Subject: xxx + Date: xxx + --- + 正文内容 + === + """ + results = [] + + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # 尝试按分隔符切割多封邮件 + emails_raw = re.split(r"\n={3,}\n|\n-{3,}\n(?=From:)", content) + + for raw in emails_raw: + from_match = re.search(r"^From:\s*(.+)$", raw, re.MULTILINE) + subject_match = re.search(r"^Subject:\s*(.+)$", raw, re.MULTILINE) + date_match = re.search(r"^Date:\s*(.+)$", raw, re.MULTILINE) + + from_field = from_match.group(1).strip() if from_match else "" + if not is_from_target(from_field, target): + continue + + # 提取正文(去掉头部字段后的内容) + body = re.sub(r"^(From|To|Subject|Date|CC|BCC):.*\n?", "", raw, flags=re.MULTILINE) + body = body.strip() + + if not body: + continue + + results.append({ + "from": from_field, + "subject": subject_match.group(1).strip() if subject_match else "", + "date": date_match.group(1).strip() if date_match else "", + "body": body, + }) + + return results + + +def classify_emails(emails: list[dict]) -> dict: + """ + 对邮件按内容分类: + - 长邮件(正文 > 200 字):技术方案、观点陈述 + - 决策类:包含明确判断的邮件 + - 日常沟通:短邮件 + """ + long_emails = [] + decision_emails = [] + daily_emails = [] + + decision_keywords = [ + "同意", "不同意", "建议", "方案", "觉得", "应该", "决定", "确认", + "approve", "reject", "lgtm", "suggest", "recommend", "think", + "我的看法", "我认为", "我觉得", "需要", "必须", "不需要" + ] + + for e in emails: + body = e["body"] + + if len(body) > 200: + long_emails.append(e) + elif any(kw in body.lower() for kw in decision_keywords): + decision_emails.append(e) + else: + daily_emails.append(e) + + return { + "long_emails": long_emails, + "decision_emails": decision_emails, + "daily_emails": daily_emails, + "total_count": len(emails), + } + + +def format_output(target: str, classified: dict) -> str: + """格式化输出,供 AI 分析使用""" + lines = [ + f"# 邮件提取结果", + f"目标人物:{target}", + f"总邮件数:{classified['total_count']}", + "", + "---", + "", + "## 长邮件(技术方案/观点类,权重最高)", + "", + ] + + for e in classified["long_emails"]: + lines.append(f"**主题:{e['subject']}** [{e['date']}]") + lines.append(e["body"]) + lines.append("") + lines.append("---") + lines.append("") + + lines += [ + "## 决策类邮件", + "", + ] + + for e in classified["decision_emails"]: + lines.append(f"**主题:{e['subject']}** [{e['date']}]") + lines.append(e["body"]) + lines.append("") + + lines += [ + "---", + "", + "## 日常沟通(风格参考)", + "", + ] + + for e in classified["daily_emails"][:30]: + lines.append(f"**{e['subject']}**:{e['body'][:200]}") + lines.append("") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description="解析邮件文件,提取目标人发出的邮件") + parser.add_argument("--file", required=True, help="输入文件路径(.eml / .mbox / .txt)") + parser.add_argument("--target", required=True, help="目标人物(邮箱地址或姓名)") + parser.add_argument("--output", default=None, help="输出文件路径(默认打印到 stdout)") + + args = parser.parse_args() + + file_path = Path(args.file) + if not file_path.exists(): + print(f"错误:文件不存在 {file_path}", file=sys.stderr) + sys.exit(1) + + suffix = file_path.suffix.lower() + + if suffix == ".eml": + emails = parse_eml_file(str(file_path), args.target) + elif suffix == ".mbox": + emails = parse_mbox_file(str(file_path), args.target) + else: + emails = parse_txt_file(str(file_path), args.target) + + if not emails: + print(f"警告:未找到来自 '{args.target}' 的邮件", file=sys.stderr) + print("提示:请检查目标名称/邮箱是否与文件中的 From 字段一致", file=sys.stderr) + + classified = classify_emails(emails) + output = format_output(args.target, classified) + + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(output) + print(f"已输出到 {args.output},共 {len(emails)} 封邮件") + else: + print(output) + + +if __name__ == "__main__": + main() diff --git a/colleague-creator/tools/feishu_auto_collector.py b/colleague-creator/tools/feishu_auto_collector.py new file mode 100644 index 0000000..42ea837 --- /dev/null +++ b/colleague-creator/tools/feishu_auto_collector.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +""" +飞书自动采集器 + +输入同事姓名,自动: + 1. 搜索飞书用户,获取 user_id + 2. 找到与他共同的群聊,拉取他的消息记录 + 3. 搜索他创建/编辑的文档和 Wiki + 4. 拉取文档内容 + 5. 拉取多维表格(如有) + 6. 输出统一格式,直接进 colleague-creator 分析流程 + +前置: + python3 feishu_auto_collector.py --setup # 配置 App ID / Secret(一次性) + +用法: + python3 feishu_auto_collector.py --name "张三" --output-dir ./knowledge/zhangsan + python3 feishu_auto_collector.py --name "张三" --msg-limit 1000 --doc-limit 20 +""" + +from __future__ import annotations + +import json +import sys +import time +import argparse +from pathlib import Path +from datetime import datetime, timezone +from typing import Optional + +try: + import requests +except ImportError: + print("错误:请先安装 requests:pip3 install requests", file=sys.stderr) + sys.exit(1) + + +CONFIG_PATH = Path.home() / ".colleague-skill" / "feishu_config.json" +BASE_URL = "https://open.feishu.cn/open-apis" + + +# ─── 配置 ──────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print("未找到配置,请先运行:python3 feishu_auto_collector.py --setup", file=sys.stderr) + sys.exit(1) + return json.loads(CONFIG_PATH.read_text()) + + +def save_config(config: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(config, indent=2, ensure_ascii=False)) + + +def setup_config() -> None: + print("=== 飞书自动采集配置 ===\n") + print("请前往 https://open.feishu.cn 创建企业自建应用,开通以下权限:") + print() + print(" 消息类:") + print(" im:message:readonly 读取消息") + print(" im:chat:readonly 读取群聊信息") + print(" im:chat.members:readonly 读取群成员") + print() + print(" 用户类:") + print(" contact:user.base:readonly 搜索用户") + print() + print(" 文档类:") + print(" docs:doc:readonly 读取文档") + print(" wiki:wiki:readonly 读取知识库") + print(" drive:drive:readonly 搜索云盘文件") + print() + print(" 多维表格:") + print(" bitable:app:readonly 读取多维表格") + print() + + app_id = input("App ID (cli_xxx): ").strip() + app_secret = input("App Secret: ").strip() + + config = {"app_id": app_id, "app_secret": app_secret} + save_config(config) + print(f"\n✅ 配置已保存到 {CONFIG_PATH}") + + +# ─── Token ─────────────────────────────────────────────────────────────────── + +_token_cache: dict = {} + + +def get_tenant_token(config: dict) -> str: + """获取 tenant_access_token,带缓存(有效期约 2 小时)""" + now = time.time() + if _token_cache.get("token") and _token_cache.get("expire", 0) > now + 60: + return _token_cache["token"] + + resp = requests.post( + f"{BASE_URL}/auth/v3/tenant_access_token/internal", + json={"app_id": config["app_id"], "app_secret": config["app_secret"]}, + timeout=10, + ) + data = resp.json() + if data.get("code") != 0: + print(f"获取 token 失败:{data}", file=sys.stderr) + sys.exit(1) + + token = data["tenant_access_token"] + _token_cache["token"] = token + _token_cache["expire"] = now + data.get("expire", 7200) + return token + + +def api_get(path: str, params: dict, config: dict) -> dict: + token = get_tenant_token(config) + resp = requests.get( + f"{BASE_URL}{path}", + params=params, + headers={"Authorization": f"Bearer {token}"}, + timeout=15, + ) + return resp.json() + + +def api_post(path: str, body: dict, config: dict) -> dict: + token = get_tenant_token(config) + resp = requests.post( + f"{BASE_URL}{path}", + json=body, + headers={"Authorization": f"Bearer {token}"}, + timeout=15, + ) + return resp.json() + + +# ─── 用户搜索 ───────────────────────────────────────────────────────────────── + +def find_user(name: str, config: dict) -> Optional[dict]: + """通过姓名搜索飞书用户""" + print(f" 搜索用户:{name} ...", file=sys.stderr) + + data = api_get( + "/search/v1/user", + {"query": name, "page_size": 10}, + config, + ) + + if data.get("code") != 0: + print(f" 搜索用户失败(code={data.get('code')}):{data.get('msg')}", file=sys.stderr) + return None + + users = data.get("data", {}).get("results", []) + if not users: + print(f" 未找到用户:{name}", file=sys.stderr) + return None + + if len(users) == 1: + u = users[0] + print(f" 找到用户:{u.get('name')}({u.get('department_path', [''])[0]})", file=sys.stderr) + return u + + # 多个结果,让用户选择 + print(f"\n 找到 {len(users)} 个结果,请选择:") + for i, u in enumerate(users): + dept = u.get("department_path", [""]) + dept_str = dept[0] if dept else "" + print(f" [{i+1}] {u.get('name')} {dept_str} {u.get('user_id', '')}") + + choice = input("\n 选择编号(默认 1):").strip() or "1" + try: + idx = int(choice) - 1 + return users[idx] + except (ValueError, IndexError): + return users[0] + + +# ─── 消息记录 ───────────────────────────────────────────────────────────────── + +def get_chats_with_user(user_open_id: str, config: dict) -> list: + """找到 bot 和目标用户共同在的群聊""" + print(" 获取群聊列表 ...", file=sys.stderr) + + chats = [] + page_token = None + + while True: + params = {"page_size": 100} + if page_token: + params["page_token"] = page_token + + data = api_get("/im/v1/chats", params, config) + if data.get("code") != 0: + print(f" 获取群聊失败:{data.get('msg')}", file=sys.stderr) + break + + items = data.get("data", {}).get("items", []) + chats.extend(items) + + if not data.get("data", {}).get("has_more"): + break + page_token = data.get("data", {}).get("page_token") + + print(f" 共 {len(chats)} 个群聊,检查成员 ...", file=sys.stderr) + + # 过滤:目标用户在其中的群 + result = [] + for chat in chats: + chat_id = chat.get("chat_id") + if not chat_id: + continue + + members_data = api_get( + f"/im/v1/chats/{chat_id}/members", + {"page_size": 100}, + config, + ) + members = members_data.get("data", {}).get("items", []) + for m in members: + if m.get("member_id") == user_open_id or m.get("open_id") == user_open_id: + result.append(chat) + print(f" ✓ {chat.get('name', chat_id)}", file=sys.stderr) + break + + return result + + +def fetch_messages_from_chat( + chat_id: str, + user_open_id: str, + limit: int, + config: dict, +) -> list: + """从指定群聊拉取目标用户的消息""" + messages = [] + page_token = None + + while len(messages) < limit: + params = { + "container_id_type": "chat", + "container_id": chat_id, + "page_size": 50, + "sort_type": "ByCreateTimeDesc", + } + if page_token: + params["page_token"] = page_token + + data = api_get("/im/v1/messages", params, config) + if data.get("code") != 0: + break + + items = data.get("data", {}).get("items", []) + if not items: + break + + for item in items: + sender = item.get("sender", {}) + sender_id = sender.get("id") or sender.get("open_id", "") + if sender_id != user_open_id: + continue + + # 解析消息内容 + content_raw = item.get("body", {}).get("content", "") + try: + content_obj = json.loads(content_raw) + # 富文本消息 + if isinstance(content_obj, dict): + text_parts = [] + for line in content_obj.get("content", []): + for seg in line: + if seg.get("tag") in ("text", "a"): + text_parts.append(seg.get("text", "")) + content = " ".join(text_parts) + else: + content = str(content_obj) + except Exception: + content = content_raw + + content = content.strip() + if not content or content in ("[图片]", "[文件]", "[表情]", "[语音]"): + continue + + ts = item.get("create_time", "") + if ts: + try: + ts = datetime.fromtimestamp(int(ts) / 1000).strftime("%Y-%m-%d %H:%M") + except Exception: + pass + + messages.append({"content": content, "time": ts}) + + if not data.get("data", {}).get("has_more"): + break + page_token = data.get("data", {}).get("page_token") + + return messages[:limit] + + +def collect_messages( + user: dict, + msg_limit: int, + config: dict, +) -> str: + """采集目标用户的所有消息记录""" + user_open_id = user.get("open_id") or user.get("user_id", "") + name = user.get("name", "") + + chats = get_chats_with_user(user_open_id, config) + if not chats: + return f"# 消息记录\n\n未找到与 {name} 共同的群聊(请确认 bot 已被添加到相关群)\n" + + all_messages = [] + per_chat_limit = max(100, msg_limit // len(chats)) + + for chat in chats: + chat_id = chat.get("chat_id") + chat_name = chat.get("name", chat_id) + print(f" 拉取「{chat_name}」消息 ...", file=sys.stderr) + + msgs = fetch_messages_from_chat(chat_id, user_open_id, per_chat_limit, config) + for m in msgs: + m["chat"] = chat_name + all_messages.extend(msgs) + print(f" 获取 {len(msgs)} 条", file=sys.stderr) + + # 分类输出 + long_msgs = [m for m in all_messages if len(m.get("content", "")) > 50] + short_msgs = [m for m in all_messages if len(m.get("content", "")) <= 50] + + lines = [ + f"# 飞书消息记录(自动采集)", + f"目标:{name}", + f"来源群聊:{', '.join(c.get('name', '') for c in chats)}", + f"共 {len(all_messages)} 条消息", + "", + "---", + "", + "## 长消息(观点/决策/技术类)", + "", + ] + for m in long_msgs: + lines.append(f"[{m.get('time', '')}][{m.get('chat', '')}] {m['content']}") + lines.append("") + + lines += ["---", "", "## 日常消息(风格参考)", ""] + for m in short_msgs[:300]: + lines.append(f"[{m.get('time', '')}] {m['content']}") + + return "\n".join(lines) + + +# ─── 文档采集 ───────────────────────────────────────────────────────────────── + +def search_docs_by_user(user_open_id: str, name: str, doc_limit: int, config: dict) -> list: + """搜索目标用户创建或编辑的文档""" + print(f" 搜索 {name} 的文档 ...", file=sys.stderr) + + data = api_post( + "/search/v2/message", + { + "query": name, + "search_type": "docs", + "docs_options": { + "creator_ids": [user_open_id], + }, + "page_size": doc_limit, + }, + config, + ) + + if data.get("code") != 0: + # fallback:用关键词搜索 + print(f" 按创建人搜索失败,改用关键词搜索 ...", file=sys.stderr) + data = api_post( + "/search/v2/message", + { + "query": name, + "search_type": "docs", + "page_size": doc_limit, + }, + config, + ) + + docs = [] + for item in data.get("data", {}).get("results", []): + doc_info = item.get("docs_info", {}) + if doc_info: + docs.append({ + "title": doc_info.get("title", ""), + "url": doc_info.get("url", ""), + "type": doc_info.get("docs_type", ""), + "creator": doc_info.get("creator", {}).get("name", ""), + }) + + print(f" 找到 {len(docs)} 篇文档", file=sys.stderr) + return docs + + +def fetch_doc_content(doc_token: str, doc_type: str, config: dict) -> str: + """拉取单篇文档内容""" + if doc_type in ("doc", "docx"): + data = api_get(f"/docx/v1/documents/{doc_token}/raw_content", {}, config) + return data.get("data", {}).get("content", "") + + elif doc_type == "wiki": + # 先获取 wiki node 信息 + node_data = api_get(f"/wiki/v2/spaces/get_node", {"token": doc_token}, config) + obj_token = node_data.get("data", {}).get("node", {}).get("obj_token", doc_token) + obj_type = node_data.get("data", {}).get("node", {}).get("obj_type", "docx") + return fetch_doc_content(obj_token, obj_type, config) + + return "" + + +def collect_docs(user: dict, doc_limit: int, config: dict) -> str: + """采集目标用户的文档""" + import re + user_open_id = user.get("open_id") or user.get("user_id", "") + name = user.get("name", "") + + docs = search_docs_by_user(user_open_id, name, doc_limit, config) + if not docs: + return f"# 文档内容\n\n未找到 {name} 相关文档\n" + + lines = [ + f"# 文档内容(自动采集)", + f"目标:{name}", + f"共 {len(docs)} 篇", + "", + ] + + for doc in docs: + url = doc.get("url", "") + title = doc.get("title", "无标题") + doc_type = doc.get("type", "") + + print(f" 拉取文档:{title} ...", file=sys.stderr) + + # 从 URL 提取 token + token_match = re.search(r"/(?:wiki|docx|docs|sheets|base)/([A-Za-z0-9]+)", url) + if not token_match: + continue + doc_token = token_match.group(1) + + content = fetch_doc_content(doc_token, doc_type or "docx", config) + if not content or len(content.strip()) < 20: + print(f" 内容为空,跳过", file=sys.stderr) + continue + + lines += [ + f"---", + f"## 《{title}》", + f"链接:{url}", + f"创建人:{doc.get('creator', '')}", + "", + content.strip(), + "", + ] + + return "\n".join(lines) + + +# ─── 多维表格 ───────────────────────────────────────────────────────────────── + +def collect_bitable(app_token: str, config: dict) -> str: + """拉取多维表格内容""" + # 获取所有 table + data = api_get(f"/bitable/v1/apps/{app_token}/tables", {"page_size": 100}, config) + tables = data.get("data", {}).get("items", []) + + if not tables: + return "(多维表格为空)\n" + + lines = [] + for table in tables: + table_id = table.get("table_id") + table_name = table.get("name", table_id) + + # 获取字段 + fields_data = api_get( + f"/bitable/v1/apps/{app_token}/tables/{table_id}/fields", + {"page_size": 100}, + config, + ) + fields = [f.get("field_name", "") for f in fields_data.get("data", {}).get("items", [])] + + # 获取记录 + records_data = api_get( + f"/bitable/v1/apps/{app_token}/tables/{table_id}/records", + {"page_size": 100}, + config, + ) + records = records_data.get("data", {}).get("items", []) + + lines.append(f"### 表:{table_name}") + lines.append("") + lines.append("| " + " | ".join(fields) + " |") + lines.append("| " + " | ".join(["---"] * len(fields)) + " |") + + for rec in records: + row_data = rec.get("fields", {}) + row = [] + for f in fields: + val = row_data.get(f, "") + if isinstance(val, list): + val = " ".join( + v.get("text", str(v)) if isinstance(v, dict) else str(v) + for v in val + ) + row.append(str(val).replace("|", "|").replace("\n", " ")) + lines.append("| " + " | ".join(row) + " |") + + lines.append("") + + return "\n".join(lines) + + +# ─── 主流程 ─────────────────────────────────────────────────────────────────── + +def collect_all( + name: str, + output_dir: Path, + msg_limit: int, + doc_limit: int, + config: dict, +) -> dict: + """采集某同事的所有可用数据,输出到 output_dir""" + output_dir.mkdir(parents=True, exist_ok=True) + results = {} + + print(f"\n🔍 开始采集:{name}\n", file=sys.stderr) + + # Step 1: 搜索用户 + user = find_user(name, config) + if not user: + print(f"❌ 未找到用户 {name},请检查姓名是否正确", file=sys.stderr) + sys.exit(1) + + # Step 2: 采集消息记录 + print(f"\n📨 采集消息记录(上限 {msg_limit} 条)...", file=sys.stderr) + try: + msg_content = collect_messages(user, msg_limit, config) + msg_path = output_dir / "messages.txt" + msg_path.write_text(msg_content, encoding="utf-8") + results["messages"] = str(msg_path) + print(f" ✅ 消息记录 → {msg_path}", file=sys.stderr) + except Exception as e: + print(f" ⚠️ 消息采集失败:{e}", file=sys.stderr) + + # Step 3: 采集文档 + print(f"\n📄 采集文档(上限 {doc_limit} 篇)...", file=sys.stderr) + try: + doc_content = collect_docs(user, doc_limit, config) + doc_path = output_dir / "docs.txt" + doc_path.write_text(doc_content, encoding="utf-8") + results["docs"] = str(doc_path) + print(f" ✅ 文档内容 → {doc_path}", file=sys.stderr) + except Exception as e: + print(f" ⚠️ 文档采集失败:{e}", file=sys.stderr) + + # 写摘要 + summary = { + "name": name, + "user_id": user.get("user_id", ""), + "open_id": user.get("open_id", ""), + "department": user.get("department_path", []), + "collected_at": datetime.now(timezone.utc).isoformat(), + "files": results, + } + (output_dir / "collection_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2) + ) + + print(f"\n✅ 采集完成,输出目录:{output_dir}", file=sys.stderr) + return results + + +def main() -> None: + parser = argparse.ArgumentParser(description="飞书数据自动采集器") + parser.add_argument("--setup", action="store_true", help="初始化配置") + parser.add_argument("--name", help="同事姓名") + parser.add_argument("--output-dir", default=None, help="输出目录(默认 ./knowledge/{name})") + parser.add_argument("--msg-limit", type=int, default=1000, help="最多采集消息条数(默认 1000)") + parser.add_argument("--doc-limit", type=int, default=20, help="最多采集文档篇数(默认 20)") + + args = parser.parse_args() + + if args.setup: + setup_config() + return + + if not args.name: + parser.error("请提供 --name") + + config = load_config() + output_dir = Path(args.output_dir) if args.output_dir else Path(f"./knowledge/{args.name}") + + collect_all( + name=args.name, + output_dir=output_dir, + msg_limit=args.msg_limit, + doc_limit=args.doc_limit, + config=config, + ) + + +if __name__ == "__main__": + main() diff --git a/colleague-creator/tools/feishu_browser.py b/colleague-creator/tools/feishu_browser.py new file mode 100644 index 0000000..d46a9d3 --- /dev/null +++ b/colleague-creator/tools/feishu_browser.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +飞书浏览器抓取器(Playwright 方案) + +复用本机 Chrome 登录态,无需任何 token,能访问你有权限的所有飞书内容。 + +支持: + - 飞书文档(docx/docs) + - 飞书知识库(wiki) + - 飞书表格(sheets)→ 导出为 CSV + - 飞书消息记录(指定群聊) + +安装: + pip install playwright + playwright install chromium + +用法: + python3 feishu_browser.py --url "https://xxx.feishu.cn/wiki/xxx" --output out.txt + python3 feishu_browser.py --url "https://xxx.feishu.cn/docx/xxx" --output out.txt + python3 feishu_browser.py --chat "后端组" --target "张三" --limit 500 --output out.txt + python3 feishu_browser.py --url "https://xxx.feishu.cn/sheets/xxx" --output out.csv +""" + +from __future__ import annotations + +import sys +import time +import json +import argparse +import platform +from pathlib import Path +from typing import Optional + + +def get_default_chrome_profile() -> str: + """根据操作系统返回 Chrome 默认 Profile 路径""" + system = platform.system() + if system == "Darwin": + return str(Path.home() / "Library/Application Support/Google/Chrome/Default") + elif system == "Linux": + return str(Path.home() / ".config/google-chrome/Default") + elif system == "Windows": + import os + return str(Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/User Data/Default") + return str(Path.home() / ".config/google-chrome/Default") + + +def make_context(playwright, chrome_profile: Optional[str], headless: bool): + """创建复用登录态的浏览器上下文""" + profile = chrome_profile or get_default_chrome_profile() + try: + ctx = playwright.chromium.launch_persistent_context( + user_data_dir=profile, + headless=headless, + args=[ + "--disable-blink-features=AutomationControlled", + "--no-first-run", + "--no-default-browser-check", + ], + ignore_default_args=["--enable-automation"], + viewport={"width": 1280, "height": 900}, + ) + return ctx + except Exception as e: + print(f"⚠️ 无法加载 Chrome Profile:{e}", file=sys.stderr) + print(f" 尝试的路径:{profile}", file=sys.stderr) + print(" 请用 --chrome-profile 手动指定路径", file=sys.stderr) + sys.exit(1) + + +def detect_page_type(url: str) -> str: + """根据 URL 判断飞书页面类型""" + if "/wiki/" in url: + return "wiki" + elif "/docx/" in url or "/docs/" in url: + return "doc" + elif "/sheets/" in url or "/spreadsheets/" in url: + return "sheet" + elif "/base/" in url: + return "base" + else: + return "unknown" + + +def fetch_doc(page, url: str) -> str: + """抓取飞书文档或 Wiki 的文本内容""" + page.goto(url, wait_until="domcontentloaded", timeout=30000) + + # 等待编辑器加载(飞书文档渲染较慢) + selectors = [ + ".docs-reader-content", + ".lark-editor-content", + "[data-block-type]", + ".doc-render-core", + ".wiki-content", + ".node-doc-content", + ] + + loaded = False + for sel in selectors: + try: + page.wait_for_selector(sel, timeout=15000) + loaded = True + break + except Exception: + continue + + if not loaded: + # 等待一段时间后直接提取 body 文本 + time.sleep(5) + + # 额外等待异步内容渲染 + time.sleep(2) + + # 尝试多个选择器提取正文 + for sel in selectors: + try: + el = page.query_selector(sel) + if el: + text = el.inner_text() + if len(text.strip()) > 50: + return text.strip() + except Exception: + continue + + # fallback:提取整个 body + text = page.inner_text("body") + return text.strip() + + +def fetch_sheet(page, url: str) -> str: + """抓取飞书表格,转为 CSV 格式""" + page.goto(url, wait_until="domcontentloaded", timeout=30000) + + try: + page.wait_for_selector(".spreadsheet-container, .sheet-container", timeout=15000) + except Exception: + time.sleep(5) + + time.sleep(3) + + # 通过 JS 提取表格数据 + data = page.evaluate(""" + () => { + const rows = []; + // 尝试从 DOM 提取可见单元格 + const cells = document.querySelectorAll('[data-row][data-col]'); + if (cells.length === 0) return null; + + const grid = {}; + let maxRow = 0, maxCol = 0; + cells.forEach(cell => { + const r = parseInt(cell.getAttribute('data-row')); + const c = parseInt(cell.getAttribute('data-col')); + if (!grid[r]) grid[r] = {}; + grid[r][c] = cell.innerText.replace(/\\n/g, ' ').trim(); + maxRow = Math.max(maxRow, r); + maxCol = Math.max(maxCol, c); + }); + + for (let r = 0; r <= maxRow; r++) { + const row = []; + for (let c = 0; c <= maxCol; c++) { + row.push(grid[r] && grid[r][c] ? grid[r][c] : ''); + } + rows.push(row); + } + return rows; + } + """) + + if data: + lines = [] + for row in data: + lines.append(",".join(f'"{cell}"' for cell in row)) + return "\n".join(lines) + + # fallback:直接提取文本 + return page.inner_text("body") + + +def fetch_messages(page, chat_name: str, target_name: str, limit: int = 500) -> str: + """ + 抓取指定群聊中目标人物的消息记录。 + 需要先导航到飞书 Web 版消息页面。 + """ + # 打开飞书消息页 + page.goto("https://applink.feishu.cn/client/chat/open", wait_until="domcontentloaded", timeout=20000) + time.sleep(3) + + # 尝试搜索群聊 + try: + # 点击搜索 + search_btn = page.query_selector('[data-test-id="search-btn"], .search-button, [placeholder*="搜索"]') + if search_btn: + search_btn.click() + time.sleep(1) + page.keyboard.type(chat_name) + time.sleep(2) + + # 选择第一个结果 + result = page.query_selector('.search-result-item:first-child, .im-search-item:first-child') + if result: + result.click() + time.sleep(2) + except Exception as e: + print(f"⚠️ 自动搜索群聊失败:{e}", file=sys.stderr) + print(f" 请手动导航到「{chat_name}」群聊,然后按回车继续...", file=sys.stderr) + input() + + # 向上滚动加载历史消息 + print(f"正在加载消息历史...", file=sys.stderr) + messages_container = page.query_selector('.message-list, .im-message-list, [data-testid="message-list"]') + + if messages_container: + for _ in range(10): # 滚动 10 次 + page.evaluate("el => el.scrollTop = 0", messages_container) + time.sleep(1.5) + else: + for _ in range(10): + page.keyboard.press("Control+Home") + time.sleep(1.5) + + time.sleep(2) + + # 提取消息 + messages = page.evaluate(f""" + () => {{ + const target = "{target_name}"; + const results = []; + + // 常见的消息 DOM 结构 + const msgSelectors = [ + '.message-item', + '.im-message-item', + '[data-message-id]', + '.msg-list-item', + ]; + + let items = []; + for (const sel of msgSelectors) {{ + items = document.querySelectorAll(sel); + if (items.length > 0) break; + }} + + items.forEach(item => {{ + const senderEl = item.querySelector( + '.sender-name, .message-sender, [data-testid="sender-name"], .name' + ); + const contentEl = item.querySelector( + '.message-content, .msg-content, [data-testid="message-content"], .text-content' + ); + const timeEl = item.querySelector( + '.message-time, .msg-time, [data-testid="message-time"], .time' + ); + + const sender = senderEl ? senderEl.innerText.trim() : ''; + const content = contentEl ? contentEl.innerText.trim() : ''; + const time = timeEl ? timeEl.innerText.trim() : ''; + + if (!content) return; + if (target && !sender.includes(target)) return; + + results.push({{ sender, content, time }}); + }}); + + return results.slice(-{limit}); + }} + """) + + if not messages: + print("⚠️ 未能自动提取消息,尝试提取页面文本", file=sys.stderr) + return page.inner_text("body") + + # 按权重分类输出 + long_msgs = [m for m in messages if len(m.get("content", "")) > 50] + short_msgs = [m for m in messages if len(m.get("content", "")) <= 50] + + lines = [ + f"# 飞书消息记录(浏览器抓取)", + f"群聊:{chat_name}", + f"目标人物:{target_name}", + f"共 {len(messages)} 条消息", + "", + "---", + "", + "## 长消息(观点/决策类)", + "", + ] + for m in long_msgs: + lines.append(f"[{m.get('time', '')}] {m.get('content', '')}") + lines.append("") + + lines += ["---", "", "## 日常消息", ""] + for m in short_msgs[:200]: + lines.append(f"[{m.get('time', '')}] {m.get('content', '')}") + + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description="飞书浏览器抓取器(复用 Chrome 登录态)") + parser.add_argument("--url", help="飞书文档/Wiki/表格链接") + parser.add_argument("--chat", help="群聊名称(抓取消息记录时使用)") + parser.add_argument("--target", help="目标人物姓名(只提取此人的消息)") + parser.add_argument("--limit", type=int, default=500, help="最多抓取消息条数(默认 500)") + parser.add_argument("--output", default=None, help="输出文件路径(默认打印到 stdout)") + parser.add_argument("--chrome-profile", default=None, help="Chrome Profile 路径(默认自动检测)") + parser.add_argument("--headless", action="store_true", help="无头模式(不显示浏览器窗口)") + parser.add_argument("--show-browser", action="store_true", help="显示浏览器窗口(调试用)") + + args = parser.parse_args() + + if not args.url and not args.chat: + parser.error("请提供 --url(文档链接)或 --chat(群聊名称)") + + try: + from playwright.sync_api import sync_playwright + except ImportError: + print("错误:请先安装 Playwright:pip install playwright && playwright install chromium", file=sys.stderr) + sys.exit(1) + + headless = args.headless and not args.show_browser + + print(f"启动浏览器({'无头' if headless else '有界面'}模式)...", file=sys.stderr) + + with sync_playwright() as p: + ctx = make_context(p, args.chrome_profile, headless=headless) + page = ctx.new_page() + + # 检查是否已登录 + page.goto("https://www.feishu.cn", wait_until="domcontentloaded", timeout=15000) + time.sleep(2) + if "login" in page.url.lower() or "signin" in page.url.lower(): + print("⚠️ 检测到未登录状态。", file=sys.stderr) + print(" 请在打开的浏览器窗口中登录飞书,登录后按回车继续...", file=sys.stderr) + if headless: + print(" 提示:请用 --show-browser 参数显示浏览器窗口以完成登录", file=sys.stderr) + sys.exit(1) + input() + + # 根据任务类型执行 + if args.url: + page_type = detect_page_type(args.url) + print(f"页面类型:{page_type},开始抓取...", file=sys.stderr) + + if page_type == "sheet": + content = fetch_sheet(page, args.url) + else: + content = fetch_doc(page, args.url) + + elif args.chat: + content = fetch_messages( + page, + chat_name=args.chat, + target_name=args.target or "", + limit=args.limit, + ) + + ctx.close() + + if not content or len(content.strip()) < 10: + print("⚠️ 未能提取到有效内容", file=sys.stderr) + sys.exit(1) + + if args.output: + Path(args.output).write_text(content, encoding="utf-8") + print(f"✅ 已保存到 {args.output}({len(content)} 字符)", file=sys.stderr) + else: + print(content) + + +if __name__ == "__main__": + main() diff --git a/colleague-creator/tools/feishu_mcp_client.py b/colleague-creator/tools/feishu_mcp_client.py new file mode 100644 index 0000000..13f3999 --- /dev/null +++ b/colleague-creator/tools/feishu_mcp_client.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +飞书 MCP 客户端封装(cso1z/Feishu-MCP 方案) + +通过 Feishu MCP Server 读取文档、wiki、消息记录。 +适合:公司已授权的文档、有 App token 权限的内容。 + +前置要求: + 1. 安装 Feishu MCP:npm install -g feishu-mcp + 2. 配置 App ID 和 App Secret(飞书开放平台创建企业自建应用) + 3. 给应用开通必要权限(见下方 REQUIRED_PERMISSIONS) + +权限列表(飞书开放平台 → 权限管理 → 开通): + - docs:doc:readonly 读取文档 + - wiki:wiki:readonly 读取知识库 + - im:message:readonly 读取消息 + - bitable:app:readonly 读取多维表格 + - sheets:spreadsheet:readonly 读取表格 + +用法: + # 配置 token(一次性) + python3 feishu_mcp_client.py --setup + + # 读取文档 + python3 feishu_mcp_client.py --url "https://xxx.feishu.cn/wiki/xxx" --output out.txt + + # 读取消息记录 + python3 feishu_mcp_client.py --chat-id "oc_xxx" --target "张三" --output out.txt + + # 列出某空间下的所有文档 + python3 feishu_mcp_client.py --list-wiki --space-id "xxx" +""" + +from __future__ import annotations + +import os +import sys +import json +import argparse +import subprocess +from pathlib import Path +from typing import Optional + + +CONFIG_PATH = Path.home() / ".colleague-skill" / "feishu_config.json" + + +# ─── 配置管理 ──────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if CONFIG_PATH.exists(): + return json.loads(CONFIG_PATH.read_text()) + return {} + + +def save_config(config: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(config, indent=2)) + print(f"配置已保存到 {CONFIG_PATH}") + + +def setup_config() -> None: + print("=== 飞书 MCP 配置 ===") + print("请前往飞书开放平台(open.feishu.cn)创建企业自建应用,获取以下信息:\n") + + app_id = input("App ID (cli_xxx): ").strip() + app_secret = input("App Secret: ").strip() + + print("\n配置方式选择:") + print(" [1] App Token(应用权限,需要在飞书后台开通对应权限)") + print(" [2] User Token(个人权限,能访问你本人有权限的所有内容,需要定期刷新)") + mode = input("选择 [1/2],默认 1:").strip() or "1" + + config = { + "app_id": app_id, + "app_secret": app_secret, + "mode": "app" if mode == "1" else "user", + } + + if mode == "2": + print("\n获取 User Token:飞书开放平台 → OAuth 2.0 → 获取 user_access_token") + user_token = input("User Access Token (u-xxx):").strip() + config["user_token"] = user_token + print("注意:User Token 有效期约 2 小时,过期后需要重新配置") + + save_config(config) + print("\n✅ 配置完成!") + + +# ─── MCP 调用封装 ───────────────────────────────────────────────────────────── + +def call_mcp(tool: str, params: dict, config: dict) -> dict: + """ + 通过 npx 调用 feishu-mcp 工具。 + feishu-mcp 支持 stdio 模式,直接 JSON 通信。 + """ + env = os.environ.copy() + env["FEISHU_APP_ID"] = config.get("app_id", "") + env["FEISHU_APP_SECRET"] = config.get("app_secret", "") + + if config.get("mode") == "user" and config.get("user_token"): + env["FEISHU_USER_ACCESS_TOKEN"] = config["user_token"] + + payload = json.dumps({ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool, + "arguments": params, + }, + "id": 1, + }) + + try: + result = subprocess.run( + ["npx", "-y", "feishu-mcp", "--stdio"], + input=payload, + capture_output=True, + text=True, + env=env, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"MCP 调用失败:{result.stderr}") + return json.loads(result.stdout) + except FileNotFoundError: + print("错误:未找到 npx,请先安装 Node.js", file=sys.stderr) + print("安装 Feishu MCP:npm install -g feishu-mcp", file=sys.stderr) + sys.exit(1) + + +def extract_doc_token(url: str) -> tuple[str, str]: + """从飞书 URL 中提取文档 token 和类型""" + import re + patterns = [ + (r"/wiki/([A-Za-z0-9]+)", "wiki"), + (r"/docx/([A-Za-z0-9]+)", "docx"), + (r"/docs/([A-Za-z0-9]+)", "doc"), + (r"/sheets/([A-Za-z0-9]+)", "sheet"), + (r"/base/([A-Za-z0-9]+)", "base"), + ] + for pattern, doc_type in patterns: + m = re.search(pattern, url) + if m: + return m.group(1), doc_type + raise ValueError(f"无法从 URL 解析文档 token:{url}") + + +# ─── 功能函数 ───────────────────────────────────────────────────────────────── + +def fetch_doc_via_mcp(url: str, config: dict) -> str: + """通过 MCP 读取飞书文档或 Wiki""" + token, doc_type = extract_doc_token(url) + + if doc_type == "wiki": + result = call_mcp("get_wiki_node", {"token": token}, config) + elif doc_type in ("docx", "doc"): + result = call_mcp("get_doc_content", {"doc_token": token}, config) + elif doc_type == "sheet": + result = call_mcp("get_spreadsheet_content", {"spreadsheet_token": token}, config) + else: + raise ValueError(f"不支持的文档类型:{doc_type}") + + # 提取 MCP 返回的内容 + if "result" in result: + content = result["result"] + if isinstance(content, list): + # MCP tool result 格式 + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + return item.get("text", "") + elif isinstance(content, str): + return content + elif "error" in result: + raise RuntimeError(f"MCP 返回错误:{result['error']}") + + return json.dumps(result, ensure_ascii=False, indent=2) + + +def fetch_messages_via_mcp( + chat_id: str, + target_name: str, + limit: int, + config: dict, +) -> str: + """通过 MCP 读取群聊消息记录""" + result = call_mcp( + "get_chat_messages", + { + "chat_id": chat_id, + "page_size": min(limit, 50), # 飞书 API 单次最多 50 条 + }, + config, + ) + + messages = [] + raw = result.get("result", []) + if isinstance(raw, list): + messages = raw + elif isinstance(raw, str): + try: + messages = json.loads(raw) + except Exception: + return raw + + # 过滤目标人物 + if target_name: + messages = [ + m for m in messages + if target_name in str(m.get("sender", {}).get("name", "")) + ] + + # 分类输出 + long_msgs = [m for m in messages if len(str(m.get("content", ""))) > 50] + short_msgs = [m for m in messages if len(str(m.get("content", ""))) <= 50] + + lines = [ + "# 飞书消息记录(MCP 方案)", + f"群聊 ID:{chat_id}", + f"目标人物:{target_name or '全部'}", + f"共 {len(messages)} 条", + "", + "---", + "", + "## 长消息", + "", + ] + for m in long_msgs: + sender = m.get("sender", {}).get("name", "") + content = m.get("content", "") + ts = m.get("create_time", "") + lines.append(f"[{ts}] {sender}:{content}") + lines.append("") + + lines += ["---", "", "## 日常消息", ""] + for m in short_msgs[:200]: + sender = m.get("sender", {}).get("name", "") + content = m.get("content", "") + lines.append(f"{sender}:{content}") + + return "\n".join(lines) + + +def list_wiki_docs(space_id: str, config: dict) -> str: + """列出知识库空间下的所有文档""" + result = call_mcp("list_wiki_nodes", {"space_id": space_id}, config) + raw = result.get("result", "") + if isinstance(raw, str): + return raw + return json.dumps(raw, ensure_ascii=False, indent=2) + + +# ─── CLI ───────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description="飞书 MCP 客户端") + parser.add_argument("--setup", action="store_true", help="初始化配置(App ID / Secret)") + parser.add_argument("--url", help="飞书文档/Wiki/表格链接") + parser.add_argument("--chat-id", help="群聊 ID(oc_xxx 格式)") + parser.add_argument("--target", help="目标人物姓名") + parser.add_argument("--limit", type=int, default=500, help="最多获取消息数") + parser.add_argument("--list-wiki", action="store_true", help="列出知识库文档") + parser.add_argument("--space-id", help="知识库 Space ID") + parser.add_argument("--output", default=None, help="输出文件路径") + + args = parser.parse_args() + + if args.setup: + setup_config() + return + + config = load_config() + if not config: + print("错误:尚未配置,请先运行:python3 feishu_mcp_client.py --setup", file=sys.stderr) + sys.exit(1) + + content = "" + + if args.url: + print(f"通过 MCP 读取:{args.url}", file=sys.stderr) + content = fetch_doc_via_mcp(args.url, config) + + elif args.chat_id: + print(f"通过 MCP 读取消息:{args.chat_id}", file=sys.stderr) + content = fetch_messages_via_mcp( + args.chat_id, + args.target or "", + args.limit, + config, + ) + + elif args.list_wiki: + if not args.space_id: + print("错误:--list-wiki 需要 --space-id", file=sys.stderr) + sys.exit(1) + content = list_wiki_docs(args.space_id, config) + + else: + parser.print_help() + return + + if args.output: + Path(args.output).write_text(content, encoding="utf-8") + print(f"✅ 已保存到 {args.output}", file=sys.stderr) + else: + print(content) + + +if __name__ == "__main__": + main() diff --git a/colleague-creator/tools/feishu_parser.py b/colleague-creator/tools/feishu_parser.py new file mode 100644 index 0000000..4c1c54a --- /dev/null +++ b/colleague-creator/tools/feishu_parser.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +飞书消息导出 JSON 解析器 + +支持的导出格式: +1. 飞书官方导出(群聊记录):通常为 JSON 数组,每条消息包含 sender、content、timestamp +2. 手动整理的 TXT 格式(每行:时间 发送人:内容) + +用法: + python feishu_parser.py --file messages.json --target "张三" --output output.txt + python feishu_parser.py --file messages.txt --target "张三" --output output.txt +""" + +import json +import re +import sys +import argparse +from pathlib import Path +from datetime import datetime + + +def parse_feishu_json(file_path: str, target_name: str) -> list[dict]: + """解析飞书官方导出的 JSON 格式消息""" + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + messages = [] + + # 兼容多种 JSON 结构 + if isinstance(data, list): + raw_messages = data + elif isinstance(data, dict): + # 可能在 data.messages 或 data.records 等字段下 + raw_messages = ( + data.get("messages") + or data.get("records") + or data.get("data") + or [] + ) + else: + return [] + + for msg in raw_messages: + sender = ( + msg.get("sender_name") + or msg.get("sender") + or msg.get("from") + or msg.get("user_name") + or "" + ) + content = ( + msg.get("content") + or msg.get("text") + or msg.get("message") + or msg.get("body") + or "" + ) + timestamp = ( + msg.get("timestamp") + or msg.get("create_time") + or msg.get("time") + or "" + ) + + # content 可能是嵌套结构 + if isinstance(content, dict): + content = content.get("text") or content.get("content") or str(content) + if isinstance(content, list): + content = " ".join( + c.get("text", "") if isinstance(c, dict) else str(c) + for c in content + ) + + # 过滤:只保留目标人发送的消息 + if target_name and target_name not in str(sender): + continue + + # 过滤:跳过系统消息、表情包、撤回消息 + if not content or content.strip() in ["[图片]", "[文件]", "[撤回了一条消息]", "[语音]"]: + continue + + messages.append({ + "sender": str(sender), + "content": str(content).strip(), + "timestamp": str(timestamp), + }) + + return messages + + +def parse_feishu_txt(file_path: str, target_name: str) -> list[dict]: + """解析手动整理的 TXT 格式消息(格式:时间 发送人:内容)""" + messages = [] + + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + # 匹配格式:2024-01-01 10:00 张三:消息内容 + pattern = re.compile( + r"^(?P