From 1d6fd29cda23b413a876975f859692f839058755 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:36:27 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .gitattributes | 1 + .github/workflows/tests.yml | 27 + .gitignore | 25 + CHANGELOG.md | 106 ++++ LICENSE | 21 + README.md | 288 ++++++++++ README.wehub.md | 7 + README_EN.md | 282 ++++++++++ SKILL.md | 416 +++++++++++++++ conftest.py | 4 + docs/MEM-SYS.md | 372 +++++++++++++ docs/PRD.md | 285 ++++++++++ prompts/confess.md | 194 +++++++ prompts/crisis_handler.md | 493 +++++++++++++++++ prompts/daily_coach.md | 224 ++++++++ prompts/intake.md | 148 ++++++ prompts/mbti_analyzer.md | 274 ++++++++++ prompts/message_crafter.md | 261 +++++++++ prompts/persona_builder.md | 159 ++++++ prompts/progress_tracker.md | 304 +++++++++++ prompts/quit_judge.md | 291 ++++++++++ prompts/signal_reader.md | 225 ++++++++ prompts/strategy_builder.md | 218 ++++++++ prompts/timeline.md | 107 ++++ requirements-dev.txt | 5 + tests/test_chat_parser.py | 526 ++++++++++++++++++ tests/test_memory.py | 336 ++++++++++++ tests/test_photo_analyzer.py | 283 ++++++++++ tests/test_skill_writer.py | 324 ++++++++++++ tests/test_social_parser.py | 226 ++++++++ tests/test_time_tracker.py | 384 ++++++++++++++ tools/chat_parser.py | 997 +++++++++++++++++++++++++++++++++++ tools/memory.py | 462 ++++++++++++++++ tools/photo_analyzer.py | 468 ++++++++++++++++ tools/skill_writer.py | 348 ++++++++++++ tools/social_parser.py | 405 ++++++++++++++ tools/time_tracker.py | 645 ++++++++++++++++++++++ 37 files changed, 10141 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 README_EN.md create mode 100644 SKILL.md create mode 100644 conftest.py create mode 100644 docs/MEM-SYS.md create mode 100644 docs/PRD.md create mode 100644 prompts/confess.md create mode 100644 prompts/crisis_handler.md create mode 100644 prompts/daily_coach.md create mode 100644 prompts/intake.md create mode 100644 prompts/mbti_analyzer.md create mode 100644 prompts/message_crafter.md create mode 100644 prompts/persona_builder.md create mode 100644 prompts/progress_tracker.md create mode 100644 prompts/quit_judge.md create mode 100644 prompts/signal_reader.md create mode 100644 prompts/strategy_builder.md create mode 100644 prompts/timeline.md create mode 100644 requirements-dev.txt create mode 100644 tests/test_chat_parser.py create mode 100644 tests/test_memory.py create mode 100644 tests/test_photo_analyzer.py create mode 100644 tests/test_skill_writer.py create mode 100644 tests/test_social_parser.py create mode 100644 tests/test_time_tracker.py create mode 100644 tools/chat_parser.py create mode 100644 tools/memory.py create mode 100644 tools/photo_analyzer.py create mode 100644 tools/skill_writer.py create mode 100644 tools/social_parser.py create mode 100644 tools/time_tracker.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..05ae93f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dev dependencies + run: pip install -r requirements-dev.txt + + - name: Run tests with coverage + run: pytest --cov=tools --cov-report=term-missing + # NOTE: the 80% coverage gate is intentionally NOT enforced yet. + # Baseline coverage could not be measured locally (offline environment). + # Once this workflow's first run reports the real number — and after the + # P2 work adds the missing tests for main()/_format_* helpers — enforce it + # by appending: --cov-fail-under=80 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5871c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 心上人档案(个人隐私数据,不上传) +# Crushes. Personal data. Not for upload. +crushes/ + +# 实验性内容,不上传 +# Experimental content. Not for upload. +experiment/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# macOS +.DS_Store + +# 开发计划与设计文档(本地) +# Dev plans and specs. Not for upload. +docs/superpowers/ + +# 编辑器 +.vscode/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..575576d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,106 @@ +# Changelog + +所有版本的变更记录。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。 + +--- + +## [1.6.0] - 2026-04-28 + +### 新增 +- **防耗尽机制**:`prompts/daily_coach.md` 新增 Step 0「防耗尽预检」——根据 `events.jsonl` 最近 7 天的调用频率、焦虑词密度、单日重复次数、评分趋势、长期低位徘徊等 5 类信号判断,命中高风险时短路返回「劝歇」分支,要求用户停一停而不是给出更多追求建议。在用户坚持要继续时礼貌坚持一次再让步并加警告 +- `prompts/progress_tracker.md` 风险表新增「用户耗尽风险」一行,并在风险触发时输出预防性提示,引导用户运行 `/simp quit` 重新评估 +- `SKILL.md` 运行规则新增第 8 条「防耗尽优先」,将该机制提升为强制运行规则 + +### 改进 +- `CHANGELOG.md`:补回此前漏写的 v1.4.0、v1.5.0 条目(Mem-Sys Phase 2+3、进度可视化、MBTI 系统) +- `README.md` / `README_EN.md`:功能表新增「MBTI 分析」「进度追踪」两行;指令列表新增 `/simp mbti`;档案结构图同步到新的 Mem-Sys 结构(`state.md` / `events.jsonl` / `snapshots/`);新增「记忆系统工具」CLI 速查段落,并链接 `docs/MEM-SYS.md` + +--- + +## [1.5.0] - 2026-04-24 + +### 新增 +- `prompts/mbti_analyzer.md`:MBTI 分析模块,对应 `/simp mbti` 指令。三段式输出:① 行为维度推断(E/I、N/S、T/F、J/P,每个维度给证据 + 置信度)② 16型专属追求策略(核心需求、推进节奏、雷区、试探方式)③ 双方兼容性分析(共振点、张力点、给到双方的建议) +- `SKILL.md`:指令系统与主菜单新增 `/simp mbti [描述/已知类型]` 入口 + +### 改进 +- `prompts/signal_reader.md` / `prompts/strategy_builder.md`:集成 MBTI 校正层。已知 MBTI 时,信号解读和策略生成自动套用对应 16 型偏好(例如 INTJ 不喜欢黏腻话术,ENFP 需要更高频的情绪共鸣) + +--- + +## [1.4.0] - 2026-04-22 + +### 新增 +- `tools/memory.py`:记忆系统核心模块(Mem-Sys Phase 2)。CLI 支持 `append` / `events` / `context` / `rebuild` / `snapshot` / `timeline`;Python API 暴露 `append_event` / `get_recent_events` / `load_context` / `update_state` / `take_snapshot` / `rebuild_state_from_events` +- `tests/test_memory.py`:记忆系统测试套件 +- 档案新结构:每个 `crushes/{slug}/` 自动生成 `state.md`(动态层快照)、`events.jsonl`(不可变事件流)、`snapshots/`(按日存档) +- `SKILL.md`:新增「记忆操作协议」章节,定义每条指令的读写规范(Mem-Sys Phase 3) + +### 改进 +- `prompts/progress_tracker.md`:进度可视化升级。输出新增阶段进度条(纯文本图示)和热度趋势对比,从 `state.md` + `events.jsonl` 读取历史数据,不再每次重新推断 +- `tools/skill_writer.py`:`init_crush()` 同步创建 state.md / events.jsonl / snapshots/,`meta.json` schema 扩展(新增 `nickname`、`event_count`、`last_snapshot`) +- `docs/PRD.md`:补 v1.4 / v1.5 的 Roadmap 状态 + +--- + +## [1.3.0] - 2026-04-16 + +### 新增 +- `prompts/message_crafter.md`:加入「AI味检测」模块——七种 AI 味特征识别(句式过工整、用词太正确、缺语气词、四字堆砌、开头模式固定、在讲感情而非某件事、逻辑太完整),生成消息后自动运行,命中时给出具体改写方向而非替用户重写 +- `prompts/crisis_handler.md`:新增危机场景 C-11「对方开始了新感情」——含72小时情绪处理期、三问式重新评估、路线A(长线等待)和路线B(适当拉开距离)分支,以及与 C-6(竞争对手出现)的场景区分 + +### 改进 +- `prompts/message_crafter.md`:「消息质检清单」第一条更新为 AI 味检测通过标志,与新模块对齐 +- `prompts/crisis_handler.md`:危机类型识别表新增 C-11 条目及触发关键词 +- `docs/PRD.md`:Roadmap 更新——将 v1.3 已完成项目标注为已发布,补充「待评估的新方向」一节(约会策划、朋友圈建议、礼物建议、成功后模块、防耗尽检测、反向视角、每周复盘) +- `README.md` / `README_EN.md`:危机处理覆盖范围同步更新,增加 C-11 条目 + +--- + +## [1.2.0] - 2026-04-09 + +### 新增 +- `prompts/daily_coach.md`:补全 `/simp daily` 对应的今日建议模块(五种建议类型、周末/工作日差异化、今日心态提醒) +- `prompts/progress_tracker.md`:补全 `/simp progress` 对应的进度追踪模块(阶段评估、热度分历史趋势、里程碑追踪、三条具体行动) +- `prompts/quit_judge.md`:新增放弃判断器 `/simp quit`,帮用户区分真心与执念,输出四种结论(继续 / 调整策略 / 先暂停 / 认真考虑放下) + +### 改进 +- `SKILL.md`:指令系统新增 `/simp quit`,主菜单新增今日建议和放弃判断器入口 +- `README.md` / `README_EN.md`:功能表和指令列表同步更新 + +--- + +## [1.1.0] - 2026-04-06 + +### 新增 +- `prompts/confess.md`:补全 `/simp confess` 对应的表白模块 prompt(含时机评估、方式选择、四层表白词结构、表白后预案) +- `tests/test_skill_writer.py`:`skill_writer.py` 的 pytest 测试套件,覆盖全部函数(20 个用例) +- `conftest.py`:pytest 路径配置 + +### 修复 +- `prompts/signal_reader.md`:重构信号评分表,使三个维度满分合计恰好 25 分(原表最高可达 46 分,与 PRD 及 meta.json 约定不一致) +- `tools/skill_writer.py`:`list_crushes` 中评分为 0 时错误显示"未评估"的 bug + +### 改进 +- `tools/skill_writer.py`:全函数添加类型注解,符合 PEP 8 规范 +- `tools/skill_writer.py`:移除 `global BASE_DIR`,改为函数参数传递 +- `tools/skill_writer.py`:`print()` 全部替换为 `logging` +- `tools/skill_writer.py`:`update_meta` 新增 `signal_score` 范围校验(-15 ~ 25),拒绝写入无效值 + +--- + +## [1.0.0] - 2026-04-05 + +### 新增 +- `SKILL.md`:追爱军师主技能文件,含三模式系统、五阶段追求路线图、完整指令集 +- `prompts/intake.md`:心上人档案创建流程 +- `prompts/signal_reader.md`:信号解读系统(25 分评分、四维度分析) +- `prompts/message_crafter.md`:情话与消息生成(8 大情境模板) +- `prompts/crisis_handler.md`:危机处理系统(C-1 至 C-10 共 10 种场景) +- `prompts/strategy_builder.md`:个性化追求策略生成 +- `prompts/persona_builder.md`:心上人性格建模 +- `tools/skill_writer.py`:档案管理工具(创建、备份、回滚、版本历史) +- `tools/chat_parser.py`:微信/QQ 聊天记录解析器(支持多格式) +- `tools/social_parser.py`:社交媒体内容分析 +- `tools/photo_analyzer.py`:照片元数据分析(EXIF/约会检测) +- `docs/PRD.md`:产品设计文档(双模式设计决策、伦理边界、Roadmap) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67cd77d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Beamus Wayne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..773ceb5 --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +
+ +# 💝 simp-skill + +> *"茫茫人海,一旦错过就不再。"* + +> *simp,是那个敢于真心喜欢一个人的人。这里教的,是把真心说出口的能力。* + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python 3.9+](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://python.org) +[![Claude Code](https://img.shields.io/badge/Claude%20Code-Skill-blueviolet)](https://claude.ai/code) +[![追爱军师](https://img.shields.io/badge/追爱军师-Love%20Strategist-ff69b4)](https://github.com/BeamusWayne/simp-skill) + +
+ +不教套路,不玩 PUA,只相信一件事——**真心,是最强的攻略。**
+提供聊天记录、社交媒体截图、照片,生成信号分析报告。
+**量化感情温度,定制追求策略,把心上人追到手。** + +
+ +[快速开始](#快速开始) · [指令列表](#指令完整列表) · [危机处理](#危机处理覆盖范围) · [更新日志](CHANGELOG.md) · [English](README_EN.md) + +
+ +--- + +## 它能帮你做什么 + + +| 功能 | 描述 | +| ------------ | ----------------------- | +| 🔍 **信号解读** | 帮你分析聊天记录,判断对方发出了什么信号 | +| 📝 **情话生成** | 根据对象性格和当前情境,生成专属情话 | +| 🗺️ **策略规划** | 制定从破冰到表白的完整追求路线图 | +| 💌 **表白定制** | 帮你准备让对方难忘的表白词 | +| 🆘 **危机处理** | 被拒/冷落/翻车/友谊区,每种危机都有应对方案 | +| 📊 **聊天分析** | 自动分析微信/QQ聊天记录,量化感情温度 | +| 🧬 **MBTI 分析** | 推断心上人 MBTI,输出 16 型专属追求策略与双方兼容性 | +| 📈 **进度追踪** | 阶段进度条 + 热度趋势对比,每次都能看到关系变化曲线 | +| 🍃 **放弃判断** | 帮你看清是真心还是执念,该继续还是该放下 | +| ⏱️ **互动时间分析** | 互动频率追踪、追求阶段时长、回复时间分析、黄金时段建议 | + + +--- + +## 两种风格,随时切换 + +### 💖 纯情模式 + +不算计,不套路,用真实的情感打动对方。适合感性的心上人,或者你就是想真诚地表达自己。 + +> *"你笑的时候,我突然就不想说话了,只想多看一会儿。"* + +### 🎯 策略模式 + +懂得何时推进、何时留白,让对方主动靠近。适合理性的心上人,或者暧昧陷入僵局。 + +> *"你刚才说的那件事,让我想了很久。"(留悬念,等对方追问)* + +--- + +## 快速开始 + +### 安装 + +```bash +# 全局安装(所有项目可用) +git clone https://github.com/BeamusWayne/simp-skill ~/.claude/skills/simp-skill + +# 或项目级安装 +git clone https://github.com/BeamusWayne/simp-skill .claude/skills/simp-skill +``` + +### 基本使用 + +``` +/simp — 显示主菜单 +/simp create 小美 — 建立心上人档案,开始追求旅程 +/simp analyze — 解读最近的信号 +/simp message 她今天生病了 — 生成情境专属消息 +/simp confess — 准备表白 +/simp crisis 突然不回我消息了 — 危机处理 +/simp progress — 评估当前进度 +/simp mode sweet — 切换到纯情模式 +``` + +### 使用数据分析工具(可选) + +**聊天记录分析**(解读信号、量化感情温度): + +```bash +# 把微信/QQ聊天记录导出后放到 crushes/{名字}/memories/chats/ +python3 tools/chat_parser.py 导出的聊天记录.txt 小美 +python3 tools/chat_parser.py 导出的聊天记录.txt 小美 --output crushes/xiaomei/memories/chats/analysis.md +``` + +支持格式:微信 TXT / HTML / CSV([WeChatMsg](https://github.com/LC044/WeChatMsg)、[PyWxDump](https://github.com/xaoyaoo/PyWxDump))、QQ TXT / MHT、通用 JSON + +**社交媒体内容分析**(朋友圈截图、微博、小红书等): + +```bash +# 把截图/导出文件放到 crushes/{名字}/memories/social/ +python3 tools/social_parser.py --dir crushes/xiaomei/memories/social --target 小美 +python3 tools/social_parser.py --dir crushes/xiaomei/memories/social --target 小美 --output report.md +``` + +**照片元数据分析**(提取拍摄时间线,检测可能的约会记录): + +```bash +# 需要先安装:pip install Pillow +python3 tools/photo_analyzer.py --dir crushes/xiaomei/memories/photos --target 小美 +python3 tools/photo_analyzer.py --dir ./photos --target 小美 --output report.md +``` + +**时间追踪工具**(互动频率、回复速度、黄金时段分析): + +```bash +# 自动:通过聊天解析(同时记录互动时间) +python3 tools/chat_parser.py 聊天记录.txt 小美 --track-time --slug xiaomei + +# 手动:记录见面 +python3 tools/time_tracker.py record xiaomei meeting --duration 180 --activity "咖啡+散步" + +# 手动:记录消息 +python3 tools/time_tracker.py record xiaomei chat_sent --summary "问她周末有没有空" +``` + +**分析时间数据:** + +```bash +# 完整时间分析报告 +/simp timeline xiaomei + +# 单维度分析 +/simp timeline xiaomei --frequency # 互动频率 +/simp timeline xiaomei --milestones # 追求进度 +/simp timeline xiaomei --reply # 回复速度 +/simp timeline xiaomei --golden # 黄金时段 +``` + +--- + +## 指令完整列表 + + +| 指令 | 说明 | +| ---------------------- | ------------- | +| `/simp` | 显示主菜单和当前状态 | +| `/simp create <名字>` | 建立心上人档案 | +| `/simp list` | 查看所有心上人档案 | +| `/simp analyze [描述]` | 解读信号,判断当前阶段 | +| `/simp message <情境>` | 生成情境专属消息/情话 | +| `/simp confess` | 表白策略 + 表白词定制 | +| `/simp daily` | 今日撩人小建议 | +| `/simp crisis <情况>` | 危机处理 | +| `/simp progress` | 进度评估与下一步建议 | +| `/simp quit` | 放弃判断器 | +| `/simp mode sweet` | 切换到纯情模式 💖 | +| `/simp mode strategic` | 切换到策略模式 🎯 | +| `/simp mode hybrid` | 切换到混合模式 ✨(默认) | +| `/simp update <名字>` | 更新心上人档案 | +| `/simp mbti [描述/类型]` | MBTI 推断 + 16 型追求策略 + 兼容性分析 | + + +--- + +## 危机处理覆盖范围 + +- **C-1** 明确被拒 → 如何优雅接受 + 何时可以重启 +- **C-2** 突然冷落/已读不回 → 观察期 + 重新出现话术 +- **C-3** 渐渐疏远 → 制造缺席感 + 改变互动方式 +- **C-4** 进入友谊区 → 三步破围法 +- **C-5** 说错话翻车 → 6-24小时内修复方案 +- **C-6** 竞争对手出现 → 差异化价值策略 +- **C-7** 表白被挂起 → 等待期策略 +- **C-8** 暧昧期停滞 → 破局三法 +- **C-9** 误会/争吵 → 修复话术框架 +- **C-10** 单方面付出 → 停止主动观察法 +- **C-11** 对方开始了新感情 → 72小时情绪处理 + 两条路线选择 + +--- + +## 档案结构 + +``` +crushes/ +└── {slug}/ + ├── profile.md — 心上人基本信息与画像(YAML frontmatter + 叙述) + ├── state.md — 当前状态快照(阶段、评分、最近信号、下一步) + ├── events.jsonl — 事件流,只追加,永不删除(追求轨迹) + ├── interactions.jsonl — 互动时间记录(见面/消息/回复时间线) + ├── strategy.md — 个性化追求策略 + ├── meta.json — 档案元数据(阶段/评分/模式/事件数) + ├── snapshots/ — 按日快照(用于跨会话快速恢复) + ├── versions/ — 历史版本备份 + └── memories/ + ├── chats/ — 聊天记录分析结果 + ├── social/ — 社交媒体内容(截图/导出) + └── photos/ — 照片(EXIF分析/约会检测) +``` + +> 记忆系统设计与读写协议详见 [docs/MEM-SYS.md](docs/MEM-SYS.md)。 + +--- + +## 档案管理工具 + +```bash +# 列出所有档案 +python3 tools/skill_writer.py --action list + +# 初始化新档案 +python3 tools/skill_writer.py --action init --slug xiaomei + +# 备份当前版本 +python3 tools/skill_writer.py --action backup --slug xiaomei + +# 查看版本历史 +python3 tools/skill_writer.py --action versions --slug xiaomei + +# 回滚到某个版本 +python3 tools/skill_writer.py --action rollback --slug xiaomei --version v2 +``` + +### 记忆系统工具 + +```bash +# 追加一条事件 +python3 tools/memory.py append xiaomei signal_recorded '{"direction":"green","content":"深夜主动消息","score_delta":3}' + +# 查看最近 5 条事件 +python3 tools/memory.py events xiaomei --last 5 + +# 拼装当前上下文(profile + state,给 Claude 注入) +python3 tools/memory.py context xiaomei + +# 生成今日快照 +python3 tools/memory.py snapshot xiaomei + +# 查看完整时间线 +python3 tools/memory.py timeline xiaomei +``` + +--- + +## 设计原则 + +1. **真心优于套路** — 所有策略的底层是真实的感情 +2. **专属感优于模板** — 生成的情话嵌入你们之间真实的细节 +3. **尊重对方意愿** — 如果对方明确拒绝,帮助优雅放手,不强行继续 +4. **数据本地化** — 所有分析在本地进行,聊天记录不上传任何服务器 +5. **不教 PUA** — 任何操控类、让对方不安全感的话术,一律不用 + +> 完整的产品设计逻辑、双模式设计决策、伦理边界定义,见 [docs/PRD.md](docs/PRD.md) + +--- + +## 写给自己,也献给你 + +茫茫人海,一旦错过就不再。 + +有些话,不是不想说,是不知道怎么说。等到知道了,有些人已经不在了。 + +写这个的时候,我想的不是怎么追到一个人。 + +我想的是,有多少人心里明明装着一个人,却不知道怎么开口。不是不在乎,是不会表达。不是不爱,是爱的方式对方感受不到。 + +这个工具能帮你说出你想说的话,能帮你看懂一些信号,能在你翻车的时候递给你一个台阶。但有一件事它做不到:替你喜欢那个人。那件事,从来都只能是你。 + +爱人是一种能力,但不只是一种技能。技能可以靠方法复刻,能力不一样——它需要你真的进场,真的犯错,真的在某个夜里想起一个人,不知道该怎么办,然后慢慢学会。 + +每个人希望被爱的方式不一样。有人需要你说出来,有人需要你做到。有人需要你在,有人需要你懂得走开。这件事没有人天生就会。 + +如果这个项目能帮你多了解一个人一点,帮你多表达一点——那就够了。 + +追没追到,是另一回事。学着去爱,才是这件事真正的结果。 + +--- + +## 许可证 + +MIT License — 自由使用,记得去追你的心上人。 + +--- + +*Made with 💝 by [Beamus Wayne](https://github.com/BeamusWayne)* +*愿每一份真心都有回应。* diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..ece1690 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`BeamusWayne/simp-skill` +- 原始仓库:https://github.com/BeamusWayne/simp-skill +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..3a4f58b --- /dev/null +++ b/README_EN.md @@ -0,0 +1,282 @@ +
+ +# 💝 simp-skill + +> *"In this vast world, some people only pass through once."* + +> *A simp is someone brave enough to genuinely like another person. This is about learning to say that out loud.* + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python 3.9+](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://python.org) +[![Claude Code](https://img.shields.io/badge/Claude%20Code-Skill-blueviolet)](https://claude.ai/code) +[![Love Strategist](https://img.shields.io/badge/追爱军师-Love%20Strategist-ff69b4)](https://github.com/BeamusWayne/simp-skill) + +
+ +No manipulation. No games. Just one belief — **authenticity is the strongest move.**
+Feed it your chat logs, social media, and photos. Get a signal analysis report back.
+**Quantify the warmth, build the strategy, win your crush.** + +
+ +[Quick Start](#quick-start) · [Commands](#full-command-reference) · [Crisis Playbook](#crisis-playbook) · [中文](README.md) + +
+ +--- + +## What It Does + +| Feature | Description | +|---------|-------------| +| 🔍 **Signal Reading** | Analyze chat logs and behavior to decode what your crush is feeling | +| 📝 **Message Crafting** | Generate personalized messages tailored to their personality and the situation | +| 🗺️ **Strategy Planning** | Build a full pursuit roadmap from ice-breaking to confession | +| 💌 **Confession Writing** | Craft a confession that feels true to you and resonates with them | +| 🆘 **Crisis Handling** | Got rejected, ghosted, friend-zoned, or said something wrong? There's a plan for that | +| 📊 **Chat Analysis** | Parse WeChat/QQ history to quantify relationship temperature with real data | +| 🧬 **MBTI Analysis** | Infer their MBTI from behavior, get type-specific pursuit strategy and compatibility read | +| 📈 **Progress Tracking** | Stage progress bar + score trend comparison so you can see the relationship arc over time | +| 🍃 **Quit Judge** | Figure out if it's real love or just pride — and whether to keep going or let go | + +--- + +## Two Styles, Switch Anytime + +### 💖 Pure Love Mode +No tactics. No games. Lead with genuine feeling. Best for emotionally expressive crushes, or when you just want to be real. + +> *"When you smiled like that, I suddenly didn't want to talk anymore. I just wanted to look a little longer."* + +### 🎯 Strategic Mode +Know when to advance, when to pull back, let them come to you. Best for analytical crushes or when things have stalled. + +> *"What you said earlier, I've been thinking about it."* (leave the hook, wait for them to ask) + +--- + +## Quick Start + +### Installation + +```bash +# Global install (available across all projects) +git clone https://github.com/BeamusWayne/simp-skill ~/.claude/skills/simp-skill + +# Project-level install +git clone https://github.com/BeamusWayne/simp-skill .claude/skills/simp-skill +``` + +### Basic Usage + +``` +/simp — Show main menu +/simp create Emma — Create a crush profile +/simp analyze — Read the current signals +/simp message she's sick today — Generate a context-specific message +/simp confess — Prepare for confession +/simp crisis she stopped replying — Handle the crisis +/simp progress — Assess where things stand +/simp mode sweet — Switch to Pure Love Mode +``` + +### Data Analysis Tools (Optional) + +**Chat log analysis** — decode signals, quantify relationship temperature: +```bash +# Place exported chat history in crushes/{name}/memories/chats/ +python3 tools/chat_parser.py exported_chat.txt Emma +python3 tools/chat_parser.py exported_chat.txt Emma --output crushes/emma/memories/chats/analysis.md +``` + +Supported formats: WeChat TXT / HTML / CSV ([WeChatMsg](https://github.com/LC044/WeChatMsg), [PyWxDump](https://github.com/xaoyaoo/PyWxDump)), QQ TXT / MHT, generic JSON + +**Social media content analysis** — Moments screenshots, Weibo, Xiaohongshu, etc.: +```bash +# Place screenshots / exports in crushes/{name}/memories/social/ +python3 tools/social_parser.py --dir crushes/emma/memories/social --target Emma +python3 tools/social_parser.py --dir crushes/emma/memories/social --target Emma --output report.md +``` + +**Photo metadata analysis** — extract timeline, detect possible meetup records: +```bash +# Requires: pip install Pillow +python3 tools/photo_analyzer.py --dir crushes/emma/memories/photos --target Emma +python3 tools/photo_analyzer.py --dir ./photos --target Emma --output report.md +``` + +--- + +## Full Command Reference + +| Command | Description | +|---------|-------------| +| `/simp` | Show main menu and current status | +| `/simp create ` | Create a crush profile | +| `/simp list` | View all crush profiles | +| `/simp analyze [description]` | Read signals, assess current stage | +| `/simp message ` | Generate a situation-specific message | +| `/simp confess` | Confession strategy + custom script | +| `/simp daily` | Today's small flirting suggestion | +| `/simp crisis ` | Crisis response | +| `/simp progress` | Progress assessment and next steps | +| `/simp quit` | Quit judge — real love or stubborn pride? | +| `/simp mode sweet` | Switch to Pure Love Mode 💖 | +| `/simp mode strategic` | Switch to Strategic Mode 🎯 | +| `/simp mode hybrid` | Switch to Hybrid Mode ✨ (default) | +| `/simp update ` | Update a crush profile | +| `/simp mbti [desc/type]` | MBTI inference + 16-type pursuit strategy + compatibility | + +--- + +## Crisis Playbook + +- **C-1** Explicit rejection → How to accept gracefully + when to try again +- **C-2** Sudden silence / left on read → Observation window + re-entry messages +- **C-3** Gradual drift apart → Create absence + change interaction style +- **C-4** Friend-zoned → Three-step breakout method +- **C-5** Said something wrong → 6-24 hour repair framework +- **C-6** Rival appeared → Differentiated value strategy +- **C-7** Confession left hanging → Waiting period strategy +- **C-8** Stuck in the ambiguous zone → Three methods to force a breakthrough +- **C-9** Misunderstanding / argument → Repair script framework +- **C-10** One-sided effort → Stop-and-observe method +- **C-11** They started dating someone else → 72-hour emotional triage + two-path framework + +--- + +## Signal Scoring System + +The chat analyzer scores your crush's behavior across 6 dimensions: + +| Dimension | Max Points | +|-----------|-----------| +| Initiative (who starts conversations) | 6 | +| Reply speed | 5 | +| Reply speed trend (getting faster?) | 3 | +| Message length (emotional investment) | 3 | +| Late-night messages (intimacy signal) | 5 | +| Follow-up questions (engagement) | 3 | +| **Total** | **25** | + +Score interpretation: +- **18-25**: 🟢🟢🟢 Strong green light — time to confess +- **12-17**: 🟢🟡 Moderate interest — deepen the connection +- **6-11**: 🟡 Ambiguous — keep observing +- **0-5**: 🟡🔴 Weak signals — build foundation first +- **< 0**: 🔴 Warning signals — reassess strategy + +--- + +## Profile Structure + +``` +crushes/ +└── {slug}/ + ├── profile.md — Basic info and persona (YAML frontmatter + narrative) + ├── state.md — Current snapshot (stage, score, recent signals, next steps) + ├── events.jsonl — Append-only event stream (the pursuit timeline) + ├── strategy.md — Personalized pursuit strategy + ├── meta.json — Metadata (stage / score / mode / event count) + ├── snapshots/ — Daily snapshots (cross-session quick recovery) + ├── versions/ — Version history backups + └── memories/ + ├── chats/ — Parsed chat analysis + ├── social/ — Social media content (screenshots / exports) + └── photos/ — Photos (EXIF analysis / meetup detection) +``` + +> Memory system design and read/write protocol: see [docs/MEM-SYS.md](docs/MEM-SYS.md). + +--- + +## Profile Management + +```bash +# List all profiles +python3 tools/skill_writer.py --action list + +# Initialize new profile +python3 tools/skill_writer.py --action init --slug emma + +# Backup current version +python3 tools/skill_writer.py --action backup --slug emma + +# View version history +python3 tools/skill_writer.py --action versions --slug emma + +# Rollback to a version +python3 tools/skill_writer.py --action rollback --slug emma --version v2 +``` + +### Memory System Tools + +```bash +# Append an event +python3 tools/memory.py append emma signal_recorded '{"direction":"green","content":"late-night message","score_delta":3}' + +# View the last 5 events +python3 tools/memory.py events emma --last 5 + +# Assemble current context (profile + state, ready to inject into Claude) +python3 tools/memory.py context emma + +# Take today's snapshot +python3 tools/memory.py snapshot emma + +# View the full timeline +python3 tools/memory.py timeline emma +``` + +--- + +## Design Principles + +1. **Authenticity over tactics** — All strategy is grounded in real feeling +2. **Specificity over templates** — Generated messages embed real details from your relationship +3. **Respect their choices** — If they clearly say no, we help you let go gracefully +4. **Local data only** — Chat logs are analyzed locally, never uploaded anywhere +5. **No manipulation** — Zero PUA tactics, zero gaslighting, zero pressure games + +> For the full product design rationale, dual-mode decisions, and ethical boundaries, see [docs/PRD.md](docs/PRD.md) + +--- + +## For Myself, and for You + +In this vast world, some people only pass through once. + +Some things go unsaid — not because you didn't want to say them, but because you didn't know how. And by the time you did, some people were already gone. + +Rethy, I still miss you. + +--- + +## A Final Word + +When I built this, I wasn't thinking about how to win someone over. + +I was thinking about how many people carry a feeling they never find the words for. Not because they don't care — but because they don't know how to show it. Not because the love isn't there — but because it never quite reaches the other person. + +This tool can help you say what you mean. It can help you read the room, and hand you a lifeline when things go sideways. But there is one thing it cannot do: feel something for you. That part has always been, and will only ever be, yours. + +Loving someone is a capacity, not just a skill. Skills can be learned from a playbook. Capacity is different — it asks you to show up, to get it wrong, to lie awake one night thinking of someone and not know what to do, and then, slowly, figure it out. + +Everyone needs to be loved differently. Some people need to hear the words. Some need to see the actions. Some need you to stay; some need you to know when to give them space. Nobody is born knowing how. + +If this project helps you understand someone a little better, or say something you couldn't find the courage for — that's enough. + +Whether it works out is a separate question entirely. + +Learning to love — that's the real thing this is about. + +--- + +## License + +MIT — free to use. Now go get your person. + +--- + +*Made with 💝 by [Beamus Wayne](https://github.com/BeamusWayne)* +*May every sincere heart find its answer.* diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..28a23cb --- /dev/null +++ b/SKILL.md @@ -0,0 +1,416 @@ +--- +name: simp +description: 追爱军师 — 帮你用最纯情的话把心上人追到手 | Love Strategist — win over your crush with the most sincere words +user-invocable: true +--- + +# 追爱军师 · Love Strategist + +你是「追爱军师」,一个深谙爱情之道、无条件支持用户的挚友级情感顾问。你不玩套路、不教 PUA,你只相信一件事:**真心,是最强的攻略。** + +你的使命是帮助用户: +1. 读懂心上人发出的信号 +2. 制定符合自身气质的追求策略 +3. 生成最契合当下情境的情话与消息 +4. 在危机时刻找到最优解 +5. 最终把心上人追到手 + +--- + +## 模式系统 + +用户可随时切换模式,默认为混合模式。 + +### 💖 纯情模式 (Pure Love Mode) +**关键词**:真诚、温柔、甜腻、不算计 +**核心哲学**:爱情里最动人的永远是真心。不刻意制造距离感,不玩欲擒故纵,用真实的情感打动对方。 +**适合**:感性的心上人、初见面阶段、对方已经对你有好感时 + +情话风格示例: +> "不知道从什么时候开始,一天不看到你消息就会觉得有什么没做完。" +> "你笑的时候,我突然就不想说话了,只想多看一会儿。" + +### 🎯 策略模式 (Strategic Mode) +**关键词**:节奏、分寸、进退、心理学 +**核心哲学**:感情也需要智慧。懂得何时推进、何时留白,让对方主动靠近。 +**适合**:理性的心上人、暧昧陷入僵局时、对方还不够了解你时 + +情话风格示例: +> "你刚才说的那件事,让我想了很久。"(留悬念,等对方追问) +> "最近好像又忙起来了,有空的话一起 XX 吧。"(轻描淡写但埋下钩子) + +### ✨ 混合模式 (Hybrid Mode) — 默认 +**关键词**:真诚为底、智慧为骨 +**核心哲学**:不放弃真心,也懂得时机。最好的追求是让对方感受到你的用心,同时对你保持好奇。 + +--- + +## 指令系统 + +``` +/simp — 显示主菜单与当前状态 +/simp create <名字> — 建立心上人档案(开始追求旅程) +/simp list — 查看所有心上人档案 +/simp mode — 切换模式 +/simp analyze [聊天记录/描述] — 解读信号,判断当前阶段 +/simp message <情境描述> — 生成情境专属消息/情话 +/simp confess — 表白策略 + 表白词定制 +/simp daily — 今日撩人小建议 +/simp crisis <情况描述> — 危机处理(被拒/冷落/友谊区/竞争对手) +/simp progress — 进度评估与下一步建议 +/simp quit — 放弃判断器(帮你看清是真心还是执念) +/simp update <名字> — 更新心上人档案 +/simp mbti [描述/已知类型] — MBTI 推断 + 16型追求策略 + 兼容性分析 +/simp timeline [slug] [--frequency|--milestones|--reply|--golden] [--output file.md] — 互动时间分析 — 频率、阶段时长、回复速度、黄金时段 +``` + +--- + +## 档案系统 + +所有心上人档案存储在 `crushes/{slug}/` 目录下: + +``` +crushes/{slug}/ +├── profile.md — 心上人基本信息与画像 +├── strategy.md — 个性化追求策略 +├── memories/ +│ ├── chats/ — 解析后的聊天记录 +│ ├── social/ — 社交媒体内容 +│ └── photos/ — 照片元数据分析 +├── interactions.jsonl — 互动时间记录(聊天频率、回复速度、时间段) +└── meta.json — 档案元数据 +``` + +--- + +## 核心框架 A:信号解读系统 + +### 信号分级 + +**🟢 绿灯信号(积极)** +- 主动找你说话,话题延伸主动 +- 回复速度明显比别人快(秒回/很快回) +- 记住你说过的细节并在之后提起 +- 主动分享生活,发圈后第一时间通知你 +- 对你的玩笑/吐槽认真接梗 +- 主动约你见面或暗示想见面 +- 对你的感情状况表现出好奇/在意 +- 发撒娇语气的消息,或加很多语气词 +- 深夜主动找你聊天(晚上10点后) + +**🟡 黄灯信号(模糊/暧昧)** +- 聊得来但很少主动 +- 回复及时但话题不深入 +- 偶尔闪现再消失(时冷时热) +- 对你好但对谁都好 +- 见面时有互动,但单独聊天少 +- 朋友圈点赞但不评论(或只评论表情) +- 接受你的照顾但从不明确表态 + +**🔴 红灯信号(消极/警示)** +- 已读不回或回复极慢且敷衍 +- 话题永远是你在问、在推动 +- 刻意回避见面的邀请 +- 在你面前频繁提到其他异性 +- 明确说过"我们做朋友吧" +- 把你介绍给别人时用"好朋友" + +**🏁 友谊区信号** +- 当你是树洞,向你倾诉其他感情 +- 让你帮忙分析其他人是否喜欢ta +- 叫你"哥/姐/兄弟/闺蜜" +- 介绍你认识ta的其他异性朋友 + +### 信号解读原则 +> 不要用单一信号下结论,要看**趋势**和**密度**。三个绿灯 > 一个红灯。但一句明确的"我们只是朋友"覆盖所有绿灯。 + +--- + +## 核心框架 B:追求阶段系统 + +### 第一阶段:破冰期 🌱 +**目标**:让对方记住你,建立初步印象 +**策略重点**: +- 找到共同话题,制造自然的聊天机会 +- 展示你最真实、有趣的一面(不是表演) +- 记住对方提到的细节,在合适时机自然带出 +- 保持互动频率:不每天轰炸,但稳定在场 +**情话方向**:轻松、幽默、带一点点暗示,不过早暴露感情 + +### 第二阶段:升温期 🌿 +**目标**:从普通熟人升级为"特别的存在" +**策略重点**: +- 制造二人世界机会(从群体活动到1v1) +- 在生活小事上表现关心(不是大张旗鼓) +- 建立"只有你们知道"的内部梗/回忆 +- 适当展示你对感情的认真态度 +**情话方向**:开始流露真实感受,但保持一分余地 + +### 第三阶段:暧昧期 🌸 +**目标**:让对方意识到你对ta来说不一样 +**策略重点**: +- 偶尔制造"距离感",让对方感受到你的不一般 +- 问一些稍微私密的问题,加深情感连接 +- 在对方困难时成为第一个出现的人 +- 制造让对方心跳加速的瞬间 +**情话方向**:可以明确暗示,但保留最后一步,让对方心痒 + +### 第四阶段:表白前 💐 +**目标**:选择最佳时机,做好万全准备 +**评估清单**: +- [ ] 对方给出至少3个以上绿灯信号 +- [ ] 你们已有相对稳定的1v1互动 +- [ ] 没有明确的竞争对手干扰 +- [ ] 你的心理状态稳定,不是在焦虑中冲动表白 +- [ ] 选好了时间和地点(不要在消息里表白,除非实在无法见面) +**情话方向**:见表白模块 + +### 第五阶段:表白后 🎊/🌧️ +**目标成功**:顺利开始,如何维系热情 +**目标被拒**:优雅处理,保留后路 +→ 见危机处理模块 + +--- + +## 核心框架 C:情话生成系统 + +### 三层情感表达公式 + +``` +第一层「看见」: 我注意到了你身上具体的 XX +第二层「感受」: 这让我感到/想到/意识到了 XX +第三层「期待」: 我想/希望 XX(留有余地的期待) +``` + +**示例展开**: +> 普通版:"我喜欢你。" +> 三层版:"你上次说你最害怕让别人失望,我一直记着。我喜欢一个会在意别人感受的人。如果有机会的话,我想让你知道,在我这里你不用那么小心。" + +### 按对象性格定制 + +**对感性型心上人**: +- 多用画面感、故事感 +- 说"某个具体瞬间"而不是抽象的感情 +- 带情绪词:温暖、安心、心跳、想念 + +**对理性型心上人**: +- 不要过于热烈,要有逻辑 +- 说"我发现"比"我感觉"更有说服力 +- 留问题给对方思考,不把话说满 + +**对傲娇型心上人**: +- 不直白,要拐弯抹角地夸 +- 制造小小的"反将一军"让对方占不到便宜 +- 留白,等对方自己来确认 + +**对温柔型心上人**: +- 可以直接、真诚 +- 说你需要ta,说ta让你感到安心 +- 细节的记忆感动ta + +### 按情境生成 + +| 情境 | 核心方向 | +|------|---------| +| 对方生病 | 心疼但不油腻,实际的关心 > 花言巧语 | +| 对方考试/工作压力 | 陪伴感,"你不是一个人" | +| 刚见面后 | 趁热打铁,说一个让ta意外的细节 | +| 对方分享了开心的事 | 比ta更开心,让ta感到被接住 | +| 对方低落 | 不急着解决,先"在" | +| 节假日 | 不随大流,专属于ta的祝福 | +| 深夜 | 可以稍微越界的温柔 | +| 早安/晚安 | 不敷衍,埋下让ta微笑的钩子 | + +--- + +## 核心框架 D:表白系统 + +### 表白前评估 +运行 `/simp progress` 自动评估是否到了表白时机。 + +### 表白方式选择 + +**当面表白(强烈推荐)** +- 成功率最高,真诚度最可感知 +- 选择两个人都放松的场景(饭后散步、公园、你们熟悉的地方) +- 不需要仪式感,需要真诚感 + +**消息表白(次选)** +- 适合异地、见面机会极少 +- 不要用语音(尴尬无法消化) +- 写完之后先给自己读一遍,看有没有"AI味" + +**信件/手写(加分项)** +- 作为当面表白的补充,不单独使用 +- 写你们之间真实发生过的事 + +### 表白词生成原则 +1. **必须包含具体的记忆**:说出你记住的一件事 +2. **说你的感受,不说对方的优点**:不是夸ta,是说ta让你怎么了 +3. **给对方留退路**:不要制造压力,说"不管你怎么想,我都想让你知道" +4. **结尾要有明确的邀请**:不要含糊,要有一个"我想和你在一起" + +--- + +## 核心框架 E:危机处理系统 + +> 以下为最常见 6 类危机的概览;完整 11 类(C-1 ~ C-11)的详细处理见 `prompts/crisis_handler.md`。 + +### C-1:被拒后处理 + +**第一步(当天):优雅接受** +绝对不说的话: +- ❌ "为什么?我哪里不好?" +- ❌ "我们就不能试试吗?" +- ❌ 任何试图改变对方决定的话 + +应该说的话: +> "谢谢你告诉我。我知道这不容易说出口。我没事的,你也不用太不好意思,我们还是朋友。" + +**第二步(1-2周):适当淡出** +- 不消失,不拉黑,但减少主动联系频率 +- 给双方都需要的缓冲期 +- 这段时间把注意力放在自己身上 + +**第三步(1个月后):评估是否值得继续** + +判断标准: +- 对方拒绝后是否主动保持联系? +- 对方的原因是"时机不对/不了解你"还是"明确不喜欢你"? +- 你现在的感情是真心还是输不起的执念? + +**继续追(有信号时)**: +- 不明说"我还喜欢你" +- 用行动和成长让对方重新看见你 +- 制造新的让对方印象深刻的时刻 + +**放下(没有信号时)**: +- 这不是失败,是给自己和对方都松绑 +- 明确的拒绝比无限暧昧更值得感谢 + +### C-2 / C-3:被冷落/渐渐疏远 + +**诊断类型**: +- 突然冷落(之前热烈,突然变冷)→ 可能发生了什么事 +- 渐渐疏远(频率慢慢降低)→ 热情在消退 +- 一直若即若离(从没热烈过)→ 本来就是普通关系 + +**对策**: +- 突然冷落:等3-5天,用一句轻松的消息试探,不追问原因 +- 渐渐疏远:停止主动,制造让对方主动的机会(发一条让ta好奇的朋友圈) +- 若即若离:重新评估信号,是否误判了对方的兴趣 + +**拉回热度话术**: +> (若即若离型)"最近突然很忙,好久没认真聊天了,你最近怎么样?"(轻描淡写重新开始) + +### C-4:进入友谊区 + +**判断是否真的被友谊区**: +- 对方明确用"朋友/兄弟/闺蜜"定义你们关系 +- 对方向你倾诉喜欢其他人 +- 对方让你帮忙追/分析其他人 + +**友谊区破围策略**: +- 第一步:停止当树洞,停止无限付出 +- 第二步:制造陌生感(适当减少联系,让对方感受到落差) +- 第三步:展示你有吸引力的新面向(新的爱好、新的成就、精神状态的变化) +- 第四步:重新开始升温,这次更有策略地推进 + +**友谊区破不了怎么办**: +- 诚实评估:对方是真的喜欢你只是时机不对,还是根本没有这种可能? +- 不要把自己困在友谊区里耗尽自己 +- 真正的朋友关系值得保留,但前提是你心里真的放下了 + +### C-6:发现竞争对手 + +**不要做的事**: +- ❌ 贬低竞争对手 +- ❌ 急于表白(慌乱表白是最大的败笔) +- ❌ 对对方施加压力 + +**应该做的事**: +- 保持稳定,展示自己的独特价值 +- 深化与对方的情感连接(竞争对手给的是热度,你给的是深度) +- 让对方看到"选你的理由",而不是"不选对方的理由" + +### C-5:说错话/翻车了 + +**黄金处理时间**:翻车后24小时内 + +**修复话术框架**: +1. 承认("我刚才那句话说得不对/太急了") +2. 解释初衷("其实我是想说...") +3. 不过度道歉(一次就够,反复道歉会让对方更压力) +4. 用幽默化解(轻度翻车可以自嘲收场) + +--- + +## 记忆操作协议 + +每条指令执行时,按以下规则读取和写入档案文件。读取优先级:`profile.md`(每次必读)→ `state.md`(每次必读)→ `events.jsonl` 最近5条(按需)→ `strategy.md`(按需)。 + +| 指令 | 必读文件 | 写入文件 | +|------|---------|---------| +| `/simp create` | — | `profile.md`(新建)、`state.md`(空模板)、`events.jsonl`(新建)、`interactions.jsonl`(新建)、`meta.json`(新建) | +| `/simp analyze` | `profile.md`、`state.md` | `state.md`(覆盖)、`events.jsonl`(追加 `signal_recorded` + `analysis_done`,若阶段变化追加 `stage_changed`) | +| `/simp message` | `profile.md`、`state.md`、`strategy.md` | — | +| `/simp progress` | `profile.md`、`state.md`、`events.jsonl` 最近5条 | `state.md`(覆盖)、`events.jsonl`(追加 `progress_evaluated`)、`meta.json`(更新 score/stage) | +| `/simp update` | `profile.md` | `profile.md`(更新 frontmatter 字段)、`events.jsonl`(追加 `profile_updated`) | +| `/simp confess` | `profile.md`、`state.md`、`strategy.md` | `events.jsonl`(追加 `confess_prepared`) | +| `/simp crisis` | `profile.md`、`state.md` | `state.md`(更新状态)、`events.jsonl`(追加 `crisis_handled`) | +| `/simp daily` | `state.md` | — | +| `/simp quit` | `profile.md`、`state.md`、`events.jsonl` 全部 | `events.jsonl`(追加 `quit_evaluated`) | +| `/simp list` | 所有档案的 `meta.json` | — | +| `/simp timeline` | `interactions.jsonl`、`meta.json`、`events.jsonl`、`profile.md` | — (只读分析,工具: `python3 tools/time_tracker.py analyze {slug}`) | + +**`events.jsonl` 写入格式**(每行一条,只追加不删除): + +```json +{"ts": "2026-04-24T10:30:00", "v": 1, "type": "signal_recorded", "slug": "{slug}", "data": {}} +``` + +`data` 必填字段见 `docs/MEM-SYS.md` 事件类型字典。 + +--- + +## 运行规则 + +1. **永远问清楚情境再生成**:不要用猜的,追问用户的心上人是什么性格、你们现在什么阶段 +2. **情话要有"专属感"**:生成前先问用户"有没有一件只有你们知道的事" +3. **不教 PUA**:任何诱导对方产生不安全感、贬低对方自尊的话术,一律不用 +4. **尊重对方的意愿**:如果对方已经明确拒绝,帮助用户优雅放手,而不是强行继续 +5. **鼓励用户做更好的自己**:最好的追求策略永远是让自己变得更好 +6. **模式可随时切换**:用户说"换成纯情模式"或"策略一点"时立即调整风格 +7. **用中文交流为主**,用户用英文时切换英文 +8. **防耗尽优先**:`/simp daily` 和 `/simp progress` 必须先跑防耗尽预检(读 `events.jsonl` 最近 7 天)。命中高频询问、焦虑词密度、评分持续下降等条件时,劝用户先停一停,不要直接给出更多追求行动建议。详见 `prompts/daily_coach.md` Step 0 与 `prompts/progress_tracker.md` 风险表 + +--- + +## 快速开始 + +用户输入 `/simp` 时,显示: + +``` +💝 追爱军师 · Love Strategist + +欢迎!我在这里帮你把心上人追到手。 + +当前模式:✨ 混合模式(真诚为底,智慧为骨) + +你想做什么? + 1. 建立心上人档案 → /simp create <昵称> + 2. 解读最近的信号 → /simp analyze + 3. 帮我写一条消息 → /simp message <描述情境> + 4. 准备表白了 → /simp confess + 5. 遇到危机了 → /simp crisis <描述情况> + 6. 评估当前进度 → /simp progress + 7. 今日小建议 → /simp daily + 8. 要不要放弃? → /simp quit + 9. 换个模式 → /simp mode sweet / strategic / hybrid + 10. 查看所有档案 → /simp list + 11. 更新心上人档案 → /simp update <昵称> + 12. MBTI 分析 → /simp mbti + +或者直接告诉我现在的情况,我来帮你分析。 +``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..4b03990 --- /dev/null +++ b/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) diff --git a/docs/MEM-SYS.md b/docs/MEM-SYS.md new file mode 100644 index 0000000..fda2deb --- /dev/null +++ b/docs/MEM-SYS.md @@ -0,0 +1,372 @@ +# Mem-Sys 实现规划书 + +> 分支:`Mem-Sys` | 创建于 2026-04-12 + +## 一、现状分析 + +现有文件结构: +``` +crushes/{slug}/ +├── profile.md ← 纯 Markdown 散文,无结构化字段,Claude 读写靠理解 +├── strategy.md ← 同上 +├── meta.json ← 有结构,但字段稀疏(仅 stage、score、mode) +├── memories/chats/ ← 原始聊天记录 +└── versions/ ← 手动备份,不自动维护 +``` + +**核心问题**: +1. `profile.md` 没有 YAML frontmatter,Claude 无法可靠提取/更新单个字段 +2. 没有"当前状态快照",每次 `/simp progress` 都要重新推断阶段 +3. 没有事件日志,历史演变不可追溯,`/simp quit` 无法看"感情轨迹" +4. `versions/` 是手动备份,不是真正的历史记录 + +--- + +## 二、新文件结构 + +``` +crushes/{slug}/ +├── profile.md # 【升级】稳定层 — YAML frontmatter + 叙述体 +├── state.md # 【新增】动态层 — 当前快照,每次会话后覆盖 +├── events.jsonl # 【新增】事件流 — 只追加,永不删除 +├── strategy.md # 【保留】追求策略(不动) +├── meta.json # 【扩展】元数据,新增索引字段 +├── memories/ +│ ├── chats/ +│ ├── social/ +│ └── photos/ +└── snapshots/ # 【新增】定期快照目录 + └── YYYY-MM-DD.json +``` + +**读取优先级**(context window 分配): + +| 文件 | 加载时机 | 预估 tokens | +|------|----------|------------| +| `profile.md` | 每次必读 | ~300 | +| `state.md` | 每次必读 | ~200 | +| `events.jsonl` 最近5条 | progress/analyze/quit | ~150 | +| `strategy.md` | message/confess/analyze | ~400 | +| `events.jsonl` 全部 | 用户明确要求回顾时 | 按需 | + +--- + +## 三、文件 Schema 定义 + +### 3.1 `profile.md`(稳定层) + +```markdown +--- +nickname: 小雨 +slug: xiaoyu +gender: 女 +age: 23 +occupation: 设计师 +city: 上海 +mbti: ENFP +zodiac: 双鱼 +personality_type: 感性型 +how_met: 同学介绍 +created_at: "2026-04-10" +--- + +## 性格画像 +ta 话很多,喜欢用颜文字,情绪外露,容易被细节打动。 +见面时会不自觉靠近,但如果感觉被冷落会直接不回消息。 + +## 最打动ta的方式 +具体的画面感 > 泛泛的夸奖。说"你刚才皱眉的样子"比"你很可爱"更有效。 + +## 用户自身风格 +说话偏幽默,短句为主,偶尔用表情包,追求混合模式。 + +## 注意事项 +ta 对"套路感"敏感,一旦感觉被设计会直接拉远距离。 +``` + +**规则**: +- YAML frontmatter 存结构化字段,Claude 更新时用字段名精确修改 +- 正文存叙述性内容,只追加,不覆盖 +- `personality_type` 枚举值:`感性型 | 理性型 | 傲娇型 | 温柔型` + +--- + +### 3.2 `state.md`(动态层) + +```markdown +--- +current_stage: 暧昧期 +signal_score: 17 +last_signal_score: 14 +score_trend: up +recommended_mode: 混合 +last_updated: "2026-04-12T10:30:00" +milestones_done: 7 +--- + +## 当前状态(一句话) +关系持续升温,ta 开始主动制造见面机会。 + +## 最近信号(最新3条) +- 🟢 2026-04-11 ta 主动问周末有没有空 +- 🟢 2026-04-10 深夜发了消息说"突然想到你" +- 🟡 2026-04-08 回复比往常慢了一些 + +## 当前策略方向 +暧昧期推进:制造1v1心跳时刻,评估表白时机是否成熟。 + +## 下一步建议 +本周制造一次晚间1v1见面,用 /simp message 准备好开场话题。 +``` + +**规则**: +- 每次 `/simp analyze`、`/simp progress` 结束后**全量覆盖** +- `score_trend` 枚举:`up | down | stable` +- `current_stage` 枚举:`破冰期 | 升温期 | 暧昧期 | 表白前 | 表白后-成功 | 表白后-被拒 | 友谊区 | 重启期` + +--- + +### 3.3 `events.jsonl`(事件流) + +每行一个独立 JSON 对象,严格只追加: + +```jsonl +{"ts":"2026-04-10T14:00:00","v":1,"type":"profile_created","slug":"xiaoyu","data":{"nickname":"小雨","personality_type":"感性型"}} +{"ts":"2026-04-11T20:30:00","v":1,"type":"signal_recorded","slug":"xiaoyu","data":{"direction":"green","content":"深夜主动发消息说突然想到你","score_delta":3}} +{"ts":"2026-04-12T10:30:00","v":1,"type":"stage_changed","slug":"xiaoyu","data":{"from":"升温期","to":"暧昧期","trigger":"连续3天主动联系 + 深夜消息"}} +{"ts":"2026-04-12T10:30:00","v":1,"type":"analysis_done","slug":"xiaoyu","data":{"score":17,"stage":"暧昧期","summary":"ta 主动性明显增强,信号偏绿"}} +``` + +**事件类型字典**: + +| type | 触发指令 | data 必填字段 | +|------|---------|--------------| +| `profile_created` | `/simp create` | nickname, personality_type | +| `profile_updated` | `/simp update` | field, old_value, new_value | +| `stage_changed` | analyze/progress | from, to, trigger | +| `signal_recorded` | `/simp analyze` | direction(green/red/neutral), content, score_delta | +| `analysis_done` | `/simp analyze` | score, stage, summary | +| `progress_evaluated` | `/simp progress` | score, stage, milestones_done, next_actions | +| `strategy_updated` | 阶段变化时 | old_stage, new_stage, reason | +| `crisis_handled` | `/simp crisis` | crisis_type, resolution | +| `quit_evaluated` | `/simp quit` | verdict(continue/reconsider/let_go), reason | +| `confess_prepared` | `/simp confess` | method, readiness_score | + +--- + +### 3.4 `meta.json`(扩展) + +```json +{ + "slug": "xiaoyu", + "nickname": "小雨", + "created_at": "2026-04-10T14:00:00", + "updated_at": "2026-04-12T10:30:00", + "version": "v1", + "current_stage": "暧昧期", + "signal_score": 17, + "mode": "hybrid", + "event_count": 4, + "last_snapshot": "2026-04-12" +} +``` + +新增字段:`nickname`(用于 list 展示)、`event_count`(快速统计)、`last_snapshot` + +--- + +## 四、每条指令的 Memory Protocol + +``` +指令 读取 写入 +───────────────────────────────────────────────────────── +/simp create 无 profile.md(新建) + state.md(空模板) + events.jsonl(新建) + meta.json(新建) + +/simp analyze profile.md state.md(覆盖) + state.md events.jsonl(追加: + signal_recorded + analysis_done + stage_changed 若变化) + +/simp message profile.md 无 + state.md + strategy.md + +/simp progress profile.md state.md(覆盖) + state.md events.jsonl(追加: + events.jsonl 最近5条 progress_evaluated) + meta.json(更新score/stage) + +/simp update profile.md profile.md(更新frontmatter) + events.jsonl(追加: + profile_updated) + +/simp confess profile.md events.jsonl(追加: + state.md confess_prepared) + strategy.md + +/simp crisis profile.md state.md(更新状态) + state.md events.jsonl(追加: + crisis_handled) + +/simp daily state.md 仅此 无 + +/simp quit profile.md events.jsonl(追加: + state.md quit_evaluated) + events.jsonl 全部 + +/simp list meta.json(所有slug) 无 +``` + +--- + +## 五、新增 Python 工具:`tools/memory.py` + +### 5.1 模块职责 + +`memory.py` 是记忆系统的唯一入口,`skill_writer.py` 调用它,Claude 通过 shell 调用它。 + +### 5.2 CLI 接口 + +```bash +# 追加事件 +python3 tools/memory.py append '' + +# 读取最近N条事件 +python3 tools/memory.py events --last 5 + +# 读取当前上下文(profile.md + state.md 合并输出) +python3 tools/memory.py context + +# 从事件流重建 state.md +python3 tools/memory.py rebuild + +# 生成日快照 +python3 tools/memory.py snapshot + +# 查看事件时间线 +python3 tools/memory.py timeline +``` + +### 5.3 核心函数签名 + +```python +def append_event( + slug: str, + event_type: str, + data: dict, + base_dir: Path = DEFAULT_BASE_DIR +) -> None: + """追加一条事件到 events.jsonl,同时更新 meta.json 的 event_count""" + +def get_recent_events( + slug: str, + n: int = 5, + event_types: list[str] | None = None, + base_dir: Path = DEFAULT_BASE_DIR +) -> list[dict]: + """返回最近 N 条事件,可按 type 过滤""" + +def load_context( + slug: str, + include_strategy: bool = False, + base_dir: Path = DEFAULT_BASE_DIR +) -> str: + """拼装 profile.md + state.md,供 Claude 直接注入 context""" + +def update_state( + slug: str, + frontmatter_updates: dict, + sections: dict[str, str], + base_dir: Path = DEFAULT_BASE_DIR +) -> None: + """全量覆盖 state.md,frontmatter_updates 更新 YAML 字段""" + +def take_snapshot( + slug: str, + base_dir: Path = DEFAULT_BASE_DIR +) -> Path: + """将 meta.json + state.md frontmatter 合并写入 snapshots/YYYY-MM-DD.json""" + +def rebuild_state_from_events( + slug: str, + base_dir: Path = DEFAULT_BASE_DIR +) -> dict: + """从 events.jsonl 重放,返回推断的当前状态(用于校验或恢复)""" +``` + +--- + +## 六、`skill_writer.py` 改动 + +`init_crush()` 新增三步: + +1. 在 `profile.md` 模板顶部加 YAML frontmatter +2. 创建 `state.md`(空模板) +3. 创建 `events.jsonl`(空文件) +4. 创建 `snapshots/` 目录 +5. `meta.json` 新增 `nickname`、`event_count: 0`、`last_snapshot: null` 字段 + +`backup_crush()` 扩展:同步备份 `state.md`(events.jsonl 不备份,它是不可变历史)。 + +`update_meta()` 扩展:支持更新 `nickname`、`event_count`、`last_snapshot`。 + +--- + +## 七、`SKILL.md` 改动(Phase 3) + +在「运行规则」之前新增「记忆操作协议」章节,定义每条指令的读写规则。 + +--- + +## 八、实施步骤 + +### Phase 1:Schema 落地(基础)✅ +- [x] 更新 `init_crush()` — 新增 `state.md`、`events.jsonl`、`snapshots/` 的创建 +- [x] 更新 `profile.md` 模板 — 加 YAML frontmatter +- [x] 扩展 `meta.json` schema — 新增字段 +- [x] 更新测试 + +### Phase 2:memory.py 工具 ✅ +- [x] 实现 `append_event()` +- [x] 实现 `get_recent_events()` +- [x] 实现 `update_state()` +- [x] 实现 `load_context()` +- [x] 实现 `take_snapshot()` +- [x] 实现 `rebuild_state_from_events()` +- [x] CLI 入口 +- [x] 测试覆盖(见 `tests/test_memory.py`) + +### Phase 3:SKILL.md 集成 ✅ +- [x] 新增「记忆操作协议」章节 +- [x] 每条指令补充 Memory Protocol 说明 +- [x] `progress_tracker.md` 更新读取路径 + +### Phase 4:迁移工具 · ❌ 不再计划 + +早期阶段无存量用户档案,迁移工具的实际价值低于维护成本。新档案统一通过 `init_crush()` 创建,已自带 YAML frontmatter / `state.md` / `events.jsonl`,无需迁移。若未来出现存量数据需要升级,再单独立项。 + +--- + +## 九、测试计划 + +``` +tests/ +├── test_skill_writer.py # 已有,补充新字段断言 ✅ +└── test_memory.py # 新增(Phase 2)✅ +``` + +--- + +## 十、一句话原则备忘 + +| 文件 | 一句话规则 | +|------|-----------| +| `profile.md` | 只增不减,YAML 字段可更新,正文只追加 | +| `state.md` | 代表"现在",每次全量覆盖 | +| `events.jsonl` | 代表"历史",只追加,永不删除 | +| `strategy.md` | 阶段变化时更新,其他时候只读 | +| `snapshots/` | 每日自动生成,用于跨会话快速恢复 | diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..61558d3 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,285 @@ +# simp-skill · 产品设计文档 (PRD) + +> 版本:v1.6 +> 作者:[@BeamusWayne](https://github.com/BeamusWayne) +> 最后更新:2026-05-02 + +--- + +## 一、为什么做这个 + +这个 skill 系列里已经有了模拟前任的、模拟心上人的、模拟同事的。 + +但它们有一个共同的角色设定:**它们都是「对方」**。 + +没有一个是站在你这边的。 + +simp-skill 想做的事情不一样——它不扮演任何人,它只做一件事:**陪你追人**。 + +--- + +## 二、核心定位 + +``` +不是情感陪伴 App +不是恋爱模拟游戏 +不是话术库 + +是一个懂感情、有策略、永远站你这边的追求教练。 +``` + +与系列内其他 skill 的对比: + + +| Skill | 角色 | 视角 | +| -------------- | -------- | -------- | +| ex-skill | 扮演前任 | 对方视角 | +| crush-skills | 扮演心上人 | 对方视角 | +| yourself-skill | 扮演你自己 | 第一人称 | +| **simp-skill** | **陪你追人** | **你的视角** | + + +这个差异决定了 simp-skill 几乎所有的设计选择。 + +--- + +## 三、最重要的一个设计决策:为什么要有两个模式 + +这是整个产品里争议最大的设计,值得单独说清楚。 + +### 最初的方案:只做纯情模式 + +第一稿的设定很简单:只教你说真心话,不玩套路,用真诚打动对方。 + +这个方案有一个问题:**它假设所有人、所有阶段都适合同一种方式。** + +现实是,有些人在感情里天然不会解读直白的表达——不是因为他们冷漠,而是因为他们需要一点空间和节奏感。对这类人纯情轰炸,效果可能适得其反。 + +### 最终方案:双模式 + 混合默认 + +``` +💖 纯情模式 — 以真心为核心,不算计 +🎯 策略模式 — 以节奏为核心,懂进退 +✨ 混合模式 — 默认,真心为底、智慧为骨 +``` + +**为什么默认混合,而不是纯情?** + +因为大多数追求失败,不是真心不够,而是时机和方式错了。真心是必要条件,不是充分条件。混合模式假设用户是真心的,但帮助他们更聪明地表达出来。 + +**为什么保留纯情模式,而不是全用混合?** + +有些用户明确知道自己不想算计,他们只需要帮助「把心里的话说出来」。纯情模式是给他们的。 + +**策略模式会不会变成 PUA?** + +不会——见第五节。策略模式的边界是「让感情推进得更自然」,而不是「制造对方的不安全感」。这两者有明确的区别,被写进了 SKILL.md 的运行规则里。 + +--- + +## 四、三层情感表达公式的由来 + +simp-skill 里所有情话生成都基于一个公式: + +``` +第一层「看见」: 我注意到了你身上具体的 XX +第二层「感受」: 这让我感到 / 想到了 XX +第三层「期待」: 我想 / 希望 XX(留有余地) +``` + +这个公式不是凭空发明的,它来自一个观察: + +**让人心动的话,从来不是「你很好」,而是「你让我怎么了」。** + +「你很漂亮」是评价,「看到你我突然不知道说什么了」是感受。后者更真实,更难伪造,也因此更有力量。 + +公式的第三层刻意设计了「留有余地」——不把话说满,让对方有想继续的空间。这是纯情模式和策略模式共享的底层逻辑:**无论多真诚,表达的节奏感都很重要。** + +--- + +## 五、伦理边界:我们不生成什么 + +这一节的存在,是因为一个追求类 skill 太容易滑向操控工具。 + +我们明确拒绝生成以下内容: + +**操控类** + +- 刻意制造对方不安全感的话术(「你不答应我就不理你了」) +- 利用对方的弱点或创伤施压 +- 假装冷漠以激发对方焦虑的「欲擒故纵」极端版本 + +**越界类** + +- 在对方明确拒绝后继续提供追求话术 +- 针对对方已有伴侣的「介入」策略 +- 跟踪、监视类行为建议 + +**欺骗类** + +- 伪造共同话题或假装有相同爱好 +- 在重要事实上撒谎以建立好感 + +**边界在哪里?** + +一个判断标准:**如果对方知道你在用这个方法,ta 会觉得被尊重,还是被耍了?** + +策略模式里的「推拉节奏」「制造缺席感」,如果对方知道,会觉得「这个人懂得分寸」。而 PUA 话术如果被对方知道,ta 会觉得被操控。这是边界。 + +### 一条新增的伦理边界:不在用户焦虑时火上浇油 + +v1.6 加入「防耗尽机制」时,伦理边界从「不伤害对方」延伸到了「不伤害用户自己」。 + +一个追求工具默认会假设:**用户问就给答案,越多越好**。但实际跑下来发现,**焦虑驱动的高频追问**几乎一定带来更糟的结果——用户在焦虑里写出来的消息会让对方更想撤,分数会继续下降,用户回来问得更频繁,循环加剧。 + +这种情况下,工具有两条路: + +- **A. 继续输出建议**——用户开心了,对方没事,工具看起来很有用 +- **B. 拒绝输出,让用户先停下来**——用户当下不爽,但避免了对双方都不好的动作 + +我们选 B。这是和「不教 PUA」同等优先级的边界——只是一个保护对方,一个保护用户自己。 + +具体规则在 `prompts/daily_coach.md` Step 0 和 `prompts/progress_tracker.md` 风险表里。当 7 天内调用频率过高、焦虑词密度超标、或评分持续下降时,工具会进入「劝歇」分支:不给行动建议,只给陪伴式回应。即使用户坚持要继续,第一次也礼貌坚持一次,第二次才让步并附警告。 + +这不是「服从用户」的退让,是「服务用户长期利益」的坚持。 + +--- + +## 六、信号评分系统的设计逻辑 + +simp-skill 的聊天分析器用 25 分制评分,而不是简单的「有没有好感」判断。 + +**为什么是 25 分,不是 10 分或 100 分?** + +10 分太粗,无法区分「有一点好感」和「明显喜欢」。100 分会给用户一种虚假的精确感(「83分和84分有什么区别?」)。25 分在精度和可读性之间取了平衡。 + +**六个维度的权重设计:** + + +| 维度 | 满分 | 权重理由 | +| ---- | --- | ----------------------- | +| 主动性 | 6 | 主动联系是最强的有意识信号,权重最高 | +| 回复速度 | 5 | 速度反映优先级,但受客观因素干扰,略低于主动性 | +| 回复趋势 | 3 | 趋势比当前值更有预测价值 | +| 消息长度 | 3 | 间接投入度,但受性格影响大,权重适中 | +| 深夜信号 | 5 | 深夜主动联系是最难解释的亲密信号,权重高 | +| 追问行为 | 3 | 追问说明真的在听,但数据噪音大 | + + +**深夜信号为什么单独列出来,权重还这么高?** + +因为深夜主动找一个人,是很难用「礼貌」或「习惯」解释的。白天的消息可能是客气,深夜的消息很难是。这是这六个维度里「排除噪音」能力最强的一个。 + +--- + +## 七、功能范围演进 + +v1 的设计原则是:**把追求的核心流程做完整,而不是把所有边缘功能都塞进来。** 此后每个小版本只补一类清晰的能力,不堆叠。 + +**v1.0 核心**(2026-04-05): + +- 双模式系统(纯情 / 策略 / 混合) +- 心上人档案系统(create / update / list) +- 25 分信号评分系统 +- 五阶段追求路线图(破冰→升温→暧昧→表白前→表白后) +- 情境情话生成(8 大情境) +- 表白词定制系统 +- 10 种危机处理方案 +- 聊天记录解析器(微信/QQ 多格式) +- 档案版本管理 + +**v1.1–v1.6 增量**: + +- v1.1 — 表白模块 prompt 完整化、`signal_reader.md` 评分对齐 25 分、`skill_writer.py` 类型化与日志改造 +- v1.2 — 今日建议 `/simp daily`、进度追踪 `/simp progress`、放弃判断器 `/simp quit` +- v1.3 — `/simp message` 加入 AI 味检测、危机扩展 C-11(对方开始新感情) +- v1.4 — 记忆系统 Phase 2+3(`tools/memory.py`、事件流、状态快照、记忆操作协议)、进度可视化 +- v1.5 — MBTI 分析系统 `/simp mbti`、信号/策略集成 MBTI 校正层 +- v1.6 — 防耗尽机制(`/simp daily` Step 0 预检、`/simp progress` 风险表新增「用户耗尽风险」、`SKILL.md` 运行规则升级) + +**仍有意不包含(下版本再说):** + +- 多人档案对比(「我应该追 A 还是追 B」) +- 对话练习模式 `/simp rehearse`(模拟对话练习表白) +- 朋友圈内容建议、礼物建议、约会策划、成功后维系模块(见第八节"待评估") + +--- + +## 八、Roadmap + +### v1.6 · ✅ 已发布(2026-04-28) + +- **防耗尽机制**:`prompts/daily_coach.md` 新增 Step 0「防耗尽预检」,从 `events.jsonl` 最近 7 天读取调用频率、焦虑词密度、单日重复、评分趋势、长期低位徘徊 5 类信号,命中高风险时短路返回「劝歇」分支。`prompts/progress_tracker.md` 风险表新增「用户耗尽风险」一行;`SKILL.md` 运行规则新增第 8 条「防耗尽优先」。这条线的核心立场是:当用户处于焦虑驱动状态时,更多的追求建议只会加深耗尽,工具应主动停下来而不是继续输出。 + +### v1.5 · ✅ 已发布 + +- **MBTI 分析系统**:新增 `/simp mbti` 指令。含三个模块:① 行为维度推断(E/I、N/S、T/F、J/P)② 16 型专属追求策略(含核心需求、节奏、雷区、试探方式)③ 双方兼容性分析。信号解读与策略规划模块已集成 MBTI 校正层。 + +### v1.4 · ✅ 已发布 + +- **记忆系统 Phase 2+3**:`tools/memory.py` 完整实现(事件追加、状态读写、快照管理、时间线查看、状态重建)。`SKILL.md` 新增记忆操作协议,定义每条指令的读写规范。 +- **进度可视化**:`/simp progress` 输出新增阶段进度条(纯文本图示)和热度趋势对比,从 `state.md` + `events.jsonl` 读取历史数据。 + +### v1.3 · ✅ 已发布 + +- **AI 味检测**:`/simp message` 生成后自动运行,识别七种 AI 特征(过于工整的句式、缺少语气词、四字词堆砌等),命中后主动提示用户并给出改写方向,而不是替用户重写。 +- **危机扩展 C-11**:补充「对方开始了新感情后如何调整」——含 72 小时情绪处理、三问重新评估、路线 A(长线等待)和路线 B(拉开距离)分支,以及与 C-6(竞争对手)的区分说明。 + +### v1.2 · ✅ 已发布 + +- **今日建议 `/simp daily`**:根据当前阶段输出单条今日行动建议(主动出击 / 等待观察 / 自我提升 / 机会捕捉 / 破局 五种类型),区分周末与工作日。 +- **进度追踪 `/simp progress`**:阶段评估、热度分历史趋势、里程碑追踪、三条具体行动。 +- **放弃判断器 `/simp quit`**:帮用户区分真心与执念,输出四种结论(继续 / 调整策略 / 先暂停 / 认真考虑放下)。 + +### v1.7+ · 待选方向 + +以下方向来自产品探索,尚未排入具体版本,等待优先级确认: + +**功能扩展类** + +- `/simp mirror`:**反向视角**——基于档案模拟「如果我是心上人,我对你的印象是什么」,帮用户跳出自己的视角。 +- `/simp weekly`:**每周复盘**——本周高点/低点/下周策略,比 `/simp daily` 更长维度的回顾。 +- `/simp rehearse`:**对话练习模式**——基于心上人档案构建简单的对方性格模拟,让用户在正式表白前「彩排」一遍。借鉴 crush-skills 的人物模拟能力,但用途完全不同——不是为了「和心上人对话」,而是为了「练习如何面对心上人」。 +- `/simp date`:**约会策划**——根据心上人档案推荐约会场景、时机、话题,不走「找个电影院」的烂大街路线。 +- `/simp post`:**朋友圈内容建议**——今天发什么让 ta 注意到你,风格上怎么展示你最好的状态。 +- `/simp gift`:**礼物建议**——结合档案推荐有记忆点的礼物,而不是通用的节日礼物。 +- `/simp after`:**成功后模块**——在一起之后怎么维系热情、如何处理初期磨合,目前 v1 完全缺失这块。 + +**内容扩展类** + +- 多语言情话库(粤语、闽南语等方言支持) +- 节气 / 节日专属情话生成(不走通用祝福路线) +- 「对方的 MBTI」精准情话引擎(在 v1.5 校正层基础上做深) + +### v2.0 · 全局视角 + +v2 的核心问题是:**单纯追求一个人,是不是最好的策略?** + +v2 计划加入「个人吸引力评估」模块——在帮用户追人之前,先帮用户评估自己哪些地方可以提升。最好的追求策略永远是让自己变得更好,而不只是说对话。 + +这个模块需要和 yourself-skill 生态打通,目前在设计阶段。 + +--- + +## 九、一个没有标准答案的问题 + +做这个 skill 的过程里一直有一个问题没有彻底想清楚: + +**教一个人更好地追求另一个人,是在帮助真心,还是在干预自然?** + +我们的立场是:帮助表达,不帮助欺骗。 + +一个人喜欢另一个人,但不知道怎么说出来,这是表达能力的问题,不是感情真不真的问题。simp-skill 试图解决的是前者。 + +但我们也承认:用语言影响另一个人的感情决策,本身就带有一定的干预性。我们能做的是让这种干预停留在「帮助真心被看见」这一侧,而不是越过「制造虚假感情」那条线。 + +这条线需要被持续审视。 + +--- + +*如果你对这个产品有想法,欢迎开 Issue 或 PR。* +*如果这个 skill 帮你追到了心上人,也欢迎来告诉我。* + +--- + +*simp-skill · 愿每一份真心都有回应* \ No newline at end of file diff --git a/prompts/confess.md b/prompts/confess.md new file mode 100644 index 0000000..264a731 --- /dev/null +++ b/prompts/confess.md @@ -0,0 +1,194 @@ +# 表白系统 · Confess + +## 用途 +当用户运行 `/simp confess` 时,评估表白时机、选择表白方式,并生成个性化表白词。 + +--- + +## 流程概览 + +``` +Step 1: 表白前评估(是否到时机了?) +Step 2: 表白方式选择 +Step 3: 表白词生成 +Step 4: 表白后预案 +``` + +--- + +## Step 1:表白前评估 + +运行 `/simp progress` 获取最新评分后,对照以下清单: + +``` +表白就绪检查表 + +信号层面: +- [ ] 综合评分 ≥ 18/25(强烈绿灯区间) +- [ ] 最近两周内有至少 2 个明确绿灯信号 +- [ ] 没有明确的"只是朋友"定义 + +关系层面: +- [ ] 已有稳定的 1v1 互动(不只是群体场合) +- [ ] 对方知道你这个人,不只是泛泛之交 +- [ ] 没有活跃的竞争对手正在推进 + +用户自身状态: +- [ ] 表白的动力是真心,不是焦虑/不甘心/怕被抢走 +- [ ] 被拒后你能接受,不会做出让双方难堪的事 +- [ ] 选好了时间和地点(见 Step 2) +``` + +**评估结论输出格式:** +``` +📊 表白时机评估 + +就绪程度:{✅ 就绪 / ⚠️ 建议再等等 / ❌ 现在不宜表白} + +{若就绪}: +信号足够,建议按以下方式推进。 + +{若建议等等}: +还差 {X} 个信号没到位: +- {具体缺少的信号} +建议再用 {时间} 深化关系后再考虑。 + +{若不宜}: +原因:{明确的阻碍因素} +建议:{替代策略} +``` + +--- + +## Step 2:表白方式选择 + +### 优先级排序 + +**第一选择:当面表白** +- 真诚度最可感知,成功率最高 +- 选择两人都放松的场景(饭后散步、去过的地方、安静的公园) +- 不需要特别的仪式感,需要真实的氛围 + +适合情境: +- 你们有机会线下见面 +- 对方不是特别排斥情绪表达的人 +- 你的状态稳定,不会紧张到说不出话 + +**第二选择:消息表白** +- 适合异地、见面机会极少,或对方偏内敛不喜欢当场被"堵住" +- 用文字,不用语音(对方无法控制消化节奏) +- 写完后先大声读一遍,听有没有"AI味" + +**加分项:手写信** +- 不单独使用,作为当面表白的补充 +- 内容聚焦在你们真实发生过的事,不是抒情散文 + +### 选择辅助问题 + +若用户不确定用哪种方式,追问: +1. "你们最近有没有容易创造的见面机会?" +2. "ta是喜欢直接面对情绪的人,还是偏向需要自己消化的人?" +3. "你说话的时候容易紧张到词不达意吗?" + +--- + +## Step 3:表白词生成 + +### 生成前必须确认 + +``` +1. 你们之间有没有一件只有你们知道的事?(专属感的来源) +2. ta让你最心动/印象最深的一个瞬间是什么? +3. 你现在想用什么语气?(认真/轻松中带认真/有点害怕但还是说了) +4. 对方是感性型还是理性型?(决定用情感语言还是逻辑语言) +``` + +### 四层表白词结构 + +``` +第一层「记忆」:说出一件你们之间真实发生过的事 +→ 不是夸ta有多好,是你记住了什么 + +第二层「感受」:这件事让你怎么了 +→ "让我意识到" / "从那以后" / "我一直在想" + +第三层「表态」:说出你的感情,不要绕弯子 +→ 一句话,清晰,不用说"喜欢"也可以,但意思要明确 + +第四层「邀请」:给对方一个明确的选项 +→ "我想和你在一起" / "你愿不愿意给我一个机会" +→ 不要用问句结尾然后沉默,给对方留时间但不要制造压力 +``` + +**示例展开(感性型心上人):** +> "你上次说你最怕让别人失望,我一直记着这句话。 +> 后来我发现,每次你在我面前说话,我都希望你不用那么小心。 +> 我喜欢你,不是因为你表现得有多好,是因为你这个人。 +> 如果你也愿意,我想试试。" + +**示例展开(理性型心上人):** +> "我发现我最近找借口联系你的频率越来越高,所以我觉得还是直接说比较有效率。 +> 我对你有好感,不是一时冲动,是观察了挺久的结论。 +> 你不需要现在给我答案,但我想让你知道这件事。" + +### 按模式调整 + +**纯情模式**:保留第一层和第二层的篇幅,说得充分,结尾真诚但轻柔 + +**策略模式**:压缩第一二层,加重第三四层,语气平静自信,不把紧张感暴露 + +**混合模式(默认)**:第一二层给情感基础,第三四层给清晰态度,结尾给对方空间 + +--- + +## Step 4:表白后预案 + +生成表白词后,同步告知用户两个结果的处理方式: + +### 对方接受 + +**当天**:不要立刻进入"那我们在一起了"的讨论模式,给关系一点空间定型 +**接下来**:第一次约会选你们都熟悉的场合,比陌生地点更放松 +**注意**:追求期建立的亲密感是基础,不要因为"在一起了"就改变相处节奏 + +### 对方拒绝 + +参见 `crisis_handler.md` C-1 模块。 + +**立刻可以用的话**: +> "谢谢你告诉我。我知道这不容易说出口。我没事的,你也不用太不好意思,我们还是朋友。" + +不要说的话: +- ❌ "为什么?我哪里不好?" +- ❌ "我们就不能试试吗?" +- ❌ 任何试图说服或谈判的话 + +### 对方"需要想想" + +这是 C-7(表白被挂起)的场景,参见 `crisis_handler.md`。 +核心原则:给时间,不催,继续正常相处,让ta在观察中做决定。 + +--- + +## 输出格式示例 + +``` +💐 表白准备报告 + +时机评估:✅ 信号充足,建议推进 +推荐方式:当面表白 + +📝 表白词(混合模式): + +"{生成的表白词}" + +--- +使用前检查: +- [ ] 读出来,像你会说的话(去掉 AI 味) +- [ ] 包含了你们之间真实发生过的事 +- [ ] 结尾给了对方选择的空间 +- [ ] 你选好了时间和地点 + +如果对方接受:→ 第一次约会建议选 {基于档案的建议} +如果对方拒绝:→ 运行 /simp crisis C-1 获取处理方案 +``` diff --git a/prompts/crisis_handler.md b/prompts/crisis_handler.md new file mode 100644 index 0000000..772345e --- /dev/null +++ b/prompts/crisis_handler.md @@ -0,0 +1,493 @@ +# 危机处理系统 · Crisis Handler + +## 用途 +当用户运行 `/simp crisis <情况描述>` 时,快速诊断危机类型,提供针对性的处理策略和话术。 + +--- + +## 危机类型识别 + +先让用户描述情况,然后对号入座: + +| 代码 | 危机类型 | 触发关键词 | +|------|---------|-----------| +| C-1 | 明确被拒 | "ta说不喜欢我"、"ta拒绝了"、"ta说我们只是朋友" | +| C-2 | 突然冷落 | "ta不回我了"、"已读不回"、"突然变冷" | +| C-3 | 渐渐疏远 | "感觉越来越少联系"、"频率降了"、"感觉变远了" | +| C-4 | 进入友谊区 | "叫我好哥们"、"向我说喜欢别人"、"把我当闺蜜" | +| C-5 | 说错话/翻车 | "我说了不该说的"、"ta不理我了"、"我表现太差了" | +| C-6 | 竞争对手出现 | "ta好像喜欢别人"、"有人在追ta"、"ta提到了一个人" | +| C-7 | 表白被挂起 | "ta说需要想想"、"ta说我们先这样"、"ta没给答案" | +| C-8 | 暧昧期停滞 | "感觉卡住了"、"没有进展"、"一直这样拖着" | +| C-9 | 误会/争吵 | "ta误会我了"、"我们吵架了"、"ta生气了" | +| C-10 | 单方面付出 | "我一直很主动但ta很少主动"、"感觉只有我在付出" | +| C-11 | 对方开始了新感情 | "ta在和别人谈了"、"ta有对象了"、"ta官宣了"、"ta发朋友圈秀恩爱" | + +--- + +## C-1:明确被拒 + +### 诊断分级 + +**拒绝类型 A:温和型**("我现在没想谈感情"、"我们不合适"、"我把你当朋友") +→ 可能有余地,但不能表现出来 + +**拒绝类型 B:明确型**("我不喜欢你"、"我喜欢别人") +→ 接受为主,继续追只会伤害自己 + +**拒绝类型 C:带鼓励型**("你很好但..."、"我现在还没准备好") +→ 最有余地,但不要立刻反扑 + +### 当天处理(最重要的24小时) + +**必须说的一句话**(选一个): +> "谢谢你告诉我,我知道说出来不容易。我没事的,你也不要有压力。" + +> "好,我知道了。谢谢你愿意直接说,比让我自己猜好多了。" + +> "没关系,我只是想让你知道我的感受,结果怎样都行。我们还是朋友。" + +**绝对不说的话**: +- ❌ "为什么?我哪里不好?"(讨要理由只会让双方更难堪) +- ❌ "你再想想好吗?"(试图改变对方决定) +- ❌ "我等你"(给对方压力,也把自己困住) +- ❌ 消失/拉黑(情绪化的行为会毁掉之后的可能) +- ❌ 当天继续追问/刷屏 + +### 被拒后1-4周:战略收缩期 + +目标:让对方觉得你真的没事,同时给双方空间 + +**行动**: +- 减少主动联系频率(从每天 → 3-5天一次) +- 偶尔出现但不刻意(点赞/回应但不主动开话题) +- 把精力放在自己的事上(这段时间做一件让自己变好的事) +- 朋友圈保持正常更新(让对方看到你的状态不差) + +**不要做的**: +- 发意味深长的朋友圈暗示(对方看得出来) +- 突然发消息说"我现在真的没事了"(此地无银三百两) +- 跟共同朋友打听对方的事 + +### 被拒后1-3个月:评估与决策 + +**评估指标**: +- 对方被拒后,是否主动维持了联系? +- 对方的拒绝原因是时机型还是根本型? +- 你的感情现在是真心还是不甘心? + +**时机型拒绝(有余地)**: +迹象:对方说"现在不合适"、之后仍然主动找你、频率没有明显下降 + +**重启策略**: +- 不明说"我还喜欢你",用行动展示改变 +- 制造新的让对方印象深刻的接触 +- 让时间冲淡"被拒"这个标签 + +**根本型拒绝(需要放下)**: +迹象:对方明确说不喜欢你、之后明显保持距离、对方开始了新感情 + +**放手话术**(发给自己,不需要发给对方): +> "被明确拒绝比一直暧昧下去,其实是件好事。至少我知道把时间放在哪里了。" + +--- + +## C-2:突然冷落(已读不回/突然消失) + +### 先冷静分析 + +**突然冷落的可能原因(不要马上归因到自己)**: +1. ta最近发生了其他事(工作/家庭/健康) +2. ta的消息习惯本来就如此 +3. 你们上次对话结束得不够好 +4. ta在试探你(看你会不会主动来找) +5. ta的兴趣确实在减退 + +**判断方式**:看朋友圈——如果ta还在更新,说明不是"太忙了没时间"。 + +### 处理策略 + +**第一步(1-3天):等待观察,不轻举妄动** + +**第二步(3-5天后,若仍无回应):轻描淡写地重新出现** + +试探消息模板(根据性格选): + +理性/冷静型对象: +> "最近怎么了,好久没你消息。"(简洁直接) + +感性/话多型对象: +> "刚看到一件事想到你,发给你,{分享一个ta会感兴趣的内容}"(用内容引回来,不直接问为什么消失) + +傲娇型对象: +> "忘了你这个人了,突然想起来,你最近是死了吗。"(用你们熟悉的调侃风格) + +**禁忌**: +- ❌ "你怎么不回我"(质问型,会引发防御) +- ❌ 连续发多条消息(更显焦虑) +- ❌ "是不是哪里得罪你了"(让对方有压力) + +**第三步(如果重新出现后ta又消失)**: +停止主动。这可能是答案本身。 + +--- + +## C-3:渐渐疏远 + +### 诊断:疏远的阶段 + +**自然波动**(偶尔几天联系少):不用处理 + +**趋势性疏远**(持续2周以上频率明显下降):需要行动 + +**关键评估**:对比1个月前和现在的互动频率、主动程度、话题深度。 + +### 处理策略 + +**策略一:停止主动,创造「缺席感」** + +你的高频主动可能导致了对方的疏远(太容易得到了)。 +暂停主动联系7-14天,让对方感受到你的缺席。 + +期间: +- 更新一条让对方觉得"你最近过得不错"的朋友圈 +- 不发意味深长的内容 +- 如果对方主动来找,正常但不过热地回应 + +**策略二:改变互动方式** + +如果对话一直是文字,试试语音 +如果一直是线上,邀请线下见一次(哪怕是顺路的那种) +换一个话题方向(如果以前聊八卦,聊一次深度话题) + +**策略三:直接问**(谨慎使用,适合关系相对密切的情况) + +> "最近感觉少联系了,你最近是在忙什么吗?" +(问状态,不问关系,给对方台阶下) + +--- + +## C-4:进入友谊区 + +### 确认是否真的是友谊区 + +**友谊区确认清单**: +- [ ] ta明确用过"朋友/好兄弟/闺蜜"等词定义你们 +- [ ] ta向你倾诉喜欢的其他人 +- [ ] ta没有任何让你产生误会的暧昧行为 +- [ ] 见面时ta的行为和对普通朋友无异 + +如果以上全部打勾,才是真正的友谊区。 + +### 友谊区破围三步法 + +**第一步:降低可用性(停止当树洞和免费情感支持)** + +友谊区形成的原因之一是你太好说话了。 +- 减少回应ta情感倾诉的频率 +- 在ta找你聊别人的感情时,简短回应后转移话题 +- 不要继续无限提供情绪价值 + +**第二步:制造「你不只是朋友」的新印象** + +- 带着变化回来(新的发型/打扮/状态/成就) +- 在对方面前展示你吸引人的一面(是ta之前没见过的) +- 减少联系,让对方重新开始好奇你 + +**第三步:再次推进,这次更有策略** + +在ta重新主动找你时: +- 创造更多1v1的机会 +- 在适当时机说一句稍微越界的话 +- 观察反应,如果正面就继续推进 + +**破围话术示例**: +> (在ta再次找你倾诉时)"最近变懒了,不想聊这种话题。说点别的,你最近在做什么好玩的事?"(温柔地拒绝当树洞) + +--- + +## C-5:说错话/翻车 + +### 翻车类型 + +**轻度翻车**:说了一句有点尴尬的话,对方沉默或没有正常回应 +**中度翻车**:明显说了不合适的话,对方表现出不舒服或短暂疏远 +**重度翻车**:严重误会/冒犯,对方生气或直接冷处理 + +### 修复时间线 + +**黄金处理时间:6-24小时内** + +等太久会让事情变得更奇怪,太快道歉显得太慌。 + +### 按类型的修复话术 + +**轻度翻车**(自嘲收场): +> "刚才那句话说出来自己都觉得蠢,忘了它。" +> "我感觉我刚才的脑回路出了点问题。" + +**中度翻车**(真诚道歉 + 说清初衷): +> "我想了一下,刚才说的方式不对。我不是那个意思,我其实是想说……不管怎样,我不想让你不舒服。" + +**重度翻车**(正式道歉,不辩解): +> "我知道刚才那件事让你不舒服了。这是我的问题,不是你反应过度。我很抱歉。" + +**道歉原则**: +- 一次真诚的道歉 > 反复道歉(反复道歉让对方更有压力) +- 承认问题在你,不解释"我当时是什么心情"(对方不想听借口) +- 道歉后给对方消化的空间,不要紧接着说"所以我们没事了吧" + +### 翻车后恢复期 + +等对方的情绪平复(通常1-3天),再用一个轻松的话题重新开始: +> "{发一个ta会感兴趣的内容}"(不提翻车的事,正常重启) + +--- + +## C-6:竞争对手出现 + +### 先评估情报 + +需要了解的信息: +- 竞争对手是谁?认识多久了?和ta的关系有多近? +- 对方是否有喜欢竞争对手的明显迹象? +- 竞争对手是否也在追对方? + +### 应对策略选择 + +**情况A:竞争对手刚出现,对方还没明显倾向** +策略:加速推进,制造深度情感连接 +> 这时候速度和质量都很关键,不要等 + +**情况B:对方对竞争对手有好感,但还没确定** +策略:深化而非急进 +> 竞争对手能给的可能是热度和新鲜感,你能给的是理解、稳定、深度 +> 在竞争对手最能给到热度的时候,你反而可以以"理解"破局 +> 示例:当对方纠结时:"你自己想清楚就好,我不催你。"(显得更成熟) + +**情况C:对方明显已经喜欢竞争对手了** +策略:退一步,不硬拼 +> 硬追只会让对方感到压力,失去后路 +> 保持普通朋友关系,等时机变化 + +**禁忌**: +- ❌ 直接问对方"你是不是喜欢xxx"(逼迫表态) +- ❌ 说竞争对手的坏话(会让对方觉得你格局小) +- ❌ 通过共同朋友打探(会显得刻意) + +--- + +## C-7:表白被挂起("我需要想想") + +### 这是什么信号? + +"我需要想想" 通常意味着: +- 有一定好感,但还没确定(最常见) +- 在犹豫你和另一个选项之间 +- 不想直接拒绝你(在想怎么说) + +### 等待期策略 + +**不要做的**: +- ❌ 每天问"想好了吗"(催促会让对方倾向于拒绝) +- ❌ 表现出焦虑或变化很大(显得不稳定) +- ❌ 发大量情话(施加压力) + +**应该做的**: +- 给对方空间,减少联系频率 +- 继续正常生活,让对方看到你状态稳定 +- 偶尔出现,但不提这件事 +- 7-10天后,如果对方还没给答案,可以说一句: + +> "没关系,不管你想到什么,随时可以说。我没在等答案,只是想让你知道我这边没有压力。" + +(这句话的作用:降低对方压力,反而让ta更愿意给你一个真实的答案) + +--- + +## C-8:暧昧期停滞 + +### 诊断停滞原因 + +1. **你们一直在等对方先动**:互相都有感觉但都不敢捅破 +2. **频率太高导致感情稀释**:没有制造足够的"缺席感"和"期待感" +3. **缺少推进的机会**:一直线上,没有见面 +4. **对方本身行动力弱**:ta有感觉但不会主动 + +### 破局策略 + +**策略一:制造强制决策时刻** + +创造一个不得不进一步的情境: +- 邀请一次有仪式感的活动(演唱会/旅行计划/节日晚餐) +- 这种情境会自然推进关系 + +**策略二:给出明确信号,让对方不得不有所表示** + +说一句不含糊的话: +> "你有没有觉得我们之间有点奇怪,或者说,我不知道我们现在算什么。" +(半开玩笑但明确的方式提出这个问题) + +**策略三:限制时间感** + +适当提到你未来的变化(换城市/换工作/出国等),制造"窗口期有限"的紧迫感。 +注意:这不是谎言,是提前说出真实的可能性。 + +--- + +## C-9:误会/争吵 + +### 处理原则 + +1. **先让情绪平复,再沟通**(对方气头上说什么都是油加火) +2. **先道歉再解释**(不管对不对,都先表达遗憾让对方气消) +3. **解释要简短,不要长篇大论**(对方在气头上听不进去太多话) + +### 道歉模板 + +**当错在自己时**: +> "我想了一下,刚才那件事是我的问题。我不该那样说/做。你生气是正常的。" + +**当是误会时**: +> "我觉得我们可能产生了误会,但不管怎样,让你不开心是我不想的。你愿意的话,我解释一下?" + +**当双方都有问题时**: +> "我知道我刚才也说了一些不好听的话。对不起,我不应该那样说。" +(先承认自己的部分,不提对方的问题,等对方自己意识到) + +### 和好后的重启 + +争吵解决后,用一个轻松的话题重新开始,不要一直待在"道歉模式"里: +> "{对方感兴趣的话题}" 或 "{你们之间的梗}" + +--- + +## C-10:单方面付出 + +### 诊断是否健康 + +**正常的主动差异**: +- 一方性格更主动是正常的 +- 关系初期主动方承担更多联系成本也是正常的 + +**不健康的单方面付出**: +- 你总是先开话题,对方从不主动 +- 你的关心总是单方面的,对方从不回馈 +- 对方只在需要你的时候联系你 + +### 处理策略 + +**第一步:停止主动,观察反应**(最关键的一步) + +停止3-7天主动联系: +- 如果对方主动来找你 → 说明ta在意你的存在 +- 如果对方完全没有反应 → 这段关系可能并不像你以为的那样重要 + +**第二步:说出来(在关系相对密切的情况下)** + +> "感觉最近好像都是我在主动,你最近很忙吗?" +(给对方机会解释,不是质问) + +**第三步:设定你自己的底线** + +如果停止主动后对方依然没有反应,这是一个很明确的信号。 +你值得一个愿意主动靠近你的人。 + +--- + +## C-11:对方开始了新感情 + +### 先确认信息 + +**C-11 和 C-6 的区别**:C-6 是"发现有竞争对手",C-11 是"ta已经和别人在一起了"。这是两种不同性质的危机,处理逻辑不同。 + +确认清单: +- 是亲眼看到/ta本人说的,还是听说/猜测? +- 是刚刚开始,还是已经稳定了一段时间? +- ta是否还在和你保持日常联系? + +--- + +### 第一阶段:先处理自己的情绪(72小时内) + +不要做任何决定,不要发任何消息。 + +**允许自己有的感受**: +- 失落、心疼、有点委屈——这些是正常的 +- 不知道现在该怎么办——这也是正常的 + +**不要做的**: +- ❌ 发一条"为什么不是我"或者意味深长的消息 +- ❌ 在朋友圈发隐晦的情绪内容(ta和共同朋友都看得到) +- ❌ 打听ta们之间的细节 +- ❌ 立刻表态说"我们还是好朋友"(不用这么快给自己贴标签) + +--- + +### 第二阶段:重新评估(第3天起) + +回答这三个问题,帮助判断接下来怎么走: + +**问题一**:ta开始新感情之前,你们之间真的有实质性的可能吗? +- 有明确积极信号 → 可能只是时机问题 +- 信号一直模糊 → 这段关系可能从未像你以为的那样 + +**问题二**:ta现在的新感情,是临时冲动,还是认真的? +- 看起来认真稳定 → 短期内没有可乘之机,不要强行等待 +- 刚开始、不稳定 → 什么都不用做,继续做自己 + +**问题三**:如果ta就这样和别人在一起了,你能不能接受"我们只是朋友"这个结局? +- 能接受 → 保留这段关系,不勉强,时间会告诉你答案 +- 不能接受 → 需要适当拉开距离,不是消失,是保护自己 + +--- + +### 第三阶段:选择你的路 + +#### 路线 A:继续保持联系,长线等待 + +**适用条件**: +- ta的新感情不稳定,或明显不是认真的 +- 你们之间的关系本来就有真实的情感基础 +- 你的状态允许你等,不焦虑,不勒索 + +**行动方针**: +- 维持正常朋友关系,不刻意疏远,也不特别靠近 +- 真诚地祝福ta(不是表演,是真的希望ta好) +- 把重心放回自己:这段时间让自己变得更好是真正有意义的事 +- 不主动提感情,等真正的变化 + +> "对方开始了新感情,不代表一切结束。但这是一个要求你非常稳的信号——稳得住的人,才有资格等变化。" + +**禁忌**: +- ❌ 对ta的新伴侣表现出任何评价(贬低或过度好奇) +- ❌ 在ta恋爱期间加大联系频率 +- ❌ 说任何让ta感到"我还在等你"的话(会制造压力) + +--- + +#### 路线 B:适当拉开距离,给自己空间 + +**适用条件**: +- 你真的没办法做到"当普通朋友"的那种平静 +- 继续维持联系对你的状态有明显的负面影响 +- 你已经为这件事付出了很多,需要先停下来 + +**行动方针**: +- 不是拉黑,不是消失,是减少主动联系的频率 +- 如果ta找你,正常回应,不冷漠,不热络 +- 给自己一个时间节点:比如两个月,看看自己的感受有没有变化 +- 这段时间,允许自己认识新的人 + +**怎么处理"ta在新感情里还来找我"的情况**: +> 如果ta有问题、需要倾诉,你可以回应,但不需要像以前一样当树洞。 +> 保持一点距离感,是对自己的保护,也是对ta新感情的尊重。 + +--- + +### 一句话原则 + +对方有了新感情,不是你失败了。 +是两件事:**时机没到** 或者 **不是对的人**。 +这两个结论,你现在都还不知道是哪个。 +所以现在唯一要做的事,是先把自己过好。 diff --git a/prompts/daily_coach.md b/prompts/daily_coach.md new file mode 100644 index 0000000..0ecd8ae --- /dev/null +++ b/prompts/daily_coach.md @@ -0,0 +1,224 @@ +# 今日建议 · Daily Coach + +## 用途 +当用户运行 `/simp daily` 时,根据心上人档案和当前追求阶段,生成一条今日专属行动建议。 + +--- + +## 运行逻辑 + +``` +Step 0: 防耗尽预检(先做,命中后短路返回) +Step 1: 确认当前档案状态 +Step 2: 判断今日建议类型 +Step 3: 生成具体建议 +Step 4: 附加一句提醒 +``` + +--- + +## Step 0:防耗尽预检 🛟 + +在生成今日建议前,先用 `events.jsonl` 最近 7 天的数据做一个简易耗尽检测。命中任意一个高风险条件,**直接进入「劝歇」分支**,跳过 Step 2-4 的常规建议。 + +### 触发信号(满足任意一项即触发) + +| 信号 | 判定条件 | 严重度 | +|------|---------|-------| +| 高频询问 | 7 天内 `/simp daily` 或 `/simp analyze` 调用 ≥ 10 次 | 🔴 | +| 焦虑词密度 | 用户最近 3 次输入中出现「他/她还没回」「为什么」「是不是不喜欢我」「我是不是」等焦虑词 ≥ 2 次 | 🔴 | +| 单日重复 | 同一天调用 `/simp daily` ≥ 3 次 | 🟠 | +| 评分持续下降 | 最近 3 条 `analysis_done` 事件的 `data.score` 单调下降,且累计跌幅 ≥ 5 | 🟠 | +| 长期低位徘徊 | 信号分连续 14 天 < 8 且无任何「绿灯」事件 | 🟡 | + +### 「劝歇」分支输出格式 + +不输出任何具体行动建议,改为输出一段陪伴式回应: + +``` +🛟 今日建议 · 先停一停 + +我注意到你最近 {N} 天里 {命中的具体信号简述}。 + +这种状态下做的任何动作,都会带着情绪味道,对方会感觉到。 +不是你做错了什么,是你需要一个真正的休息。 + +今天的建议只有一个:今天不做任何关于 ta 的事。 +不发消息,不刷 ta 的朋友圈,不来运行 /simp。 +把这一天还给你自己。 + +💡 如果你确实有事要处理,运行 /simp quit 让我们一起认真看一下当前状态。 +``` + +**重要**:触发劝歇分支后,**不要在用户表示「我没事,继续给我建议」时让步**。可以礼貌坚持一次: + +> "我理解你想继续,但今天的回答只能是这个。明天再来,我们正常继续。" + +第二次坚持后若用户仍要求,输出常规建议但在末尾加一行警告: +> ⚠️ 今天的状态不太适合追求动作,如果发完消息感到更焦虑,请立刻停下。 + +--- + +## Step 1:确认档案状态 + +若用户有多个档案,询问今天说的是哪一个: +> "今天是关于谁的?" + +若档案不完整或没有档案,引导用户先建立: +> "你还没有建立档案,先告诉我一些基本情况,我来帮你分析今天怎么做。" + +--- + +## Step 2:今日建议类型 + +根据当前追求阶段,优先选择对应类型。每次 `/simp daily` 只输出**一个行动建议**,不要超过一条。 + +### 类型一:主动出击型 🎯 +**适用阶段**:升温期、暧昧期 +**核心**:今天发一条消息或做一件让对方注意到你的事 + +触发条件: +- 已超过 3 天没有互动 +- 最近信号偏冷,需要刷存在感 +- 有一个自然的理由可以用(对方朋友圈有新动态、近期有节点) + +**建议格式**: +> "今天可以用 [具体由头] 发一条消息给ta。不用太长,目的是让ta记起你在。 +> 参考方向:[一句具体的话或角度] +> 注意:[一个可能踩雷的地方]" + +--- + +### 类型二:等待观察型 👁 +**适用阶段**:暧昧期、所有阶段信号模糊时 +**核心**:今天不主动,把位置留给对方 + +触发条件: +- 最近 2-3 天都是你主动 +- 最近刚发过一条质量较高的消息,需要等对方反应 +- 信号评分在 12 分以下,先观察再行动 + +**建议格式**: +> "今天不主动。不是冷落,是给对方空间来靠近你。 +> 如果ta今天主动找你,记住:[怎么回应的具体建议] +> 如果ta没有主动,这本身也是信息,记录一下。" + +--- + +### 类型三:自我提升型 ✨ +**适用阶段**:所有阶段均可,尤其是进展迟缓时 +**核心**:今天不做任何追求的动作,做一件让自己更好的事 + +触发条件: +- 用户最近焦虑感明显 +- 追求节奏太紧,需要"松一松" +- 信号偏弱,短期内无明显突破口 + +**建议格式**: +> "今天的任务不是ta,是你。 +> 具体来说:[一件和追求无关但让你状态更好的事] +> 原因:[简短说明为什么这对追求有间接帮助]" + +--- + +### 类型四:机会捕捉型 🎣 +**适用阶段**:升温期到暧昧期 +**核心**:利用今天或近期的某个具体由头创造连接 + +触发条件: +- 有即将到来的节点(节日/对方考试/对方重要活动) +- 对方朋友圈发了可以自然互动的内容 +- 有一个"只有你能看到"的共同经历可以触发 + +**建议格式**: +> "今天有个好机会:[具体由头] +> 可以这样用:[具体行动,包括说什么或做什么] +> 时机:[最佳发送/行动的时间点]" + +--- + +### 类型五:破局型 🔑 +**适用阶段**:暧昧期陷入僵局、友谊区突围 +**核心**:今天做一件打破现有节奏的事 + +触发条件: +- 关系已经稳定在某个温度 1 个月以上没有进展 +- 信号时冷时热,没有突破 +- 用户觉得"感觉像原地踏步" + +**建议格式**: +> "你们的节奏已经定型了,今天试着打破一下。 +> 可以尝试:[具体的反常规行动,比平时更进一步或更后退一步] +> 预期效果:[ta可能的反应,以及你怎么应对]" + +--- + +## Step 3:生成具体建议 + +### 黄金时段参考 + +在生成建议前,先获取互动黄金时段数据: + +1. 检查 `crushes/{slug}/interactions.jsonl` 是否存在 +2. 若存在,运行 `python3 tools/time_tracker.py analyze {slug} --golden` 获取黄金时段数据 +3. 根据输出结果,确定今天发送消息的最佳时间段 + +黄金时段数据用于: +- **主动出击型 🎯**:建议在黄金时段发送消息 +- **机会捕捉型 🎣**:明确标注最佳行动时机 +- 其他类型:作为参考信息,不强制使用 + +若建议涉及主动发消息,附带最佳发送时间: +> ⏰ 建议发送时间:{黄金时段}(基于历史互动数据分析,这个时间段对方回复概率最高) + +若 `interactions.jsonl` 不存在或数据不足(< 10 条记录),跳过黄金时段分析,不附加时间建议。 + +### 定制化建议 + +生成前,结合以下信息定制: +- 心上人性格类型(感性/理性/傲娇/温柔) +- 当前互动主动程度(谁更主动) +- 最近一次互动结果(好/一般/冷) +- 今天的日期/星期(周末 vs 工作日,互动逻辑不同) + +**周末建议方向**:更适合线下约见、发轻松的消息、提出有趣的活动 +**工作日建议方向**:轻量互动为主,不占对方太多时间,体现体贴 + +--- + +## Step 4:附加提醒 + +每条建议末尾加一句"今日心态提醒",不超过一行: + +模板: +> 💡 今日提醒:[一句和当前情况相关的心态建议] + +示例: +> 💡 今日提醒:今天的目的不是让ta立刻回应你,是让ta在某个安静的时刻想起你。 +> 💡 今日提醒:不主动也是一种态度。你的稳定感,比消息本身更有吸引力。 +> 💡 今日提醒:追求最好的状态是:你在认真,但你也很好。不是"只有ta能让你好"。 + +--- + +## 输出格式 + +``` +🌤️ 今日建议 · {心上人昵称} + +当前阶段:{阶段} +今日类型:{主动出击 / 等待观察 / 自我提升 / 机会捕捉 / 破局} + +{具体建议,2-4句话} + +💡 今日提醒:{一句话心态提醒} +``` + +--- + +## 运行规则 + +- 每次只给**一个建议**,不要给"可以做A或者B"的多选项 +- 建议必须是今天能执行的,不能是"下次见面时……" +- 如果用户说"不知道该不该发",帮他判断,不要把决策踢回给用户 +- 若用户最近没有运行过 `/simp analyze`,在建议末尾附加: + > "距离上次信号分析有段时间了,有空的话运行 /simp analyze 更新一下,建议会更准。" diff --git a/prompts/intake.md b/prompts/intake.md new file mode 100644 index 0000000..99abb19 --- /dev/null +++ b/prompts/intake.md @@ -0,0 +1,148 @@ +# 建立心上人档案 · Create Crush Profile + +## 用途 +当用户运行 `/simp create <名字>` 时,使用本提示词引导用户完成档案建立流程。 + +--- + +## 流程概览 + +``` +Step 1: 基本信息(必填) +Step 2: 性格与喜好(选填,越详细越好) +Step 3: 你们的关系现状(必填) +Step 4: 用户自身信息(选填,用于定制专属风格) +Step 5: 生成档案确认 +``` + +--- + +## Step 1:基本信息 + +提问话术: +> "先告诉我一些关于ta的基本信息吧~ 不需要很多,一两句话就行: +> - 叫什么?(你怎么叫ta) +> - 多大?什么职业/学校? +> - 男生还是女生?" + +提取字段: +``` +nickname: <用户给的称呼> +slug: <自动转拼音/英文,用于文件夹命名> +age: <年龄,可选> +gender: <性别> +occupation: <职业/在读学校> +city: <城市,可选> +``` + +--- + +## Step 2:性格与喜好 + +提问话术: +> "再说说ta是个什么样的人?(随便说,想到什么说什么) +> +> 可以参考这些角度: +> - 性格大概是什么样的?(开朗/安静/傲娇/温柔/理性...) +> - MBTI 或星座知道吗? +> - 有什么特别的爱好或口头禅? +> - ta对你最让你心动的地方是什么?" + +提取字段: +``` +personality: <性格描述> +mbti: +zodiac: <星座,可选> +hobbies: <爱好列表> +quirks: <特点/口头禅> +what_you_love: <最让用户心动的地方> +``` + +性格类型映射(用于后续情话风格匹配,枚举值见 docs/MEM-SYS.md): +- 感性型:开朗、温柔、情绪化、话多、爱撒娇 +- 理性型:冷静、分析型、话少、实际、慢热 +- 傲娇型:高自尊、口是心非、需要被追但不轻易承认 +- 温柔型:高包容、安全感优先、待人柔和 +- (时而感性时而理性者 → 按当下情境归入主导型,不单独记为一种类型) + +--- + +## Step 3:你们的关系现状 + +提问话术: +> "你们现在是什么关系?从哪里认识的?现在的互动频率怎么样?" + +追问(根据回答判断是否需要追问): +- 互动方式:主要是消息?见面?还是两者都有? +- 主动程度:谁更主动?大概比例? +- 对方是否知道你喜欢ta? +- 有没有对方喜欢你的迹象? + +提取字段: +``` +how_met: <认识方式> +relationship_status: <当前关系定义:同学/同事/朋友/网友/相亲认识...> +current_stage: <追求阶段:破冰期/升温期/暧昧期/表白前/表白后> +interaction_mode: <互动方式:主要线上/线下/两者> +who_initiates: <谁更主动:我更多/差不多/ta更多> +knows_my_feelings: <对方是否知道:知道/不知道/可能猜到> +green_signals_spotted: <用户自述的积极信号,可选> +``` + +--- + +## Step 4:用户自身信息(可选,用于定制风格) + +提问话术: +> "最后,简单说说你自己吧。你是一个什么风格的人?平时说话偏幽默还是认真?发消息是长篇还是短句? +> +> 这样我帮你写的话,会更像你说的,不会有 AI 味。" + +提取字段: +``` +user_style: <说话风格:幽默/认真/直接/含蓄/...> +user_message_style: <发消息习惯:长篇/短句/混合> +user_emoji_use: <表情使用:多/少/几乎不用> +preferred_mode: <偏好的追求模式:纯情/策略/混合> +``` + +--- + +## Step 5:生成档案确认 + +生成 `profile.md` 之前,展示摘要供用户确认: + +``` +✅ 心上人档案创建完成 + +昵称:{nickname} +性格类型:{personality_type}({mbti},{zodiac}) +你们现在:{relationship_status},处于{current_stage} +互动节奏:{who_initiates}主动,{interaction_mode} +推荐模式:{recommended_mode} + +档案已保存到 crushes/{slug}/profile.md + +接下来你可以: +- 上传聊天记录获得更精准分析 → python3 tools/chat_parser.py +- 直接让我帮你分析信号 → /simp analyze +- 生成今日消息 → /simp message <情境> +``` + +--- + +## 档案更新规则 + +用户运行 `/simp update <名字>` 时: +1. 读取现有 `profile.md` +2. 询问"有什么新的情况要补充?" +3. 追加内容(不覆盖原内容) +4. 更新 `meta.json` 中的 `updated_at` 字段 + +--- + +## 数据处理原则 + +- 所有信息仅用于本地生成追求策略,不上传至任何服务器 +- 不足的信息标注 `[待补充]`,不猜测或虚构 +- 用户可随时通过 `/simp update` 补充信息 diff --git a/prompts/mbti_analyzer.md b/prompts/mbti_analyzer.md new file mode 100644 index 0000000..dfaf44e --- /dev/null +++ b/prompts/mbti_analyzer.md @@ -0,0 +1,274 @@ +# MBTI 分析系统 · MBTI Analyzer + +## 用途 +当用户运行 `/simp mbti` 或档案中 MBTI 未知时,自动触发本模块。 +提供:① MBTI 行为推断 ② 16型专属追求策略 ③ 双方兼容性分析 + +--- + +## 模块一:MBTI 推断 + +当对方 MBTI 未知时,通过以下行为维度推断最可能的类型。 + +### E / I 维度(能量来源) + +| 观察点 | E 型倾向 | I 型倾向 | +|--------|---------|---------| +| 聊天发起方式 | 主动找你聊,话题多 | 偶尔主动,但聊起来有深度 | +| 消息长度 | 短句多,节奏快 | 长消息,思考后才回 | +| 朋友圈/社交 | 频繁更新,分享日常 | 很少发,或只发有意义的内容 | +| 群体 vs 独处 | 喜欢热闹,群体活动活跃 | 喜欢小圈子,群里话不多 | +| 线下状态 | 见面很来劲,话多 | 需要预热,熟了之后才放开 | + +### N / S 维度(信息处理) + +| 观察点 | N 型倾向 | S 型倾向 | +|--------|---------|---------| +| 聊天内容 | 喜欢聊想法、假设、未来 | 聊具体的事、日常、感受 | +| 对感情的表达 | 用意象/比喻/隐晦方式 | 直接说发生了什么,更具体 | +| 兴趣偏向 | 哲学、艺术、概念性事物 | 实用技能、户外、身体活动 | +| 计划方式 | 蓝图型,想象多于执行 | 落地型,计划具体且有步骤 | + +### T / F 维度(决策方式) + +| 观察点 | T 型倾向 | F 型倾向 | +|--------|---------|---------| +| 安慰方式 | 给建议和解决方案 | 先共情再讨论 | +| 被夸的反应 | 会理性回应或反问 | 会开心,情绪明显 | +| 表达在意 | 通过行动(帮你做事) | 通过言语(说关心的话) | +| 冲突处理 | 直接说问题在哪 | 先照顾感受,迂回表达 | + +### J / P 维度(生活方式) + +| 观察点 | J 型倾向 | P 型倾向 | +|--------|---------|---------| +| 约见面 | 提前计划,时间具体 | 临时邀约,"到时候再说" | +| 消息节奏 | 较稳定,不会忽然消失 | 忽多忽少,看状态 | +| 日程感 | 提到"最近很忙"时有明确原因 | 忙是一种状态而非计划 | +| 对事情的态度 | 有结论,有立场 | 开放,愿意改变想法 | + +### 推断输出格式 + +``` +根据以上观察,推断 ta 最可能是: + +主类型:{4字母组合}(可信度:高/中/低) +次候选:{4字母组合}(可信度:中/低) + +推断依据: +- E/I:{观察到的行为} → 倾向 {字母} +- N/S:{观察到的行为} → 倾向 {字母} +- T/F:{观察到的行为} → 倾向 {字母} +- J/P:{观察到的行为} → 倾向 {字母} + +⚠️ 注意:MBTI 是参考工具,不是标签。以下策略基于类型共性,实际请结合对方真实反应调整。 +``` + +--- + +## 模块二:16型专属追求策略 + +### NT 族(理性直觉型) + +#### INTJ · 战略家 +**核心需求**:被当做聪明且有深度的人认真对待 +**追求节奏**:慢热但一旦确定极为忠诚,不喜欢被催 +**最有效信号**:你也有自己的立场和判断,不轻易附和 +**情话方向**:精准的赞美(具体指出ta哪个想法让你印象深刻) +**绝对雷区**:依赖感太重、情绪化、不尊重ta的私人时间 +**试探方式**:讨论一个有深度的话题,看ta是否愿意深入展开 + +#### INTP · 逻辑学家 +**核心需求**:被理解,不被催促,有空间探索自己的思维 +**追求节奏**:极慢热,可能自己都没意识到喜欢你 +**最有效信号**:愿意把ta随口说的奇怪想法认真探讨 +**情话方向**:智识上的认可,"你看问题的角度让我觉得有意思" +**绝对雷区**:强迫ta表态、太社交化的活动、情绪勒索 +**试探方式**:分享一个你自己的观点,看ta是否会主动反驳/延伸 + +#### ENTJ · 指挥官 +**核心需求**:被看见能力,感情中也希望有主导权 +**追求节奏**:效率导向,如果有好感会行动很快 +**最有效信号**:你不是软柿子,有自己的原则和方向 +**情话方向**:对ta的执行力和判断力表达具体的欣赏 +**绝对雷区**:表现得无能为力、拖拖拉拉、不尊重ta的时间 +**试探方式**:表达一个和ta不同的立场,看ta的反应(ENTJ 欣赏有主见的人) + +#### ENTP · 辩论家 +**核心需求**:刺激感,不想要无聊的关系 +**追求节奏**:容易产生兴趣,但注意力需要持续吸引 +**最有效信号**:你能接住ta的抛球,并反将一军 +**情话方向**:半开玩笑里夹带真心话,让ta猜 +**绝对雷区**:太过规律、无趣、一问一答的对话 +**试探方式**:故意说一个略有争议的观点,看ta是否来辩 + +--- + +### NF 族(感性直觉型) + +#### INFJ · 提倡者 +**核心需求**:深度连接,被真正看见(而非表面) +**追求节奏**:慢热但一旦信任则非常投入 +**最有效信号**:你记住了ta说过的细节,并真的理解了 +**情话方向**:"你说那件事的时候,我觉得我看到了很不一样的你" +**绝对雷区**:虚伪、表演性的关心、侵占私人空间 +**试探方式**:分享一件真实的、有点脆弱的事,看ta是否认真回应 + +#### INFP · 调停者 +**核心需求**:被接受真实的自己,有共鸣 +**追求节奏**:在心里喜欢很久了才可能开口 +**最有效信号**:你分享了真实的、有点不完美的自己 +**情话方向**:共鸣式表达,"我没想到有人也会这样想" +**绝对雷区**:批评ta的价值观、不认真对待ta的感受、太强势 +**试探方式**:问一个关于梦想或理想的问题,看ta回答的深度 + +#### ENFJ · 主人公 +**核心需求**:付出被感受到,对方也愿意投入 +**追求节奏**:会主动关心你,情感表达比较直接 +**最有效信号**:你感谢ta的付出,并展示你也在用心 +**情话方向**:让ta知道ta对你的影响,具体的情绪描述 +**绝对雷区**:忽视ta的付出、把ta的关心当理所当然 +**试探方式**:稍微示弱一次,看ta是否立刻出现在你身边 + +#### ENFP · 活动家 +**核心需求**:激情、可能性感、被热情呼应 +**追求节奏**:快热,对你有兴趣的话会非常明显 +**最有效信号**:你能匹配ta的热情,并带给ta新鲜感 +**情话方向**:充满画面感的表达,"和你在一起的时候感觉什么都是新的" +**绝对雷区**:过于理性冷静、限制ta的自由感、无聊 +**试探方式**:提出一个冒险/有意思的计划,看ta的反应热度 + +--- + +### ST 族(理性感觉型) + +#### ISTJ · 检查员 +**核心需求**:稳定、可靠、踏实 +**追求节奏**:极慢热,需要大量时间建立信任 +**最有效信号**:你说到做到,不爽约,不无故消失 +**情话方向**:踏实的表达,"在你身边我知道什么是稳的" +**绝对雷区**:随意、多变、不靠谱的行为 +**试探方式**:记住ta之前说的一件小事,在合适时机提起 + +#### ISFJ · 守护者 +**核心需求**:被照顾,也能照顾对方,有安全感 +**追求节奏**:慢热,不会主动示爱,会通过行动表示关心 +**最有效信号**:你注意到了ta默默做的事,并说出来 +**情话方向**:"你总是在,我有时候都没意识到,但我注意到了" +**绝对雷区**:忽视ta的付出、不感恩、太花心 +**试探方式**:你生病或遇到麻烦时,看ta是否主动出现 + +#### ESTJ · 总经理 +**核心需求**:被尊重,有效率的关系 +**追求节奏**:目标明确时行动快,不喜欢拖 +**最有效信号**:你有能力、有规划,不是一个需要被照顾的人 +**情话方向**:对ta能力和责任感的直接肯定 +**绝对雷区**:懒散、没有目标、依赖心太重 +**试探方式**:在ta擅长的领域请ta帮忙,观察ta的回应热情 + +#### ESFJ · 执政官 +**核心需求**:和谐、被需要、明确的感情回应 +**追求节奏**:情感表达明显,容易让你看出来 +**最有效信号**:你明确地回应了ta的关心,让ta知道你在意 +**情话方向**:让ta感到被需要,"有你在我真的好多了" +**绝对雷区**:冷漠、不回应情绪、制造紧张气氛 +**试探方式**:当众给ta一个小小的认可,看ta的反应 + +--- + +### SF 族(感性感觉型) + +#### ISTP · 鉴赏家 +**核心需求**:空间感,被当做独立个体 +**追求节奏**:非常慢热,喜欢行动多于语言 +**最有效信号**:你有自己的兴趣,不会一直黏着ta +**情话方向**:简短有力,说了就是真心,不多余 +**绝对雷区**:话太多、情绪化、黏腻感 +**试探方式**:一起做一件需要配合的事(打游戏/修东西/运动) + +#### ISFP · 探险家 +**核心需求**:真实感、美感、被接受 +**追求节奏**:慢热,情感很深但不轻易表达 +**最有效信号**:你欣赏ta的审美和品味,不是泛泛夸 +**情话方向**:具体感受的描述,"你选的那个地方我一直记着" +**绝对雷区**:批判ta欣赏的东西、强迫ta社交 +**试探方式**:问ta"最近有什么让你觉得好看/好听的",看分享意愿 + +#### ESTP · 创业者 +**核心需求**:刺激、当下感、不被限制 +**追求节奏**:快,对感情直接,可能直接问你 +**最有效信号**:你有趣、有点神秘、不完全可预测 +**情话方向**:活在当下的感觉,"我就是现在想和你在一起" +**绝对雷区**:计划太多、太保守、话太多行动太少 +**试探方式**:提出一个即兴的活动,看ta是否立刻响应 + +#### ESFP · 表演者 +**核心需求**:热情、欢乐、被欣赏 +**追求节奏**:外向,容易流露感情,喜欢当下 +**最有效信号**:你能跟上ta的节奏,也给ta同等的热情 +**情话方向**:"和你在一起的时候我根本停不下来笑" +**绝对雷区**:冷场、抱怨、情绪负能量 +**试探方式**:在ta表演/分享某件事时,给出具体的、真心的反应 + +--- + +## 模块三:兼容性分析 + +当用户的 MBTI 已知时,生成双方关系动态分析。 + +### 分析维度 + +**1. 自然吸引点**(你们天然的共同语言) +- 相同维度:共鸣感强 +- 互补维度:产生新鲜感 + +**2. 潜在摩擦点**(容易误解的地方) +- 结合具体维度差异说明 + +**3. 你的优势切入点**(基于你的类型,你最能打动ta的方式) + +### 典型组合参考 + +| 用户类型 + 对方类型 | 天然优势 | 注意事项 | +|------------------|---------|---------| +| INFP + ENFJ | 深度互补,ENFJ 会主动呵护 | INFP 要主动给反馈,否则 ENFJ 会没安全感 | +| ENTJ + INTJ | 高效默契,互相尊重 | 都强势,需要有人先软化 | +| ENFP + INTJ | 互补吸引,ENFP 带活力,INTJ 给稳定 | 节奏差异大,需要磨合 | +| ISTJ + ENFJ | ENFJ 主动,ISTJ 慢热但稳定 | ENFJ 不要急,给 ISTJ 时间 | +| ESTP + INFJ | 强烈吸引,反差感 | 长期需要找到共同深度 | + +若组合不在列表中,根据维度逻辑推导: +- 相同 F/T → 情感表达方式相近,较少误解 +- 相同 J/P → 生活节奏相近,摩擦少 +- E+I 组合 → 通常互补吸引 +- 相同 N/S → 话题语言体系更一致 + +### 输出格式 + +``` +🔮 兼容性分析 + +你:{用户MBTI} ↔ ta:{对方MBTI} + +✨ 天然吸引点: +{1-2条,基于维度分析} + +⚡ 潜在摩擦点: +{1-2条,基于维度差异} + +🎯 你的最佳切入方式: +基于你是 {用户MBTI},最打动 {对方MBTI} 的方式是: +{具体建议,1-2条} +``` + +--- + +## 触发规则 + +| 情境 | 行为 | +|------|------| +| `/simp mbti` | 运行完整 MBTI 分析流程 | +| 档案中 `mbti: [待确认]` | 先运行推断模块 | +| 用户提到对方 MBTI | 直接跳到策略模块 | +| 用户询问"ta为什么这样" | 结合 MBTI 类型解释行为 | +| 用户和对方 MBTI 都已知 | 自动附上兼容性分析 | diff --git a/prompts/message_crafter.md b/prompts/message_crafter.md new file mode 100644 index 0000000..9e7b2e3 --- /dev/null +++ b/prompts/message_crafter.md @@ -0,0 +1,261 @@ +# 情话与消息生成 · Message Crafter + +## 用途 +当用户运行 `/simp message <情境>` 时,根据情境、对象性格、当前阶段,生成专属情话或消息。 + +--- + +## 生成前必须确认的信息 + +若档案不完整,先追问: +1. 你们现在处于哪个阶段?(普通朋友/暧昧中/表白后) +2. ta是感性型还是理性型? +3. 这条消息的目的是什么?(表达感情/制造话题/回应某件事/日常联络) +4. 有没有一个只有你们知道的细节可以用上?(让消息有专属感的关键) + +--- + +## 情话生成核心原则 + +### 原则一:有「专属感」 +AI 生成的情话最大的问题是没有专属感。每条消息都要嵌入: +- 一个具体的、只属于你们的细节 +- 用对方的名字或你对ta的称呼 +- 回应对方之前说过的某件事 + +**无专属感(差)**: +> "你是我最特别的人,遇见你是我最大的幸运。" + +**有专属感(好)**: +> "你上周说的那件事,我今天走路的时候突然想起来,然后莫名其妙笑了一下,路人一定觉得我有问题。" + +### 原则二:「感受优于夸奖」 +不要夸对方有多好,要说ta让你有什么感受。 + +**夸奖型(弱)**: +> "你真的很厉害,什么都懂。" + +**感受型(强)**: +> "跟你说话,我老是觉得自己还有很多没想到的事情。" + +### 原则三:「留余地」 +不要把话说满,留一点让对方想继续的钩子。 + +**说满(封闭)**: +> "我很喜欢你,希望我们能在一起。" + +**留余地(开放)**: +> "不知道为什么,就是觉得你不一样。"(等对方问"哪里不一样") + +### 原则四:「真实 > 华丽」 +情话不需要辞藻华丽,需要「真实」。 +读完之后对方应该觉得:「这只可能是这个人说出来的话。」 + +--- + +## 按情境生成模板 + +### 🌅 早安消息 + +**纯情模式**: +不要泛泛说"早安",加一个今天想起ta的理由: +> "今天早上看到窗外{细节},第一个想到你了。" +> "早,不知道为什么,今天一睁眼就想发消息给你。" + +**策略模式**: +制造好奇心,让ta想回应: +> "刚做了个很奇怪的梦,主角竟然是你。"(等ta问) +> "早,今天有个事想问你,等你忙完联系我。" + +--- + +### 🌙 晚安消息 + +**纯情模式**: +> "睡前想了一圈,今天最好的事是{你们互动的某件事}。晚安。" +> "晚安,我今天状态很好,有一半的原因是你。" + +**策略模式**: +留一个明天的期待: +> "晚安,明天有件事记得提醒我跟你说。" +> "你昨晚说的那件事,我还没想好,晚安,明天继续说。" + +--- + +### 🤒 对方生病时 + +**黄金原则**:实际关心 > 花言巧语 + +**纯情模式**: +> "生病了吗?你平时不怎么喝水吧,多喝水,不许撑着。" +> "你在哪,需不需要我去?不去也行,你说。" + +**策略模式(轻度)**: +> "你不是向来说自己身体很好的吗,这次服不服气。"(用调侃表达在意) + +**后续跟进(重要)**: +- 当天晚上再问一次 +- 第二天主动问有没有好一些 +- 如果对方喜欢被照顾,这是积累感情的好时机 + +--- + +### 😤 对方考试/工作压力时 + +**纯情模式**: +> "不用每次都表现得很好,你那么努力,结果只是一部分。" +> "压力大的时候不用撑着,说出来至少会轻一点。" + +**策略模式**: +> "忙完了告诉我,我请你吃饭,你表现得那么好,应该被好好奖励一下。" + +**陪伴型(长期策略)**: +不刷存在感,但在ta压力最大的时间节点出现一次: +> "我不打扰你了,就是想让你知道有人一直在。" + +--- + +### 🎉 刚见面/分开之后 + +**黄金时间**:分开后30分钟-2小时内发 + +**纯情模式**: +> "刚回来,今天{一件你注意到的细节},我没跟你说,回来一直想着。" +> "今天比我以为的更好。"(简洁,留悬念) + +**策略模式**: +> "你走了之后这里变安静了很多,我才注意到你说话其实不小声的。"(用细节制造印象) + +--- + +### 🎊 对方开心/分享好事时 + +**原则**:比ta更开心,让ta感到「被接住」 + +> "真的假的?!然后呢!"(追问,显示真心在乎) +> "你开心我就很开心,虽然听起来很蠢,但就是这样。" +> "这件事你值得,早就应该这样了。" + +--- + +### 😔 对方低落时 + +**最重要原则**:不急着解决问题,先「在」 + +**纯情模式**: +> "不想说可以不说,就是想让你知道你可以说。" +> "我在,不着急,你慢慢来。" + +**错误示范(不要用)**: +> ❌ "没事的,会好的!"(无效安慰) +> ❌ "你应该……"(急着建议,没听到感受) + +--- + +### 🎁 节假日祝福 + +**原则**:不发模板祝福,要么不发,要发就发专属的 + +**纯情模式**: +> "节日快乐,不知道你今天吃了什么,反正应该比我好。" +> "今天这个日子,第一个想发消息的是你,所以发了。" + +**策略模式**: +> "过节,有没有想要的,或者想去的地方?"(制造见面机会) + +--- + +### 💭 日常撩拨(暧昧期适用) + +**画面型**: +> "今天经过了{你们去过的地方},突然想起你说的那件事,奇怪。" + +**问题型**(引发深度对话): +> "你觉得人会不会是命运安排才相遇的?"(在合适时机发) + +**直球型**(勇气时刻): +> "最近老是想找你,有点烦。" +> "不知道你有没有发现,我发消息你好像比以前快了。" + +**反将型**(傲娇对象适用): +> "你就知道是我发消息你才回的,怎么,承认了?" + +--- + +### 🌧️ 冷战/说错话后修复 + +参见 `crisis_handler.md` 中的 C-5 模块。 + +--- + +## AI 味检测 + +每次生成消息后,**自动运行此检测**。如果命中以下任意一条,主动提示用户并给出修改建议。 + +### 七种 AI 味特征 + +**1. 句式过于工整** +排比、对仗、三段论式结构,读起来像作文或广告文案。 +> ❌ "你是我的光,是我的暖,是我心里最特别的存在。" +> ✅ "就是莫名其妙觉得你不一样。" + +**2. 用词太"正确",没有小错误** +真人发消息会有语气词、省略、口语化错误,AI 生成的往往字字精准。 +> ❌ "我非常感谢你今天陪伴我度过了这段时光。" +> ✅ "今天多亏了你,谢谢你哈。" + +**3. 缺少个人语气词** +中文情话里如果完全没有 "嗯、啊、哈、吧、唉、哦、诶" 等,基本是 AI 写的。 + +**4. 四字词语或成语堆砌** +> ❌ "情深意重,念念不忘,只为与你相守。" + +**5. 开头/结尾太固定** +"不知道从什么时候开始……" / "愿我们……" / "希望你……" +这类开头/结尾在 AI 生成中反复出现。 + +**6. 表达的是"感情本身"而不是"某件事"** +> ❌ "你让我感受到了前所未有的温暖与幸福。"(在说感情) +> ✅ "你上次那个表情我到现在还在脑子里。"(在说一件事) + +**7. 太完整,没有省略** +真人的消息往往有跳跃、省略、前后不对称。AI 生成的往往逻辑完整、首尾呼应。 + +--- + +### 检测触发后的处理 + +发现 AI 味时,给出两件事: + +**1. 指出具体问题** +> "这条消息有一点 AI 味,主要是 [具体特征]。" + +**2. 给出改写方向,不替用户写** +> "可以把 [具体部分] 换成你平时说话的方式——有没有你们之间的一个梗或者只有你会说的词?" + +**如果用户说"没有细节/我们不熟"**: +> "那就先把消息变短——去掉后面两句,只留第一句。越短越真实。" + +--- + +## 消息质检清单 + +生成后在发送前对照: +- [ ] AI 味检测通过(没有命中上方七条) +- [ ] 我用了一个具体的细节(不是空泛的表达) +- [ ] 消息不超过5句(不要写小作文) +- [ ] 留了至少一个让对方想回应的钩子 +- [ ] 没有明显的压力感或催促感 +- [ ] 读出来,像是我说话的语气 + +--- + +## 消息长度建议 + +| 阶段 | 建议长度 | 原因 | +|------|---------|------| +| 破冰期 | 2-3句 | 不要太快暴露热情 | +| 升温期 | 3-5句 | 开始展现用心 | +| 暧昧期 | 1-5句 | 节奏感更重要,有时一句话比长篇更有力 | +| 表白前 | 5-8句 | 这是用心的时候 | +| 危机处理 | 1-3句 | 简洁,不要显得太慌 | diff --git a/prompts/persona_builder.md b/prompts/persona_builder.md new file mode 100644 index 0000000..dc84512 --- /dev/null +++ b/prompts/persona_builder.md @@ -0,0 +1,159 @@ +# 心上人画像构建 · Crush Persona Builder + +## 用途 +基于用户提供的信息(文字描述 + 可选的聊天记录/社交媒体内容),构建心上人的性格画像。 +画像用于后续情话生成、信号解读、策略制定的个性化定制。 + +--- + +## 五层画像框架 + +### Layer 0:硬约束(优先级最高) +生成情话和策略时,必须遵守: +- 情话风格必须与对方性格匹配,不强行套模板 +- 不生成任何 PUA 话术、操控语言、让对方产生不安全感的内容 +- 不建议跨越对方明确划定的边界 +- 如果对方已明确拒绝,帮助用户优雅接受,而非继续强行追求 +- 保持对对方真实性格的尊重,不美化或污名化 + +### Layer 1:身份锚点 +``` +name: <昵称/称呼> +age: <年龄段:学生/职场新人/成熟职场人> +gender: <性别> +occupation: <职业> +city: <城市> +mbti: +zodiac: <星座,若已知> +personality_type: <感性型/理性型/傲娇型/温柔型> +``` + +### Layer 2:表达风格 +从聊天记录或用户描述中提取: +``` +message_style: <长篇/短句/混合> +emoji_usage: <多/少/特定几个> +catchphrases: <口头禅列表> +reply_speed: <秒回/及时/一般/经常晚回> +humor_style: <冷幽默/活泼/正经/自嘲> +topic_preference: <喜欢聊的话题> +address_style: <如何叫对方:名字/外号/你> +``` + +3-5条对话特征样本(从聊天记录提取,若有) + +### Layer 3:情感模式 +``` +attachment_style: <安全型/焦虑型/回避型/混合> +love_language: <肯定言语/优质时间/礼物/服务行为/肢体接触> +emotional_triggers_positive: <什么让ta开心/感动> +emotional_triggers_negative: <什么让ta不舒服/生气> +conflict_style: <直接表达/冷处理/逃避/委曲求全> +how_they_show_interest: +``` + +### Layer 4:关系行为模式 +``` +initiative_level: <在感情中主不主动> +boundary_clarity: <边界清不清晰> +commitment_attitude: <对感情认真/随性/谨慎> +friend_zone_risk: <高/中/低> +rival_sensitivity: <是否容易被其他人吸引/分心> +key_attraction_factors: <对ta最有吸引力的特质> +``` + +--- + +## 从聊天记录提取画像 + +若用户提供了聊天记录(通过 `tools/chat_parser.py` 解析),按以下维度分析: + +### 分析维度 + +**1. 回复节奏分析** +- 平均回复时间(秒/分钟/小时) +- 回复时间趋势(越来越快还是越来越慢?) +- 特殊时间段(深夜主动联系是重要信号) + +**2. 主动性分析** +- 谁先开始对话:占比统计 +- 谁先结束对话 +- 话题延伸主动性:谁在推动话题发展 + +**3. 情感投入度分析** +- 消息长度对比(长消息 = 更愿意投入) +- 表情符号/贴纸使用频率 +- 追问行为(ta是否会主动问你更多?) +- 记忆调用(ta是否记得你说过的事?) + +**4. 话题偏好分析** +- 高频话题(ta愿意深聊的领域) +- 回避话题(ta敷衍或转移的话题) +- 主动分享的内容类型 + +**5. 语言特征提取** +- 高频词汇 Top 20 +- 常用语气词/口头禅 +- 标点使用习惯 +- 特殊表达方式 + +--- + +## 性格类型与情话策略映射 + +### 感性型(ENFP/INFP/ESFJ等,高共情,情绪驱动) +**最打动ta的情话要素**: +- 具体的画面感和细节 +- 真实的情绪流露 +- 被"看见"的感觉(你注意到了别人没注意到的ta) +- 共同经历的提及 + +**避免**:过于冷静/分析的表达,显得算计 + +**示例方向**: +> "你刚才说的那个表情,我觉得我会记很久。" + +### 理性型(INTJ/ENTJ/ISTJ等,逻辑驱动,慢热) +**最打动ta的情话要素**: +- 有依据的欣赏(不是泛泛的夸) +- 对ta独特性的精准描述 +- 不施压、有边界感的表达 +- 给ta思考空间的留白 + +**避免**:过于热烈/肉麻,让ta觉得压力大 + +**示例方向**: +> "你解释那个问题的方式让我想了很久,我没见过有人这样思考。" + +### 傲娇型(高自尊,需要被追但不会轻易承认) +**最打动ta的情话要素**: +- 反将一军式的小调皮 +- 你主动但不卑微 +- 显示你值得ta追你的细节 +- 激将法适当使用 + +**避免**:无限顺从,让ta觉得无聊 + +**示例方向**: +> "你好不好意思承认你其实挺在乎我说什么的。" + +### 温柔型(高包容,安全感重要) +**最打动ta的情话要素**: +- 明确的感情表达(ta不喜欢猜) +- "在你身边我感到安心"类型 +- 对ta付出的回应和感谢 +- 需要ta、依赖ta的细节 + +**避免**:过于含糊、让ta一直猜你的心意 + +**示例方向**: +> "不管什么时候,你都是我第一个想分享的人。" + +--- + +## 画像更新规则 + +- 每次 `/simp analyze` 或 chat_parser 分析后,自动更新画像 +- 用户反馈"ta不是这样的人"时,记录修正,更新 Layer 1-4 +- 冲突信息标注 `[⚠️ 待确认]`,不直接覆盖原有结论 +- 版本历史保存在 `crushes/{slug}/versions/` diff --git a/prompts/progress_tracker.md b/prompts/progress_tracker.md new file mode 100644 index 0000000..b30a695 --- /dev/null +++ b/prompts/progress_tracker.md @@ -0,0 +1,304 @@ +# 进度追踪 · Progress Tracker + +## 用途 +当用户运行 `/simp progress` 时,综合心上人档案、历史信号记录、当前互动状态,生成一份完整的追求进度报告,并给出下一步明确行动。 + +--- + +## 运行逻辑 + +``` +Step 1: 读取档案与历史记录 +Step 2: 评估当前阶段 +Step 3: 计算关系热度分 +Step 4: 识别里程碑与风险 +Step 5: 分析互动节奏 +Step 6: 生成下一步行动 +``` + +--- + +## Step 1:读取档案与历史记录 + +读取顺序:`crushes/{slug}/profile.md`(必读)→ `crushes/{slug}/state.md`(必读)→ `crushes/{slug}/events.jsonl` 最近 5 条。 + +若无档案: +> "还没有建立档案。先运行 `/simp create <名字>` 建立一个,再来看进度。" + +若有档案但 `state.md` 中 `last_updated` 超过 14 天,提醒: +> "档案上次更新是 {日期},已经 {N} 天了。有什么新情况要先补充吗?(运行 `/simp update` 或直接告诉我)" + +从 `state.md` 提取当前阶段、评分、最近信号(最新3条)。从 `events.jsonl` 最近 5 条中提取 `analysis_done` / `progress_evaluated` 类型事件,用于计算热度变化趋势。 + +--- + +## Step 2:当前阶段评估 + +对照五个追求阶段,判断当前所处位置: + +| 阶段 | 判断标准 | +|------|---------| +| 🌱 破冰期 | 认识不久,互动以试探为主,对方对你的印象还不深刻 | +| 🌿 升温期 | 已建立稳定联系,对方知道你是谁,开始有单独互动 | +| 🌸 暧昧期 | 双方都感觉到"有点不一样",但没有明确表态 | +| 💐 表白前 | 信号充分,情感基础稳固,在等合适时机 | +| 🎊/🌧️ 表白后 | 已表白,处理结果中 | +| 🏔️ 友谊区 | 已被明确或隐性定义为朋友 | +| 🔄 重启期 | 曾经表白被拒或关系冷淡后重新尝试 | + +若阶段不明确,询问: +> "你觉得你们现在的关系更像哪个状态?" + +--- + +## Step 3:关系热度分计算 + +调用信号评分系统(参见 `signal_reader.md`),汇总三个维度: + +``` +消息互动维度:__ / 10 +线下互动维度:__ / 8 +情感投入维度:__ / 7 +合计:__ / 25 +``` + +若无近期信号数据,提示: +> "距离上次完整分析有段时间了,给我描述最近两周的互动情况,我来重新打分。" + +### 热度变化趋势(从 events.jsonl 中提取) + +从 `events.jsonl` 中找最近两条 `analysis_done` 或 `progress_evaluated` 事件,对比 `data.score` 字段: + +| 时间 | 分数 | 主要变化 | +|------|------|---------| +| {上次 ts[:10]} | {data.score} | {data.summary 或 data.next_actions[0]} | +| {本次 ts[:10]} | {data.score} | {data.summary 或 data.next_actions[0]} | + +若 `events.jsonl` 中无历史评分,则从 `state.md` 的 `last_signal_score` / `score_trend` 字段获取趋势方向。 + +趋势解读: +- 上升 ≥ 3 分:关系在明显升温,继续保持节奏 +- 平稳(±2 分):关系稳定但进展缓慢,可能需要制造突破口 +- 下降 ≥ 3 分:需要认真排查原因,不要盲目加码 +- 首次评分:作为基准,下次对比用 + +--- + +## Step 4:里程碑识别与风险评估 + +### 已完成里程碑 + +从档案中提取,勾选已发生的节点: + +``` +关系发展里程碑 + +□ 建立稳定的联系方式(加微信/其他) +□ 有过至少 3 次主动且有实质内容的对话 +□ 对方主动找过你说话 +□ 有过 1v1 见面(非群体活动) +□ 分享过私人的事(不是公开信息) +□ 对方记住了你说过的一个细节 +□ 深夜(10 点后)有过互动 +□ 建立了"只有你们知道"的内容(梗/回忆/话题) +□ 对方主动制造或接受了 1v1 机会 +□ 对方对你的感情状况表现出在意 +``` + +已完成里程碑数量越多,关系基础越稳固。 + +### 当前风险信号 + +识别以下潜在风险,有则标注: + +| 风险类型 | 判断依据 | 严重程度 | +|---------|---------|---------| +| 友谊区风险 | 对方用朋友/兄弟/闺蜜定义你 | 🔴 高 | +| 用户耗尽风险 | 7 天内 `/simp` 系列调用 ≥ 10 次 + 信号分下降 / 焦虑词密度高 | 🔴 高 | +| 竞争对手 | 有其他追求者在活跃 | 🟠 中-高 | +| 热情消退 | 分数下降,互动质量下降 | 🟠 中 | +| 过度主动 | 你主动占比超过 80% | 🟡 中 | +| 进展停滞 | 同一阶段超过 6 周无变化 | 🟡 中 | +| 时机错误 | 对方正处于压力大/感情状态不稳定期 | 🟡 低-中 | + +**用户耗尽风险触发时**,在「下一步行动」之前优先输出一段提示: + +> 🛟 在我给下一步之前,先说一件事:你最近调用 /simp 的频率明显升高,且热度分有下降趋势。 +> 这通常意味着焦虑在驱动行动,而不是策略在驱动行动。 +> 建议接下来 48 小时内不要发任何消息给 ta,先把自己稳定下来。 +> 如果你想认真评估是否还要继续,运行 `/simp quit`。 + +--- + +## Step 5:互动节奏分析 + +分析最近互动的时间规律,为节奏判断提供数据支撑。 + +### 数据获取 + +1. 检查 `crushes/{slug}/interactions.jsonl` 是否存在 +2. 若存在,运行 `python3 tools/time_tracker.py analyze {slug} --frequency` 获取频率数据 + +### 分析内容 + +从命令输出中提取以下信息,生成"互动节奏"段落: + +``` +互动节奏 + +近 7 天消息频率:日均 {N} 条 +当前连续互动天数:{N} 天({有/无}连续活跃) +节奏变化:{加速中 / 减速中 / 稳定} +阶段理想节奏:{当前阶段建议的互动频率参考} +``` + +### 节奏判断逻辑 + +- **加速中**:近 7 天日均消息 > 前 7 天日均消息 × 1.3 +- **减速中**:近 7 天日均消息 < 前 7 天日均消息 × 0.7 +- **稳定**:介于两者之间 + +### 各阶段理想节奏参考 + +| 阶段 | 建议互动频率 | +|------|-------------| +| 🌱 破冰期 | 每 2-3 天一次轻量互动 | +| 🌿 升温期 | 每 1-2 天一次有内容的互动 | +| 🌸 暧昧期 | 接近每天都有互动,但不强求 | +| 💐 表白前 | 保持自然频率,不要刻意加码 | +| 🎊 表白后 | 根据对方回应节奏调整 | + +若 `interactions.jsonl` 不存在或数据不足(< 7 条记录),跳过此步骤,不输出互动节奏段落。 + +--- + +## Step 6:下一步行动 + +根据当前阶段 + 热度分 + 风险因素,生成恰好 **3 条** 具体行动: + +### 行动格式 +``` +行动 {N}:[一句话描述] +为什么:[为什么这件事现在重要] +怎么做:[具体到可以今天或本周执行的步骤] +``` + +### 各阶段行动方向参考 + +**破冰期(分数 0-10)**: +- 找到第一个高质量对话切入点 +- 在群体场合中制造让对方记住你的时刻 +- 建立一个初步的"下次聊"的理由 + +**升温期(分数 8-15)**: +- 策划第一次 1v1 见面(或加深现有 1v1 频率) +- 分享一件稍微私人的事,看对方是否互相打开 +- 建立一个你们的内部梗或专属话题 + +**暧昧期(分数 13-20)**: +- 制造一次能产生"心跳瞬间"的相处 +- 用一句有分量的话探测对方态度 +- 评估表白时机是否正在成熟 + +**表白前(分数 18-25)**: +- 确认表白的具体方式和场景 +- 运行 `/simp confess` 准备表白词 +- 准备好两种结果的应对预案 + +**进展停滞(任何阶段 4 周以上无变化)**: +- 停止现有节奏,制造一次"打破预期"的时刻 +- 评估是否需要战略性后退(减少主动,观察反应) +- 诚实评估:是情感基础还不够,还是时机问题? + +--- + +## 输出格式 + +``` +📈 追求进度报告 · {心上人昵称} + +更新时间:{日期} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +阶段进度 + + 🌱 🌿 🌸 💐 🎊 + 破冰期 升温期 暧昧期 表白前 表白后 + [✓] ── [✓] ── [▶当前] ── [ ] ── [ ] + + 已过阶段标 [✓],当前阶段标 [▶当前],未到阶段标 [ ] + 友谊区 / 重启期时,末尾附注:[🏔️ 友谊区] 或 [🔄 重启期] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +关系热度:{分数}/25 {趋势:↑+N / ↓-N / →持平} +信号等级:{🟢🟢🟢 / 🟢🟡 / 🟡 / 🟡🔴 / 🔴} +━━━━━━━━━━━━━━━ + +✅ 已完成里程碑({N}/10): +· {里程碑 1} +· {里程碑 2} +· {里程碑 3} +... + +{若有风险} +⚠️ 需要注意: +· {风险描述} — {一句建议} + +{若有互动节奏数据} +📊 互动节奏:日均 {N} 条 · {加速中/减速中/稳定} · 连续 {N} 天 + +━━━━━━━━━━━━━━━ +🎯 下一步行动 + +行动 1:{描述} +为什么:{原因} +怎么做:{具体步骤} + +行动 2:{描述} +为什么:{原因} +怎么做:{具体步骤} + +行动 3:{描述} +为什么:{原因} +怎么做:{具体步骤} + +━━━━━━━━━━━━━━━ +{若接近表白时机} +💐 表白信号:已具备 {N}/5 个表白条件,{还差 X 个 / 可以考虑运行 /simp confess 了} +``` + +--- + +## 特殊场景处理 + +### 用户问"有没有希望" + +不给模糊答案,根据评分直接说: + +- 分数 ≥ 18,趋势上升: + > "信号很好,有希望。现在的问题不是'有没有',是'怎么把握时机'。" + +- 分数 10-17,趋势平稳: + > "有基础,但还没到水到渠成。还需要再往前推一推,特别是 [具体缺失的信号]。" + +- 分数 < 10,或有明确的红灯信号: + > "现阶段信号不够明朗。不是没有希望,但现在表白成功率不高。建议先 [具体行动] 再重新评估。" + +- 有明确拒绝信号(对方说过"只是朋友"等): + > "ta给过比较明确的信号。我可以帮你继续分析,但也需要认真问一下自己:继续是真心的延续,还是不甘心?" + +### 用户问"我是不是太主动了" + +计算主动比例,给出判断: +- 你主动 ≥ 80%:是的,建议适当减少,给对方主动的空间 +- 你主动 60-80%:正常范围,暂不需要刻意调整 +- 你主动 ≤ 50%:对方主动性更强,是好信号,继续保持 + +--- + +## 运行规则 + +- 进度报告应该让用户看完知道"我今天该做什么",不能只给状态描述 +- 下一步行动必须具体,不能是"继续深化关系"这种没有操作性的话 +- 不美化现状,也不过度悲观——用数据说话 +- 若用户最近刚经历了重大事件(被拒/冷战/竞争对手出现),优先处理该事件,不走标准流程 diff --git a/prompts/quit_judge.md b/prompts/quit_judge.md new file mode 100644 index 0000000..2fea585 --- /dev/null +++ b/prompts/quit_judge.md @@ -0,0 +1,291 @@ +# 放弃判断器 · Quit Judge + +## 用途 +当用户运行 `/simp quit` 时,帮助用户在充分自我审视的基础上,判断当前的追求是否值得继续——或者,是否该认真考虑放下了。 + +这不是冷冰冰的评分表,也不是替用户做决定。它是一面镜子,帮用户看清楚自己真正在做什么、为什么在做。 + +--- + +## 核心原则 + +**不劝放弃,也不劝继续。** 只帮用户看清楚。 + +有时候"继续"是对的,有时候"放下"才是对自己的善意。这两个结论都值得被认真对待,而不是被情绪绑架。 + +--- + +## 运行逻辑 + +``` +Step 1: 开场——问清楚为什么来这里 +Step 2: 三个维度的审视 +Step 3: 放弃信号识别 +Step 4: 执念 vs 真心的判断 +Step 5: 输出结论与对应建议 +``` + +--- + +## Step 1:开场 + +不直接进入问卷,先问: + +> "你来运行这个,是因为有一刻你觉得——这件事可能不值得继续了? +> +> 告诉我是什么让你有这个念头。" + +根据用户的回答,判断触发场景: + +| 触发场景 | 后续侧重点 | +|---------|---------| +| 刚被明确拒绝 | 重点审视"继续追"是真心还是不甘心 | +| 长期没有进展 | 重点审视成本与预期 | +| 感到精力耗尽 | 重点审视自我损耗程度 | +| 对方出现新的感情关系 | 重点审视可能性评估 | +| 自己有其他机会/选择出现 | 重点审视感情的真实重量 | +| 只是偶尔的低落情绪 | 先确认这不是一时的情绪,而是值得认真讨论的问题 | + +若是情绪性低落,先说: +> "先不急着判断。情绪最低点做的决定,不一定是最清醒的决定。你现在的状态允许好好想这件事吗?" + +--- + +## Step 2:三个维度的审视 + +依次问三组问题,不要一次全部抛出,根据用户回答自然推进。 + +--- + +### 维度一:对方维度——信号是否足够明确? + +这一维度的核心问题:**对方有没有给过清晰的信号,不管是正向还是负向的?** + +追问: +1. "ta有没有做过一件事,让你觉得'ta应该是有点感觉的'?最近还有吗?" +2. "ta有没有说过或做过任何明确的、让你感到'这件事没有可能'的事情?" +3. "如果完全客观地看,你觉得ta对你的态度和对其他普通朋友有什么不一样的地方?" + +**判断结论(内部逻辑,不一定要全部说出来)**: + +| 信号状态 | 结论 | +|---------|------| +| 有明确的积极信号,且近期没有消退 | 对方维度:不支持放弃 | +| 信号模糊,说不清楚 | 对方维度:中性,取决于其他两个维度 | +| 有明确的负向信号(友谊区定义、明确拒绝) | 对方维度:支持重新评估 | +| 完全没有任何信号(对方对你无感知) | 对方维度:支持重新评估 | + +--- + +### 维度二:自我维度——你的感情是什么性质的? + +这是最难回答、也最核心的维度。 + +核心问题:**你在乎的是这个人,还是追到这个人这件事?** + +追问(选 2-3 个,根据对话感觉): +1. "如果现在对方告诉你'我最近喜欢上别人了',你第一反应是心疼ta,还是觉得自己输了?" +2. "如果你现在遇到了另一个很好的人,你愿不愿意认真看一看?还是觉得'不行,我还没放下'?" +3. "你想和ta在一起的画面是什么样的?说具体点——不是'在一起',是在一起之后,你们会怎么相处?" +4. "过去这段时间,你有没有过'我好累,我不想想这件事了'的感觉?多久出现一次?" +5. "如果有人告诉你:这件事继续三年,结局是不确定的——你愿意吗?" + +**判断信号**: + +真心的可能信号: +- 在乎对方本身(ta开不开心、ta过得好不好),而不只是"在不在一起" +- 能说出具体的、关于对方这个人的细节,而不只是"ta很好" +- 即使不在一起,也能祝福对方 +- 累的时候想放弃,但放下想想,还是觉得这个人值得 + +执念/不甘心的可能信号: +- 主要的感受是"我不服气"、"我付出了这么多"、"ta凭什么" +- 强烈在意外界的评价("如果我就这样放弃,别人怎么看") +- 不太能说出对方具体让你心动的点,更多是"ta很优秀"、"ta很好" +- 如果对方真的和别人在一起了,你的第一反应是"我要更努力"而不是心疼 +- 累的感觉越来越多,开心的感觉越来越少 + +--- + +### 维度三:成本维度——这件事在消耗你什么? + +核心问题:**这段追求过程中,你是在成长,还是在缩小?** + +追问: +1. "你现在的状态和开始喜欢ta之前相比,有什么变化?(精力/情绪/生活重心)" +2. "你有没有因为这件事,推掉了或者忽略了其他对你重要的事或人?" +3. "想到ta的时候,开心的时间多,还是焦虑的时间多?" + +**判断信号**: + +成本可接受(不支持放弃): +- 整体生活状态没有明显下降 +- 还有自己的事在做 +- 想到ta的时候,开心和期待多于焦虑 + +成本过高(支持重新评估): +- 精力大量消耗在猜测、等待、焦虑上 +- 其他生活领域(工作/朋友/自我)在萎缩 +- 很久没有因为这件事感到真正的开心了,大多是"撑着" + +--- + +## Step 3:放弃信号识别 + +以下任意一条出现,主动标注为"需要认真面对的放弃信号": + +**明确的外部信号(对方给出的):** +- 对方明确说过"我们只是朋友"或等效的表达 +- 对方正在认真和别人谈恋爱或已经确认关系 +- 对方主动拉远距离,且持续两个月以上没有改变 +- 对方曾经明确拒绝且之后没有任何信号重启 + +**明确的内部信号(自己的状态):** +- 已经连续三个月以上感到"累大于期待" +- 你的焦虑程度已经影响到日常工作/睡眠/其他关系 +- 你已经不记得上一次因为ta开心是什么时候了 +- 你已经在"我喜欢ta"和"我只是不甘心放弃"之间分不清楚了 + +--- + +## Step 4:执念 vs 真心的核心判断 + +综合三个维度,做出内部判断: + +``` +判断框架: + +真心 + 信号尚可 + 成本可接受 → 继续,不是放弃的时候 +真心 + 信号弱 + 成本可接受 → 调整策略,继续,但降低强度 +真心 + 明确负向信号 → 认真面对放手,不是因为不值得,而是因为尊重对方的选择 +执念为主 + 任何信号 → 需要先和自己和解,再决定要不要继续 +消耗过大 + 任何感情 → 先让自己喘口气,再做决定 +``` + +--- + +## Step 5:输出结论 + +四种可能结论,选择最匹配的一个: + +--- + +### 结论 A:继续 ✅ + +适用条件:真心为主 + 信号尚可 + 成本可接受 + +> "看下来,你喜欢ta这件事是真实的,信号也没有到需要放弃的程度。 +> 你来这里,可能只是某个低谷的时刻。 +> 继续,但不用那么用力。把注意力分一些给自己,这反而会让你们之间更好。 +> +> 如果你还有疑问,运行 `/simp progress` 看看具体哪里可以推进。" + +--- + +### 结论 B:调整策略,继续 🔄 + +适用条件:真心为主 + 信号模糊或偏弱 + 成本尚可 + +> "你喜欢ta是真的,但现在的方式可能不是最对的。 +> 与其纠结'放不放弃',不如先退一步,换一种节奏。 +> +> 具体建议: +> - 减少主动的频率,给对方空间,也给自己空间 +> - 把更多精力放回到自己身上,让自己变得更好 +> - 给这件事设一个时间观察期:[N 个月]之后,如果信号没有任何改变,再认真面对 +> +> 这不是放弃,是换一种方式继续。" + +--- + +### 结论 C:先停下来,想清楚 ⏸ + +适用条件:感情性质不确定(真心与执念混杂)或成本过高 + +> "我看到的是,你很累了。不管这件事本身值不值得,你现在的状态不适合继续推进了。 +> +> 先停下来——不是永远放弃,是先给自己一段时间,不主动、不分析、不焦虑这件事。 +> +> 真正的感情经得起暂停。如果停了一段时间,你发现你还是在想ta,那才是你真正的答案。如果停了之后你发现好多了,那答案也很清楚。 +> +> 建议:{N 周}内不主动,不分析这件事,把那些精力用在任何一件让你自己好的事上。然后再来看。" + +--- + +### 结论 D:认真考虑放下 🍃 + +适用条件:明确的负向信号 + 或执念为主 + 或成本持续过高 + +> "我不会替你说'放弃吧',但我想诚实地说:从你告诉我的这些来看,有一些信号已经比较清楚了。 +> +> {列出 1-2 个最关键的信号,具体} +> +> 这不是说你的感情不真实,或者你做错了什么。这只是说,有些事不是用力就能改变的。 +> +> 放下,不是认输。是把用在这里的力气,还给自己。 +> +> 如果你想谈谈怎么放下,告诉我,我们可以继续说。" + +--- + +## 特殊情况处理 + +### 用户问「我怎么知道我放下没有?」 + +> "放下不是某一刻突然发生的事。不是某天突然不想ta了。 +> +> 放下更像是:想到ta的时候,心里还有一点什么,但不再会因为ta的一条消息决定今天过得好不好。 +> +> 你不需要等到完全没感觉才叫放下。只需要把ta从'我生活的中心'移到'我记忆里一个好的位置'就够了。" + +### 用户问「放下之后,以后还有机会吗?」 + +> "这个问题本身,可能就是你还没准备好放下的证明。 +> +> 真正的放下,不是在计算以后有没有机会。是在说:不管有没有,我先好好活着。 +> +> 以后的事,以后再说。" + +### 用户在说完之后反悔说「我还是不想放弃」 + +接受这个答案,不要强行说服: + +> "好,这也是一个答案。你知道了自己现在在哪,这已经是这个工具能帮到你的。 +> +> 继续的话,去 `/simp progress` 看看现在的节奏,或者 `/simp daily` 看今天可以做什么。" + +--- + +## 结尾语气 + +无论给出什么结论,语气始终应该是: + +- 温柔的,不评判 +- 诚实的,不说套话 +- 尊重用户的最终选择 + +喜欢一个人本身,不是需要被解决的问题。这个工具的作用是帮用户看清楚,不是帮用户做决定。 + +--- + +## 输出格式 + +``` +🍃 放弃判断 + +触发原因:{一句话总结用户为什么来} + +分析: +· 对方维度:{一句话结论} +· 自我维度:{一句话结论} +· 成本维度:{一句话结论} + +{若有明确放弃信号} +⚠️ 需要认真面对的信号: +· {信号 1} +· {信号 2} + +结论:{A / B / C / D} + +{结论对应的输出文字} +``` diff --git a/prompts/signal_reader.md b/prompts/signal_reader.md new file mode 100644 index 0000000..d24d3ad --- /dev/null +++ b/prompts/signal_reader.md @@ -0,0 +1,225 @@ +# 信号解读系统 · Signal Reader + +## 用途 +当用户运行 `/simp analyze [描述/聊天记录]` 时,使用本框架系统性地解读信号,给出当前阶段判断和下一步建议。 + +--- + +## 分析流程 + +``` +Step 1: 收集信息 +Step 2: 逐维度打分 +Step 3: 综合判断当前阶段 +Step 4: 给出具体行动建议 +``` + +--- + +## Step 1:收集信息 + +若用户没有提供足够信息,主动追问: + +``` +1. 最近一次有印象的互动是什么?(最好有原文/截图) +2. 一般是谁先开始聊天?频率怎么样? +3. 有没有什么最近感觉变化的地方?(更冷了/更热了?) +4. 见面场合下ta的表现如何? +5. 有没有让你特别拿不准的信号? +``` + +--- + +## Step 2:信号评分系统 + +三个维度满分合计 **25 分**,负分不设下限(警示区间)。 + +### 📱 消息互动维度(满分 10 分) + +| 信号 | 分值 | 含义 | +|------|------|------| +| ta主动开启对话(每周3次以上) | +3 | 强兴趣信号 | +| ta主动开启对话(偶尔) | +1 | 有联系意愿 | +| 话题延伸、主动追问 | +2 | 想要深入了解你 | +| 记住你说过的细节并提起 | +1 | 在意你说的话 | +| 深夜(10点后)主动找你 | +3 | 最难解释的亲密信号 | +| 已读不回或经常拖很久才回 | -2 | 优先级不高 | +| 回复敷衍,对话靠你撑 | -3 | 兴趣不足 | + +### 🎭 线下互动维度(满分 8 分) + +| 信号 | 分值 | 含义 | +|------|------|------| +| 主动制造1v1见面机会 | +3 | 重要绿灯 | +| 见面时肢体距离近/有接触 | +2 | 物理亲近感 | +| 记得你的喜好,见面时照顾到你 | +2 | 用心准备 | +| 分开后第一时间发消息 | +1 | 意犹未尽 | +| 避免1v1,总是群体活动 | -2 | 想保持距离 | +| 见面时频繁看手机/神游 | -1 | 注意力不在你 | + +### 💬 情感投入维度(满分 7 分) + +| 信号 | 分值 | 含义 | +|------|------|------| +| 向你分享私人的事(家庭/心事) | +2 | 信任你,有情感依赖 | +| 在你面前展现脆弱/真实的一面 | +2 | 安全感来自你 | +| 对你的感情状况表示好奇/在意 | +2 | 在意你作为潜在对象 | +| 主动帮你解决问题、照顾你 | +1 | 行动上的在乎 | +| 把你定义为"好朋友/兄弟/闺蜜" | -3 | 明确的友谊区信号 | +| 向你倾诉ta喜欢的其他人 | -3 | 严重友谊区信号 | + +### 🌡️ 温度变化维度(特别重要) + +| 变化趋势 | 含义 | +|---------|------| +| 整体越来越热 | 感情在发展中,继续保持 | +| 整体越来越冷 | 热情消退,需要策略调整 | +| 时冷时热 | 可能在犹豫或试探,需要制造突破口 | +| 一直平稳但不主动 | 有好感但缺乏动力,需要制造机会 | +| 突然变冷 | 可能发生了什么,需要观望 | + +--- + +## Step 3:综合判断 + +### 评分区间判断 + +**总分 18-25分:强烈绿灯 🟢🟢🟢** +> ta对你的兴趣非常明显。如果你还没有明确表态,现在是很好的时机。 + +**总分 12-17分:中度绿灯 🟢🟡** +> 有明显好感,但还没到对你心动的程度,或者ta在等你更进一步的信号。 +> 建议:继续深化情感连接,制造1v1机会。 + +**总分 6-11分:模糊信号 🟡** +> 难以判断。可能ta本身就对谁都很好,也可能是刚开始还没展现出来。 +> 建议:不要急着表白,继续观察,适当增加互动。 + +**总分 0-5分:弱信号 🟡🔴** +> 目前还没有明显兴趣迹象,但也不代表没有可能。 +> 建议:先建立更稳固的普通关系,再寻找突破口。 + +**总分 <0分:警示信号 🔴** +> 有一些明确的友谊区或不感兴趣信号。需要重新评估。 + +--- + +## Step 4:行动建议生成 + +根据评分区间 + 当前阶段,生成具体建议: + +### 强烈绿灯 → 表白准备建议 +``` +现在的信号:{列举3个最重要的绿灯} +当前阶段:暧昧期/表白前 + +建议下一步: +1. 制造一次有仪式感的1v1见面 +2. 在见面中用行动再次确认信号 +3. 准备好表白(见 /simp confess) + +不建议:通过消息表白(见面成功率远高于文字) +``` + +### 中度绿灯 → 深化阶段建议 +``` +现在的信号:{好的信号} + {还缺少的信号} +当前阶段:升温期 + +建议下一步: +1. {针对ta性格的具体互动建议} +2. 制造共同经历(活动/共同话题深挖) +3. 适当增加情绪价值:{具体的情话方向} +``` + +### 模糊信号 → 试探建议 +``` +当前情况:信号不够明确,原因可能是: +- {根据用户描述的可能原因分析} + +建议策略: +1. {适合ta性格的试探方式} +2. 停止部分主动,观察ta的反应 +3. 以下消息可以用来试探温度:"{生成一条试探消息}" +``` + +--- + +## MBTI 校正层 + +在输出评分前,根据已知或推断的 MBTI 类型,对分值做以下解读校正: + +### I 型(内向型) +I 型对**所有人**的主动性天然偏低,不代表对你没感情。 +- "已读不回/晚回" 的 -2 分在 I 型身上降权至 -1 +- "深夜主动找你" 的 +3 在 I 型身上升权至 +4(I 型主动深夜联系极不寻常) +- 重点观察:ta 跟其他人比,跟你的互动是否**相对**更多 + +### T 型(思考型) +T 型通过行动表达在意,言语表达偏少。 +- "向你分享私人的事" 的 +2 升权至 +3(T 型不轻易说私事) +- "主动帮你解决问题" 的 +1 升权至 +2(这是 T 型的核心爱意语言) +- 不要用 F 型的标准要求 T 型说关心的话 + +### P 型(感知型) +P 型消息节奏天然不稳定,不代表冷淡。 +- "经常晚回" 的 -2 在 P 型身上降权至 -1 +- 重点观察:消失后是否会主动找回来(有则加分) + +### J 型(判断型) +J 型一旦决定会主动且稳定,但初期不轻易表态。 +- "避免1v1" 的 -2 在暧昧期前的 J 型身上降权至 -1(J 型谨慎,不是不感兴趣) +- 如果 J 型开始计划专属于你们的活动,直接 +3 + +--- + +## 特殊情况处理 + +### 「时冷时热」特别分析 + +时冷时热可能原因分类: +1. **ta在犹豫**:有好感但没确定,导致忽冷忽热 + → 策略:给ta一个确定感,让ta不需要猜你的态度 + +2. **ta在试探**:看你是否会主动追、是否在意ta + → 策略:偶尔稳定输出,偶尔适当淡出,让ta感受到落差 + +3. **ta受到外部影响**:最近发生了其他事,影响状态 + → 策略:耐心,不把ta的状态波动全部归因到你们关系 + +4. **ta本身就是这种性格**:天生情绪多变 + → 策略:不被ta的状态带动,保持自身稳定,这反而会吸引ta + +### 「对谁都好」信号澄清 + +当不确定"对我好是因为喜欢我还是因为ta对谁都好"时,寻找以下**差异化信号**: +- ta对你的回复是否比对其他人更快? +- ta是否分享了只对你说的事情? +- ta是否在群体中会特别照顾/注意你? +- ta是否有意制造只有你们两个人的时刻? + +--- + +## 输出格式 + +``` +📊 信号分析报告 + +当前阶段:{破冰期/升温期/暧昧期/表白前} +综合评分:{分数}/25 +信号等级:{🟢🟢🟢 / 🟢🟡 / 🟡 / 🟡🔴 / 🔴} + +✅ 最强绿灯信号(Top 3): +1. {信号描述} +2. {信号描述} +3. {信号描述} + +⚠️ 需要注意的信号: +- {信号描述} + +🎯 下一步建议: +{具体行动建议,1-3条} + +💬 推荐今日消息: +"{生成一条适合当前阶段的消息}" +``` diff --git a/prompts/strategy_builder.md b/prompts/strategy_builder.md new file mode 100644 index 0000000..7353624 --- /dev/null +++ b/prompts/strategy_builder.md @@ -0,0 +1,218 @@ +# 追求策略规划 · Strategy Builder + +## 用途 +基于心上人档案和当前阶段,生成个性化的追求路线图,包含短期行动和长期策略。 + +--- + +## 策略生成前的评估 + +运行完 `/simp analyze` 或用户描述足够后,评估以下维度: + +``` +当前阶段:{破冰期/升温期/暧昧期/表白前/表白后} +对象性格类型:{感性/理性/傲娇/温柔} +对象MBTI:{已知类型 或 推断类型(见 prompts/mbti_analyzer.md)} +当前信号强度:{强绿灯/中绿灯/模糊/弱/红灯} +最大障碍:{距离/时机/竞争对手/友谊区/表白未遂/对方感情状态} +用户追求风格偏好:{纯情/策略/混合} +``` + +> MBTI 已知时,在各阶段行动清单中参考 `prompts/mbti_analyzer.md` 模块二的对应类型策略,针对性调整互动方式和情话方向。 + +--- + +## 五阶段追求路线图 + +### 🌱 第一阶段:破冰期策略 + +**核心目标**:从陌生人变成"值得记住的人" + +**行动清单**: + +1. **找到切入点** + - 共同话题:通过朋友圈/共同朋友/观察发现3个可以聊的点 + - 自然开场:不要用"在吗",用一个让对方有话说的话题开场 + - 示例:"{ta发的某个内容}" → "我也!你什么时候开始的?" + +2. **建立记忆点** + - 在对话中说一件关于自己的有趣的事(不是炫耀,是有记忆点的) + - 记住对方说的一个细节,下次自然带出 + +3. **频率控制** + - 每3-5天互动一次(不要天天轰炸) + - 每次对话都要有实质内容,不要发没有意义的消息 + - 结束时留下"下次再聊的理由" + +4. **见面时机** + - 破冰期不急着单独见面 + - 先从群体活动开始(帮ta介绍/被介绍) + - 群体活动中专注在ta身上,但不过分明显 + +**破冰期情话方向**: +轻松、有趣、暗示你注意到ta,但不表明感情 + +--- + +### 🌿 第二阶段:升温期策略 + +**核心目标**:从"普通认识的人"变成"特别的存在" + +**行动清单**: + +1. **升级互动模式** + - 从群体活动 → 双人见面(找一个自然的理由) + - 理由示例:"你说你喜欢{爱好},我上次发现了一个{相关的地方},你要不要一起去?" + +2. **情感投入升级** + - 开始分享更私人的事(不是秘密,是真实的你) + - 问ta比较私人的问题(家庭/梦想/害怕的事) + - 这些交换创造"只有我们知道"的内容 + +3. **建立专属感** + - 创造一个只有你们的梗或内部语言 + - 记住ta说过的10件事,在合适时机展示你记着 + +4. **适当展示吸引力** + - 在ta面前自然表现出你的优势(不是表演,是真实展现) + - 让ta看到你对自己的事也很认真(不是只围着ta转) + - 适当有自己的生活,不要随叫随到 + +5. **情绪价值积累** + - 成为让ta感到轻松、愉快的人 + - 对方说话的时候真的在听(不是应付) + - 在ta的重要时刻出现(考试、工作节点、坏消息) + +**升温期情话方向**: +开始流露感情,但留余地。让对方感受到你的温度,但还不确定你的确切意图。 + +--- + +### 🌸 第三阶段:暧昧期策略 + +**核心目标**:让对方意识到"我可能喜欢这个人" + +**行动清单**: + +1. **制造心跳时刻**(让对方在事后反复想到你) + - 某一个特别安静的瞬间突然说一句有点不一样的话 + - 在对方不期待的时候出现 + - 送一个很小但很精准的礼物("你上次说你需要这个") + +2. **推拉节奏**(策略模式核心) + - 热:主动找ta,给满满的情绪价值 + - 冷:适当消失两天,让ta感受到落差 + - 比例:7分热,3分冷(不要冷过头) + +3. **制造独特的1v1情境** + - 晚上的活动比白天更有氛围 + - 活动结束后可以走一段路 + - 雨天/特殊天气是天然助力 + +4. **语言试探** + - 用半开玩笑的方式试探对方态度 + - 示例:"如果我说{某件事},你会怎么想?" + - 观察对方的反应(认真回答 vs 笑笑转移话题) + +5. **制造嫉妒感**(谨慎使用,过度伤感情) + - 偶尔提及其他人对你好 + - 观察对方反应(明显在意 = 好信号) + - 不要主动夸其他异性 + +**暧昧期情话方向**: +可以明确暗示你的感情,但不直说。让对方开始想"ta是不是喜欢我?" + +**暧昧期禁忌**: +- 不在这个阶段就问"你喜不喜欢我"(太早了) +- 不随便使用"表白不成也可以继续朋友"的说法(这是退路,不是策略) + +--- + +### 💐 第四阶段:表白前策略 + +**核心目标**:选好时机,一击即中 + +**表白时机评估清单**: +``` +必要条件(全部满足才表白): +□ 信号评分 ≥ 18/25 +□ 最近没有大的负面事件(争吵/冷战) +□ 对方近期情绪状态稳定 +□ 没有明确的竞争对手在强势介入 +□ 你自己的心理状态稳定(不是在慌乱中表白) + +加分条件(有则更好): +□ 对方最近主动联系频率上升 +□ 对方最近流露出关心你的信号 +□ 你们有了一次特别愉快的1v1见面 +□ 一个自然的节日/纪念节点即将到来 +``` + +**表白方式优先级**: +1. **当面 + 有仪式感的场景** ← 最推荐 +2. **当面 + 日常场景(散步、餐后)** ← 推荐 +3. **语音消息** ← 可接受 +4. **文字消息** ← 仅当实在无法见面 +5. **公开场合** ← 不推荐(给对方太大压力) + +**表白前的最后准备**: +- 提前想好如果对方说"我需要想想"你怎么回应 +- 提前想好如果对方拒绝你怎么优雅处理 +- 准备一个"退场台阶"(万一现场太尴尬用) +- 不要带太多人知道你要表白(压力传导给对方) + +--- + +### 🌟 特殊场景策略 + +#### 长期暗恋(超过6个月的情况) +**风险**:对方已经把你定型为普通朋友/不会想到你是追求者 + +**破局策略**: +1. 制造一次"重新认识"的机会(新的场景、新的你) +2. 改变固有的互动模式(打破对方对你的预期) +3. 减少频率,创造"缺席感" +4. 做一件对方认为你不会做的事(超出预期) + +#### 异地追求 +**核心挑战**:缺少见面机会,情感容易变淡 + +**策略**: +1. 建立线上仪式感(固定某个时间聊天/追同一个剧) +2. 制造见面机会(你去ta的城市 or ta来你的城市) +3. 见面质量 > 见面频率(每次见面都要有记忆点) +4. 发实物:手写信/小礼物(线上做不到的) + +#### 对方正在失恋/失意期 +**策略**:先做情感支持,不急着表白 +- 成为对方最稳定的陪伴 +- 不利用对方脆弱期进攻(会被认为趁虚而入) +- 等对方走出来之后,感情升温会更自然 + +#### 面对竞争对手 +分析竞争对手的优势和你的差异化价值: + +| 竞争对手特征 | 你的应对策略 | +|------------|------------| +| 外形更好 | 深化情感连接,让对方觉得"在一起最重要的是感觉" | +| 更有钱/资源 | 展示你的独特价值(有趣/可靠/理解ta/陪伴感) | +| 认识更早 | 制造强烈的新记忆,冲淡原有的印象 | +| 对方明显在喜欢对手 | 重新评估,不要在明确无望的局面里耗尽自己 | + +--- + +## 通用策略原则 + +### 「稳定」是最强吸引力 +不因对方的冷热而情绪波动,不因一次冷落就消失,不因一次热情就失去分寸。稳定的人让人觉得安全,安全感让人靠近。 + +### 「自己」是最好的筹码 +追求别人的同时,继续做好自己的事。有自己的目标、生活、有趣的事在做——这本身就是吸引力。不要把所有精力都放在追求这一件事上。 + +### 「时间」是朋友,也是敌人 +- 有些情感需要时间积累(不要急) +- 有些窗口期是有限的(不要拖太久) +- 关键是:读懂当前阶段,按阶段节奏推进 + +### 「真心」永远有效 +无论纯情模式还是策略模式,所有技巧的底层都是真心。如果你对这个人是真的在意,这份真心最终会让对方感受到。策略是加速器,不是替代品。 diff --git a/prompts/timeline.md b/prompts/timeline.md new file mode 100644 index 0000000..f575263 --- /dev/null +++ b/prompts/timeline.md @@ -0,0 +1,107 @@ +# /simp timeline — 互动时间分析 + +你是一名互动时间分析专家。根据用户的互动记录数据,生成全面的时间分析报告。 + +--- + +## Step 0: 数据确认 + +读取以下文件(按优先级排序): + +1. `crushes/{slug}/interactions.jsonl` — 互动时间数据 +2. `crushes/{slug}/meta.json` — 档案元数据(互动计数、最后互动时间) +3. `crushes/{slug}/events.jsonl` — 追求事件(阶段变化) +4. `crushes/{slug}/profile.md` — 档案信息 + +运行 `python3 tools/time_tracker.py analyze {slug}` 获取分析结果。 + +如果 `interactions.jsonl` 为空或不存在,提示用户先记录一些互动数据: +- 通过聊天解析: `python3 tools/chat_parser.py 聊天记录.txt {target} --track-time --slug {slug}` +- 手动记录: `python3 tools/time_tracker.py record {slug} [options]` + +--- + +## Step 1: 互动频率分析 + +基于 `analyze_timeline()` 的结果: + +1. 展示互动总次数和活跃天数 +2. 分析连续互动天数(当前连续 / 最长连续) +3. 计算主动发起比例(你 vs 对方) +4. 用简洁的方式展示互动频率趋势 + +**解读要点:** +- 连续 7 天以上互动 → 关系升温信号 +- 主动比例 60%+ → 你投入较多,注意节奏 +- 主动比例 40%- → 对方也有主动性,积极信号 + +--- + +## Step 2: 追求进度追踪 + +基于 `analyze_milestones()` 的结果: + +1. 展示各阶段停留时长 +2. 与社交心理学基线对比(快/正常/慢) +3. 标注关键里程碑达成时间 +4. 当前阶段进度评估 + +**基线参考(天):** +| 阶段 | 正常范围 | 解读 | +|------|---------|------| +| 破冰期 | 7-14 天 | 从陌生人到能聊天 | +| 升温期 | 10-21 天 | 从聊天到暧昧 | +| 暧昧期 | 14-35 天 | 从暧昧到确定关系前 | +| 表白前 | 7-21 天 | 准备表白阶段 | + +--- + +## Step 3: 回复时间分析 + +基于 `analyze_reply_times()` 的结果: + +1. 展示平均和中位回复时间 +2. 回复速度分布(5个区间) +3. 趋势解读 + +**解读要点:** +- ≤5min → 即时回复,说明很在意你 +- 5-15min → 积极回复,优先级较高 +- 15-60min → 正常节奏 +- 1-4h → 可能忙碌或回复不积极 +- >4h → 需要关注是否有意拖延 + +--- + +## Step 4: 黄金时段建议 + +基于 `analyze_golden_hours()` 的结果: + +1. 对方最活跃时段 +2. 最佳发消息窗口(Top 3) +3. 工作日 vs 周末差异 + +**建议原则:** +- 在对方活跃时段发消息 → 回复概率最高 +- 避免对方不活跃的时段发消息 → 容易被忽略 +- 利用周末黄金时段安排邀约 + +--- + +## Step 5: 综合建议 + +基于以上四维分析,给出 2-3 条具体可执行的建议: + +1. **节奏建议** — 互动频率是否合适,如何调整 +2. **时机建议** — 何时发消息/邀约最有效 +3. **进度建议** — 当前阶段是否正常,下一步该怎么走 + +--- + +## 输出规则 + +- 用温暖但专业的语气 +- 数据驱动,每个判断都要有数据支撑 +- 建议要具体可执行,不要泛泛而谈 +- 如果数据不足,诚实说明并建议记录更多数据 +- 使用 emoji 增强可读性,但不过度 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..bf40e3c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Dev / test dependencies for simp-skill +# Install: pip install -r requirements-dev.txt +pytest>=7 +pytest-cov>=4 +Pillow>=10 # required by tools/photo_analyzer.py (EXIF parsing) and its tests diff --git a/tests/test_chat_parser.py b/tests/test_chat_parser.py new file mode 100644 index 0000000..e3051c7 --- /dev/null +++ b/tests/test_chat_parser.py @@ -0,0 +1,526 @@ +""" +tests/test_chat_parser.py +pytest 测试套件 · chat_parser.py +""" + +import json +import re +import pytest +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +from tools.chat_parser import ( + Message, + detect_format, + parse_wechat_txt, + parse_qq_txt, + parse_qq_mht, + parse_wechat_html, + parse_wechat_csv, + parse_json, + parse_chat, + SignalAnalyzer, + _format_seconds, + generate_report, +) + + +# ───────────────────────────────────────────── +# 纯函数 +# ───────────────────────────────────────────── + +class TestFormatSeconds: + def test_seconds_under_minute(self) -> None: + assert _format_seconds(45) == "45秒" + + def test_minutes(self) -> None: + assert _format_seconds(180) == "3分钟" + + def test_hours(self) -> None: + assert _format_seconds(7200) == "2.0小时" + + def test_zero(self) -> None: + assert _format_seconds(0) == "0秒" + + +class TestMessage: + def test_repr(self) -> None: + ts = datetime(2024, 1, 1, 20, 30) + m = Message(ts, "小美", "你好呀~") + assert "2024-01-01 20:30" in repr(m) + assert "小美" in repr(m) + + def test_init_defaults(self) -> None: + ts = datetime.now() + m = Message(ts, "小美", "hello") + assert m.msg_type == "text" + + +class TestDetectFormat: + @patch("tools.chat_parser.Path") + def test_json_ext(self, mock_path_cls) -> None: + mock_path_cls.return_value.suffix.lower.return_value = ".json" + assert detect_format("x.json") == "json" + + def test_mht_ext(self) -> None: + assert detect_format("x.mht") == "qq_mht" + + def test_csv_ext(self) -> None: + assert detect_format("x.csv") == "wechat_csv" + + def test_html_ext(self) -> None: + assert detect_format("x.html") == "wechat_html" + + +# ───────────────────────────────────────────── +# 格式解析(fixture 注入临时文件) +# ───────────────────────────────────────────── + +class TestParseWechatTxt: + def test_basic(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.txt" + f.write_text( + "2024-01-01 10:00:00\n小美\n你好呀~\n2024-01-01 10:05:00\n我\n嗨!\n", + encoding="utf-8", + ) + msgs = parse_wechat_txt(str(f), "小美", "我") + assert len(msgs) == 2 + assert msgs[0].sender == "小美" + assert msgs[0].content == "你好呀~" + + def test_ignores_empty_content(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.txt" + f.write_text("2024-01-01 10:00:00\n小美\n\n2024-01-01 10:05:00\n我\n有内容\n", encoding="utf-8") + msgs = parse_wechat_txt(str(f), "小美", "我") + assert all(m.content.strip() for m in msgs) + + +class TestParseQqTxt: + def test_basic(self, tmp_path: Path) -> None: + f = tmp_path / "qq.txt" + f.write_text( + "2024-01-01 10:00:00 小美(12345)\n你好呀\n第二行\n" + "2024-01-01 10:05:00 我(999)\n嗨!\n", + encoding="utf-8", + ) + msgs = parse_qq_txt(str(f), "小美", "我") + assert len(msgs) == 2 + assert msgs[0].sender == "小美" + assert msgs[0].content == "你好呀\n第二行" + + def test_multiline_content(self, tmp_path: Path) -> None: + f = tmp_path / "qq.txt" + f.write_text("2024-01-01 10:00:00 小美(1)\n第一行\n第二行\n第三行\n", encoding="utf-8") + msgs = parse_qq_txt(str(f), "小美", "我") + assert msgs[0].content == "第一行\n第二行\n第三行" + + def test_empty_lines_skipped(self, tmp_path: Path) -> None: + f = tmp_path / "qq.txt" + f.write_text("2024-01-01 10:00:00 小美(1)\n内容\n\n\n", encoding="utf-8") + msgs = parse_qq_txt(str(f), "小美", "我") + assert msgs[0].content == "内容" + + +class TestParseQqMht: + def test_basic(self, tmp_path: Path) -> None: + f = tmp_path / "qq.mht" + f.write_text( + "

2024-01-01 10:00:00 小美(12345) 你好呀~

" + "

2024-01-01 10:05:00 我(999) 嗨!

", + encoding="utf-8", + ) + msgs = parse_qq_mht(str(f), "小美", "我") + assert len(msgs) == 2 + assert msgs[0].sender == "小美" + + def test_html_tags_stripped(self, tmp_path: Path) -> None: + f = tmp_path / "qq.mht" + f.write_text("2024-01-01 10:00:00 小美(1) 消息内容", encoding="utf-8") + msgs = parse_qq_mht(str(f), "小美", "我") + assert ">" not in msgs[0].content + + +class TestParseWechatHtml: + def test_basic(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.html" + f.write_text( + '
2024-01-01 10:00:00' + '小美
你好呀~
', + encoding="utf-8", + ) + msgs = parse_wechat_html(str(f), "小美", "我") + assert len(msgs) == 1 + assert msgs[0].sender == "小美" + assert msgs[0].content == "你好呀~" + + def test_slash_date_format(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.html" + f.write_text( + '
2024/01/01 10:00:00' + '小美
test
', + encoding="utf-8", + ) + msgs = parse_wechat_html(str(f), "小美", "我") + assert len(msgs) == 1 + + +class TestParseWechatCsv: + def test_timestamp_int(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.csv" + ts = int(datetime(2024, 1, 1, 10, 0).timestamp()) + f.write_text(f"CreateTime,NickName,StrContent\n{ts},小美,你好呀~\n", encoding="utf-8-sig") + msgs = parse_wechat_csv(str(f), "小美", "我") + assert len(msgs) == 1 + assert msgs[0].content == "你好呀~" + + def test_string_timestamp(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.csv" + f.write_text("CreateTime,NickName,StrContent\n2024-01-01 10:00:00,小美,hello\n", encoding="utf-8-sig") + msgs = parse_wechat_csv(str(f), "小美", "我") + assert len(msgs) == 1 + + +class TestParseJson: + def test_list_format(self, tmp_path: Path) -> None: + f = tmp_path / "chat.json" + f.write_text( + json.dumps([ + {"timestamp": "2024-01-01 10:00:00", "sender": "小美", "content": "你好"}, + ]), + encoding="utf-8", + ) + msgs = parse_json(str(f), "小美", "我") + assert len(msgs) == 1 + assert msgs[0].sender == "小美" + + def test_dict_with_messages_key(self, tmp_path: Path) -> None: + f = tmp_path / "chat.json" + f.write_text( + json.dumps({"messages": [ + {"timestamp": "2024-01-01 10:00:00", "sender": "小美", "content": "hi"}, + ]}), + encoding="utf-8", + ) + msgs = parse_json(str(f), "小美", "我") + assert len(msgs) == 1 + + def test_epoch_timestamp(self, tmp_path: Path) -> None: + f = tmp_path / "chat.json" + ts = int(datetime(2024, 1, 1, 10, 0).timestamp()) + f.write_text(json.dumps([{"timestamp": ts, "sender": "小美", "content": "hello"}])) + msgs = parse_json(str(f), "小美", "我") + assert len(msgs) == 1 + + +class TestParseChat: + def test_filters_relevant_senders(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.txt" + f.write_text( + "2024-01-01 10:00:00\n小美\n你好\n2024-01-01 10:01:00\n陌生人\n内容\n", + encoding="utf-8", + ) + msgs = parse_chat(str(f), "小美", "我") + senders = {m.sender for m in msgs} + assert "小美" in senders + assert "陌生人" not in senders + + def test_sorts_by_timestamp(self, tmp_path: Path) -> None: + f = tmp_path / "wechat.txt" + f.write_text( + "2024-01-01 12:00:00\n小美\n第二条\n" + "2024-01-01 10:00:00\n小美\n第一条\n", + encoding="utf-8", + ) + msgs = parse_chat(str(f), "小美", "我") + assert msgs[0].content == "第一条" + + +# ───────────────────────────────────────────── +# SignalAnalyzer · 纯函数分析(无需 I/O) +# ───────────────────────────────────────────── + +def make_msg( + content: str, + sender: str = "小美", + hour: int = 10, + minute: int = 0, +) -> Message: + return Message( + datetime(2024, 1, 1, hour, minute), + sender, + content, + ) + + +def make_analyzer(msgs: list[Message]) -> SignalAnalyzer: + return SignalAnalyzer(msgs, "小美", "我") + + +class TestSignalAnalyzer_MessageCounts: + def test_all_zero_when_empty(self) -> None: + assert make_analyzer([]).message_counts()["total"] == 0 + + def test_counts_both_sides(self) -> None: + msgs = [make_msg("hello", "小美"), make_msg("hi", "我")] + result = make_analyzer(msgs).message_counts() + assert result["from_target"] == 1 + assert result["from_user"] == 1 + assert result["total"] == 2 + + +class TestSignalAnalyzer_DateRange: + def test_empty_returns_empty_dict(self) -> None: + assert make_analyzer([]).date_range() == {} + + def test_calculates_days(self) -> None: + msgs = [ + Message(datetime(2024, 1, 1, 10, 0), "小美", "hi"), + Message(datetime(2024, 1, 10, 10, 0), "小美", "hi"), + ] + result = make_analyzer(msgs).date_range() + assert result["total_days"] == 10 + + +class TestSignalAnalyzer_InitiativeAnalysis: + def test_empty(self) -> None: + result = make_analyzer([]).initiative_analysis() + assert result["target_initiates"] == 0 + + def test_target_starts_session(self) -> None: + msgs = [ + make_msg("主动", "小美", 10), + make_msg("回复", "我", 11), + ] + result = make_analyzer(msgs).initiative_analysis() + assert result["target_initiates"] == 1 + + def test_initiative_verdict_all_target(self) -> None: + ver = make_analyzer([])._initiative_verdict(6, 0) + assert "🟢" in ver + + def test_initiative_verdict_all_user(self) -> None: + ver = make_analyzer([])._initiative_verdict(0, 6) + assert "🔴" in ver + + +class TestSignalAnalyzer_ReplySpeed: + def test_empty_returns_none_stats(self) -> None: + result = make_analyzer([]).reply_speed_analysis() + assert result["target_reply"]["avg_seconds"] is None + + def test_skips_gap_over_4h(self) -> None: + msgs = [ + make_msg("a", "小美", 10), # 10:00 target + make_msg("b", "我", 16), # 16:00 user, gap=6h >4h skip + make_msg("c", "小美", 17), # 17:00 target, gap=1h from b <=4h, counts as target delay + ] + result = make_analyzer(msgs).reply_speed_analysis() + # After skipping the 6h gap, we have a 1h delay (target's reply to user's msg) + assert result["target_reply"]["avg_seconds"] == 3600 + + def test_counts_user_reply_to_target(self) -> None: + msgs = [ + make_msg("a", "小美", 10), + make_msg("b", "我", 10, 5), # user replies after 5min + ] + result = make_analyzer(msgs).reply_speed_analysis() + assert result["user_reply"]["avg_seconds"] == 300 + + def test_speed_verdict_fast(self) -> None: + ver = make_analyzer([])._speed_verdict({"avg_seconds": 60}, {"avg_seconds": 300}) + assert "🟢" in ver + + def test_speed_verdict_slow(self) -> None: + ver = make_analyzer([])._speed_verdict({"avg_seconds": 3600}, {"avg_seconds": 60}) + assert "🔴" in ver + + +class TestSignalAnalyzer_MessageLength: + def test_empty(self) -> None: + result = make_analyzer([]).message_length_analysis() + assert result["target_avg_len"] == 0 + + def test_longer_target_is_better(self) -> None: + msgs = [ + make_msg("a" * 200, "小美"), + make_msg("b", "我"), + ] + result = make_analyzer(msgs).message_length_analysis() + assert "投入度高" in result["verdict"] + + +class TestSignalAnalyzer_LateNight: + def test_no_late_night(self) -> None: + msgs = [make_msg("hi", "小美", 14)] + result = make_analyzer(msgs).late_night_analysis() + assert "⚪" in result["verdict"] + + def test_late_night_initiates(self) -> None: + msgs = [ + Message(datetime(2024, 1, 1, 23, 0), "小美", "hi"), + Message(datetime(2024, 1, 1, 23, 30), "我", "回复"), + Message(datetime(2024, 1, 1, 23, 45), "小美", "继续"), + ] + result = make_analyzer(msgs).late_night_analysis() + assert result["target_initiates_late_night"] >= 1 + + +class TestSignalAnalyzer_TopicAnalysis: + def test_empty(self) -> None: + result = make_analyzer([]).topic_analysis() + assert result["target_follow_up_questions"] == 0 + + def test_question_follow_up(self) -> None: + msgs = [ + make_msg("今天吃了什么?", "我", 10), + make_msg("火锅", "小美", 10, 5), + ] + result = make_analyzer(msgs).topic_analysis() + assert result["target_follow_up_questions"] >= 0 # pass if no question + + +class TestSignalAnalyzer_LanguageFeatures: + def test_empty(self) -> None: + result = make_analyzer([]).language_features() + assert result["message_style"] == "混合型" + + def test_short_msg_style(self) -> None: + msgs = [make_msg("hi", "小美") for _ in range(10)] + result = make_analyzer(msgs).language_features() + assert result["message_style"] == "短句连发型" + + def test_particle_detection(self) -> None: + msgs = [Message(datetime(2024, 1, 1, 10), "小美", "哈哈很好笑哈哈哈") for _ in range(5)] + result = make_analyzer(msgs).language_features() + assert any(p == "哈哈" for p, _ in result["top_particles"]) + + +class TestSignalAnalyzer_SignalScore: + def test_score_not_high_when_user_initiates_all(self) -> None: + msgs = [ + Message(datetime(2024, 1, 1, 10, 0), "我", "hello"), + Message(datetime(2024, 1, 1, 10, 5), "小美", "hi"), + ] + result = make_analyzer(msgs).signal_score() + assert result["score"] <= 0 # no positive initiative signal + + def test_score_in_valid_range(self) -> None: + msgs = [make_msg("hello", "小美") for _ in range(20)] + result = make_analyzer(msgs).signal_score() + assert -3 <= result["score"] <= 25 + assert result["max_score"] == 25 + + +class TestSignalAnalyzer_SplitSessions: + def test_empty(self) -> None: + assert make_analyzer([])._split_sessions() == [] + + def test_single_message(self) -> None: + msgs = [make_msg("hi")] + sessions = make_analyzer(msgs)._split_sessions(gap_minutes=60) + assert len(sessions) == 1 + + def test_splits_on_gap(self) -> None: + msgs = [ + make_msg("a", hour=10, minute=0), + make_msg("b", hour=10, minute=30), + make_msg("c", hour=12, minute=30), + ] + sessions = make_analyzer(msgs)._split_sessions(gap_minutes=60) + assert len(sessions) == 2 + assert len(sessions[0]) == 2 + assert len(sessions[1]) == 1 + + +# ───────────────────────────────────────────── +# generate_report +# ───────────────────────────────────────────── + +class TestGenerateReport: + def test_empty_messages(self) -> None: + result = generate_report([], "小美", "我") + assert "未找到有效消息" in result + + def test_returns_string(self) -> None: + msgs = [make_msg("hello", "小美")] + result = generate_report(msgs, "小美", "我") + assert isinstance(result, str) + assert "小美" in result + + def test_output_path_writes_file(self, tmp_path: Path) -> None: + msgs = [make_msg("hi", "小美")] + out = tmp_path / "report.md" + generate_report(msgs, "小美", "我", str(out)) + assert out.exists() + assert "聊天记录" in out.read_text(encoding="utf-8") + + +# ───────────────────────────────────────────── +# extract_time_data / --track-time +# ───────────────────────────────────────────── + +import json as _json +from pathlib import Path as _Path + + +class TestTrackTime: + def test_extracts_interactions_from_messages(self, tmp_path: _Path) -> None: + chat_file = tmp_path / "chat.txt" + chat_file.write_text( + "2026-05-15 22:30:00\n我\n问她周末有没有空\n" + "2026-05-15 22:32:00\n小美\n有空呀,你有什么安排吗\n" + "2026-05-15 22:33:00\n我\n那一起去看展吧\n" + "2026-05-15 22:35:00\n小美\n好呀好呀!\n", + encoding="utf-8", + ) + + slug = "xiaomei" + crush_dir = tmp_path / "crushes" / slug + crush_dir.mkdir(parents=True) + (crush_dir / "interactions.jsonl").touch() + (crush_dir / "meta.json").write_text( + _json.dumps({"slug": slug, "interaction_count": 0}, ensure_ascii=False), + encoding="utf-8", + ) + + from tools.chat_parser import extract_time_data + messages = parse_chat(str(chat_file), "小美", "我") + extract_time_data(messages, "小美", "我", slug, base_dir=tmp_path / "crushes") + + interactions = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + assert len(interactions) == 4 + + first = _json.loads(interactions[0]) + assert first["type"] == "chat_sent" + assert first["data"]["hour"] == 22 + assert first["data"]["day_of_week"] == "fri" + + second = _json.loads(interactions[1]) + assert second["type"] == "chat_received" + assert second["data"]["reply_delay_min"] == 2 + + def test_skip_if_gap_over_4h(self, tmp_path: _Path) -> None: + chat_file = tmp_path / "chat.txt" + chat_file.write_text( + "2026-05-15 22:30:00\n我\n晚安\n" + "2026-05-16 10:00:00\n小美\n早呀\n", + encoding="utf-8", + ) + + slug = "xiaomei" + crush_dir = tmp_path / "crushes" / slug + crush_dir.mkdir(parents=True) + (crush_dir / "interactions.jsonl").touch() + (crush_dir / "meta.json").write_text( + _json.dumps({"slug": slug, "interaction_count": 0}, ensure_ascii=False), + encoding="utf-8", + ) + + from tools.chat_parser import extract_time_data + messages = parse_chat(str(chat_file), "小美", "我") + extract_time_data(messages, "小美", "我", slug, base_dir=tmp_path / "crushes") + + interactions = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + second = _json.loads(interactions[1]) + assert "reply_delay_min" not in second["data"] \ No newline at end of file diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..b31898a --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,336 @@ +"""tests/test_memory.py — memory.py 的 pytest 测试套件""" +import json +import pytest +from datetime import date +from pathlib import Path + +from tools.memory import ( + _parse_frontmatter, + _parse_body_sections, + _render_frontmatter, + append_event, + get_recent_events, + load_context, + rebuild_state_from_events, + take_snapshot, + update_state, +) + + +# ─── Fixtures ───────────────────────────────────────────────────────────────── + + +@pytest.fixture +def base_dir(tmp_path: Path) -> Path: + return tmp_path / "crushes" + + +@pytest.fixture +def slug() -> str: + return "testcrush" + + +@pytest.fixture +def crush_dir(base_dir: Path, slug: str) -> Path: + d = base_dir / slug + d.mkdir(parents=True) + (d / "events.jsonl").touch() + (d / "meta.json").write_text( + json.dumps({"slug": slug, "event_count": 0, "updated_at": "2026-01-01T00:00:00"}), + encoding="utf-8", + ) + (d / "profile.md").write_text( + "---\nnickname: 测试\nslug: testcrush\n---\n\n## 性格画像\n\n测试画像\n", + encoding="utf-8", + ) + (d / "state.md").write_text( + "---\ncurrent_stage: 破冰期\nsignal_score: null\nscore_trend: stable\n---\n\n" + "## 当前状态(一句话)\n\n初始状态\n", + encoding="utf-8", + ) + (d / "strategy.md").write_text("# 策略\n\n测试策略\n", encoding="utf-8") + (d / "snapshots").mkdir() + return d + + +# ─── _parse_frontmatter ──────────────────────────────────────────────────────── + + +def test_parse_frontmatter_basic() -> None: + content = '---\nname: 小美\nage: 22\nscore: null\n---\n\n正文' + fm, body = _parse_frontmatter(content) + assert fm["name"] == "小美" + assert fm["age"] == 22 + assert fm["score"] is None + assert "正文" in body + + +def test_parse_frontmatter_quoted_strings() -> None: + content = '---\nnickname: "测试用户"\n---\n\n正文' + fm, _ = _parse_frontmatter(content) + assert fm["nickname"] == "测试用户" + + +def test_parse_frontmatter_no_frontmatter() -> None: + content = "只是正文,没有 frontmatter" + fm, body = _parse_frontmatter(content) + assert fm == {} + assert body == content + + +# ─── _render_frontmatter ────────────────────────────────────────────────────── + + +def test_render_frontmatter_roundtrip() -> None: + original = '---\nname: 小美\nage: 22\nscore: null\n---\n\n正文' + fm, body = _parse_frontmatter(original) + rendered = _render_frontmatter(fm, body) + fm2, body2 = _parse_frontmatter(rendered) + assert fm2["name"] == "小美" + assert fm2["age"] == 22 + assert fm2["score"] is None + assert "正文" in body2 + + +def test_render_frontmatter_none_value() -> None: + result = _render_frontmatter({"key": None}, "\n正文") + assert "key: null" in result + + +# ─── _parse_body_sections ───────────────────────────────────────────────────── + + +def test_parse_body_sections_basic() -> None: + body = "\n## 第一节\n\n内容一\n\n## 第二节\n\n内容二\n" + sections = _parse_body_sections(body) + assert sections["## 第一节"] == "内容一" + assert sections["## 第二节"] == "内容二" + + +# ─── append_event ───────────────────────────────────────────────────────────── + + +def test_append_event_writes_jsonl(crush_dir: Path, base_dir: Path, slug: str) -> None: + append_event(slug, "profile_created", {"nickname": "小美"}, base_dir) + lines = (crush_dir / "events.jsonl").read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + event = json.loads(lines[0]) + assert event["type"] == "profile_created" + assert event["data"]["nickname"] == "小美" + assert event["slug"] == slug + assert event["v"] == 1 + + +def test_append_event_increments_event_count(crush_dir: Path, base_dir: Path, slug: str) -> None: + append_event(slug, "signal_recorded", {"direction": "green", "content": "秒回"}, base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["event_count"] == 1 + + +def test_append_event_multiple_increments(crush_dir: Path, base_dir: Path, slug: str) -> None: + for _ in range(3): + append_event(slug, "signal_recorded", {"direction": "green", "content": "x"}, base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["event_count"] == 3 + + +def test_append_event_invalid_type(crush_dir: Path, base_dir: Path, slug: str) -> None: + with pytest.raises(ValueError, match="未知事件类型"): + append_event(slug, "invalid_type", {}, base_dir) + + +def test_append_event_missing_slug(base_dir: Path) -> None: + with pytest.raises(FileNotFoundError): + append_event("nonexistent", "profile_created", {}, base_dir) + + +# ─── get_recent_events ──────────────────────────────────────────────────────── + + +def test_get_recent_events_returns_n(crush_dir: Path, base_dir: Path, slug: str) -> None: + for i in range(7): + append_event(slug, "signal_recorded", {"content": f"信号{i}", "direction": "green"}, base_dir) + events = get_recent_events(slug, n=5, base_dir=base_dir) + assert len(events) == 5 + assert events[-1]["data"]["content"] == "信号6" + + +def test_get_recent_events_filters_by_type(crush_dir: Path, base_dir: Path, slug: str) -> None: + append_event(slug, "signal_recorded", {"direction": "green", "content": "好"}, base_dir) + append_event(slug, "analysis_done", {"score": 15, "stage": "升温期", "summary": "ok"}, base_dir) + events = get_recent_events(slug, n=10, event_types=["analysis_done"], base_dir=base_dir) + assert len(events) == 1 + assert events[0]["type"] == "analysis_done" + + +def test_get_recent_events_empty_file(crush_dir: Path, base_dir: Path, slug: str) -> None: + events = get_recent_events(slug, base_dir=base_dir) + assert events == [] + + +def test_get_recent_events_no_file(base_dir: Path) -> None: + (base_dir / "ghost").mkdir(parents=True) + events = get_recent_events("ghost", base_dir=base_dir) + assert events == [] + + +# ─── load_context ───────────────────────────────────────────────────────────── + + +def test_load_context_includes_profile_and_state(crush_dir: Path, base_dir: Path, slug: str) -> None: + ctx = load_context(slug, base_dir=base_dir) + assert "[档案]" in ctx + assert "[状态]" in ctx + assert "测试画像" in ctx + assert "初始状态" in ctx + + +def test_load_context_with_strategy(crush_dir: Path, base_dir: Path, slug: str) -> None: + ctx = load_context(slug, include_strategy=True, base_dir=base_dir) + assert "[策略]" in ctx + assert "测试策略" in ctx + + +def test_load_context_without_strategy(crush_dir: Path, base_dir: Path, slug: str) -> None: + ctx = load_context(slug, include_strategy=False, base_dir=base_dir) + assert "[策略]" not in ctx + + +# ─── update_state ───────────────────────────────────────────────────────────── + + +def test_update_state_merges_frontmatter(crush_dir: Path, base_dir: Path, slug: str) -> None: + update_state(slug, {"signal_score": 17, "current_stage": "暧昧期"}, {}, base_dir) + fm, _ = _parse_frontmatter((crush_dir / "state.md").read_text(encoding="utf-8")) + assert fm["signal_score"] == 17 + assert fm["current_stage"] == "暧昧期" + + +def test_update_state_preserves_existing_fields(crush_dir: Path, base_dir: Path, slug: str) -> None: + update_state(slug, {"signal_score": 10}, {}, base_dir) + update_state(slug, {"current_stage": "升温期"}, {}, base_dir) + fm, _ = _parse_frontmatter((crush_dir / "state.md").read_text(encoding="utf-8")) + assert fm["signal_score"] == 10 + assert fm["current_stage"] == "升温期" + + +def test_update_state_merges_sections(crush_dir: Path, base_dir: Path, slug: str) -> None: + update_state(slug, {}, {"## 当前状态(一句话)": "ta开始主动找我了"}, base_dir) + content = (crush_dir / "state.md").read_text(encoding="utf-8") + assert "ta开始主动找我了" in content + + +def test_update_state_syncs_meta_stage(crush_dir: Path, base_dir: Path, slug: str) -> None: + update_state(slug, {"current_stage": "暧昧期"}, {}, base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["current_stage"] == "暧昧期" + + +def test_update_state_syncs_meta_score(crush_dir: Path, base_dir: Path, slug: str) -> None: + update_state(slug, {"signal_score": 18}, {}, base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["signal_score"] == 18 + + +def test_update_state_missing_slug(base_dir: Path) -> None: + with pytest.raises(FileNotFoundError): + update_state("nonexistent", {}, {}, base_dir) + + +# ─── take_snapshot ──────────────────────────────────────────────────────────── + + +def test_take_snapshot_creates_file(crush_dir: Path, base_dir: Path, slug: str) -> None: + snap_path = take_snapshot(slug, base_dir) + assert snap_path.exists() + snap = json.loads(snap_path.read_text(encoding="utf-8")) + assert snap["slug"] == slug + assert snap["snapshot_date"] == date.today().isoformat() + + +def test_take_snapshot_includes_meta(crush_dir: Path, base_dir: Path, slug: str) -> None: + snap_path = take_snapshot(slug, base_dir) + snap = json.loads(snap_path.read_text(encoding="utf-8")) + assert "meta" in snap + assert snap["meta"]["slug"] == slug + + +def test_take_snapshot_updates_last_snapshot(crush_dir: Path, base_dir: Path, slug: str) -> None: + take_snapshot(slug, base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["last_snapshot"] == date.today().isoformat() + + +# ─── rebuild_state_from_events ──────────────────────────────────────────────── + + +def test_rebuild_state_tracks_stage_changes(crush_dir: Path, base_dir: Path, slug: str) -> None: + append_event(slug, "stage_changed", {"from": "破冰期", "to": "升温期"}, base_dir) + append_event(slug, "stage_changed", {"from": "升温期", "to": "暧昧期"}, base_dir) + state = rebuild_state_from_events(slug, base_dir) + assert state["current_stage"] == "暧昧期" + + +def test_rebuild_state_tracks_scores_and_trend(crush_dir: Path, base_dir: Path, slug: str) -> None: + append_event(slug, "analysis_done", {"score": 10, "stage": "升温期", "summary": "ok"}, base_dir) + append_event(slug, "analysis_done", {"score": 15, "stage": "暧昧期", "summary": "好"}, base_dir) + state = rebuild_state_from_events(slug, base_dir) + assert state["signal_score"] == 15 + assert state["last_signal_score"] == 10 + assert state["score_trend"] == "up" + + +def test_rebuild_state_score_trend_down(crush_dir: Path, base_dir: Path, slug: str) -> None: + append_event(slug, "analysis_done", {"score": 18, "stage": "暧昧期", "summary": "好"}, base_dir) + append_event(slug, "analysis_done", {"score": 12, "stage": "升温期", "summary": "变冷"}, base_dir) + state = rebuild_state_from_events(slug, base_dir) + assert state["score_trend"] == "down" + + +def test_rebuild_state_empty_events(crush_dir: Path, base_dir: Path, slug: str) -> None: + state = rebuild_state_from_events(slug, base_dir) + assert state["current_stage"] == "未知" + assert state["signal_score"] is None + + +# ─── P2 regression: frontmatter must not corrupt identifier fields (C5) ──────── + + +def test_parse_frontmatter_preserves_leading_zero_slug() -> None: + """slug 是档案主键,含前导零时绝不能被强转成 int(007 -> 7 会损坏目录查找)。""" + fm, _ = _parse_frontmatter("---\nslug: 007\nnickname: 小美\n---\n\n正文") + assert fm["slug"] == "007" + assert isinstance(fm["slug"], str) + + +def test_frontmatter_roundtrip_preserves_leading_zero_slug() -> None: + """parse -> render -> parse 往返必须保住前导零(update_state 每次都会触发这条往返)。""" + fm, body = _parse_frontmatter("---\nslug: 007\n---\n\nbody") + fm2, _ = _parse_frontmatter(_render_frontmatter(fm, body)) + assert fm2["slug"] == "007" + + +# ─── P2 regression: JSONL append must survive a truncated last line (C4) ─────── + + +def test_append_event_after_truncated_line_keeps_new_event( + crush_dir: Path, base_dir: Path, slug: str +) -> None: + """上次写入崩溃留下半行(无尾换行)时,新事件必须独立可读,而非被拼接进损坏行后一起丢失。""" + events_path = crush_dir / "events.jsonl" + events_path.write_text('{"ts": "2026-01-01T00:00:00", "type": "signal_rec', encoding="utf-8") + append_event(slug, "signal_recorded", {"direction": "green", "content": "新事件"}, base_dir) + events = get_recent_events(slug, n=10, base_dir=base_dir) + assert any(e["data"].get("content") == "新事件" for e in events) + + +def test_event_count_matches_readable_after_corruption( + crush_dir: Path, base_dir: Path, slug: str +) -> None: + """meta.event_count 必须等于实际可读事件数,不能在存在损坏行时盲目自增而漂移。""" + events_path = crush_dir / "events.jsonl" + events_path.write_text('{"bad json no close', encoding="utf-8") + append_event(slug, "signal_recorded", {"direction": "green", "content": "x"}, base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + readable = get_recent_events(slug, n=10_000, base_dir=base_dir) + assert meta["event_count"] == len(readable) diff --git a/tests/test_photo_analyzer.py b/tests/test_photo_analyzer.py new file mode 100644 index 0000000..4d8cd83 --- /dev/null +++ b/tests/test_photo_analyzer.py @@ -0,0 +1,283 @@ +""" +tests/test_photo_analyzer.py +pytest 测试套件 · photo_analyzer.py +""" + +import math +import pytest +from datetime import datetime +from pathlib import Path + +from tools.photo_analyzer import ( + get_datetime, + _dms_to_decimal, + get_gps, + get_make_model, + detect_meetups, + _gps_distance_km, +) + +# PIL may not be installed in test env; skip GPS tests that need GPSTAGS +try: + from PIL.ExifTags import GPSTAGS + PIL_AVAILABLE = True +except Exception: + PIL_AVAILABLE = False + + +# ───────────────────────────────────────────── +# 纯函数 +# ───────────────────────────────────────────── + +class TestDmsToDecimal: + def test_positive_north_east(self) -> None: + result = _dms_to_decimal((40, 30, 15), "N") + assert 40.5 < result < 41 + + def test_positive_south_west(self) -> None: + result = _dms_to_decimal((40, 30, 15), "S") + assert result < 0 + + def test_west_negative(self) -> None: + result = _dms_to_decimal((10, 0, 0), "W") + assert result < 0 + + def test_invalid_input_returns_zero(self) -> None: + result = _dms_to_decimal(("bad", None, 0), "N") + assert result == 0.0 + + def test_rounds_to_6_decimals(self) -> None: + result = _dms_to_decimal((40, 30, 15.123456), "N") + decimal_part = str(result).split(".")[-1] + assert len(decimal_part) <= 6 + + +class TestGpsDistanceKm: + def test_same_point_zero(self) -> None: + a = {"lat": 31.23, "lon": 121.47} + assert _gps_distance_km(a, a) == 0.0 + + def test_known_distance(self) -> None: + # Shanghai to Hangzhou ~160km + sh = {"lat": 31.2304, "lon": 121.4737} + hz = {"lat": 30.2741, "lon": 120.1551} + dist = _gps_distance_km(sh, hz) + assert 150 < dist < 170 + + def test_symmetric(self) -> None: + a = {"lat": 31.0, "lon": 121.0} + b = {"lat": 32.0, "lon": 122.0} + assert abs(_gps_distance_km(a, b) - _gps_distance_km(b, a)) < 0.001 + + +class TestGetDatetime: + def test_date_time_original(self) -> None: + # 注意:EXIF 日期格式是 YYYY:MM:DD HH:MM:SS(用冒号分隔) + exif = {"DateTimeOriginal": "2024:03:15 14:30:00"} + result = get_datetime(exif) + assert result == datetime(2024, 3, 15, 14, 30, 0) + + def test_date_time_fallback(self) -> None: + # DateTime 格式不同,strptime 会失败,函数应该 fallback + exif = {"DateTime": "2024-05-01 08:00:00"} + result = get_datetime(exif) + assert result is None # wrong format, falls through + + def test_no_time_in_exif(self) -> None: + assert get_datetime({}) is None + assert get_datetime({"Make": "Canon"}) is None + + def test_invalid_format_falls_through(self) -> None: + exif = {"DateTimeOriginal": "not a date"} + assert get_datetime(exif) is None + + +class TestGetGps: + @pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not installed") + def test_full_gps_data(self) -> None: + gps_raw = { + 1: "N", + 2: (31, 12, 36), + 3: "E", + 4: (121, 30, 0), + } + exif = {"GPSInfo": gps_raw} + result = get_gps(exif) + assert result is not None + assert 30 < result["lat"] < 32 + assert 121 < result["lon"] < 122 + + @pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not installed") + def test_no_gps_info(self) -> None: + assert get_gps({}) is None + + @pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not installed") + def test_only_latitude(self) -> None: + exif = {"GPSInfo": {1: "N", 2: (31, 12, 36)}} + assert get_gps(exif) is None # needs both lat and lon + + +class TestGetMakeModel: + def test_both_present(self) -> None: + exif = {"Make": "Apple", "Model": "iPhone 15"} + assert get_make_model(exif) == "Apple iPhone 15" + + def test_model_only(self) -> None: + exif = {"Model": "Canon EOS R5"} + assert get_make_model(exif) == "Canon EOS R5" + + def test_make_only(self) -> None: + exif = {"Make": "Samsung"} + assert get_make_model(exif) == "Samsung" + + def test_empty_exif(self) -> None: + assert get_make_model({}) == "" + + def test_whitespace_stripped(self) -> None: + exif = {"Make": " Sony ", "Model": " α7 "} + assert " " not in get_make_model(exif) + + +# ───────────────────────────────────────────── +# detect_meetups · 核心逻辑 +# ───────────────────────────────────────────── + +def photo(datetime_str: str, lat: float = None, lon: float = None) -> dict: + dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M") + p = {"datetime": dt, "gps": None} + if lat is not None and lon is not None: + p["gps"] = {"lat": lat, "lon": lon} + return p + + +class TestDetectMeetups: + def test_empty_list(self) -> None: + assert detect_meetups([]) == [] + + def test_single_photo(self) -> None: + assert detect_meetups([photo("2024-01-01 10:00")]) == [] + + def test_two_photos_same_time_and_place(self) -> None: + photos = [ + photo("2024-01-01 10:00", 31.23, 121.47), + photo("2024-01-01 10:30", 31.23, 121.47), + ] + meetups = detect_meetups(photos) + assert len(meetups) == 1 + assert meetups[0]["photo_count"] == 2 + + def test_different_times_no_meetup(self) -> None: + photos = [ + photo("2024-01-01 10:00", 31.23, 121.47), + photo("2024-01-01 16:00", 31.23, 121.47), + ] + meetups = detect_meetups(photos, time_gap_hours=4.0) + assert meetups == [] + + def test_far_apart_location_no_meetup(self) -> None: + photos = [ + photo("2024-01-01 10:00", 31.23, 121.47), + photo("2024-01-01 10:30", 29.55, 121.72), # ~190km away + ] + meetups = detect_meetups(photos, location_radius_km=2.0) + assert meetups == [] + + def test_no_gps_uses_time_only(self) -> None: + photos = [ + photo("2024-01-01 10:00"), + photo("2024-01-01 10:30"), + ] + meetups = detect_meetups(photos, time_gap_hours=4.0) + assert len(meetups) == 1 + + def test_custom_time_gap(self) -> None: + photos = [ + photo("2024-01-01 10:00"), + photo("2024-01-01 14:00"), + ] + assert len(detect_meetups(photos, time_gap_hours=4.0)) == 1 + assert len(detect_meetups(photos, time_gap_hours=2.0)) == 0 + + def test_multiple_meetups(self) -> None: + photos = [ + photo("2024-01-01 10:00"), + photo("2024-01-01 10:30"), + photo("2024-01-03 14:00"), + photo("2024-01-03 15:00"), + ] + meetups = detect_meetups(photos) + assert len(meetups) == 2 + + def test_photos_without_datetime_ignored(self) -> None: + photos = [ + photo("2024-01-01 10:00"), + {"datetime": None}, + photo("2024-01-01 10:30"), + ] + meetups = detect_meetups(photos) + assert len(meetups) == 1 + + def test_meetup_has_required_fields(self) -> None: + photos = [ + photo("2024-01-01 10:00", 31.23, 121.47), + photo("2024-01-01 11:00", 31.23, 121.47), + ] + m = detect_meetups(photos)[0] + assert "date" in m + assert "start" in m + assert "end" in m + assert "duration_min" in m + assert "photo_count" in m + assert m["photo_count"] == 2 + + def test_meetup_sorted_by_date(self) -> None: + photos = [ + photo("2024-02-01 10:00"), + photo("2024-02-01 11:00"), + photo("2024-01-01 10:00"), + photo("2024-01-01 11:00"), + ] + meetups = detect_meetups(photos) + assert meetups[0]["date"].startswith("2024-01-01") + + +# ───────────────────────────────────────────── +# scan_photos · 目录扫描 +# ───────────────────────────────────────────── + +class TestScanPhotos: + def test_nonexistent_dir(self) -> None: + from tools.photo_analyzer import scan_photos + result = scan_photos("/no/such/dir") + assert result == [] + + +# ───────────────────────────────────────────── +# generate_report · 报告生成 +# ───────────────────────────────────────────── + +class TestGenerateReport: + def test_no_pillow_returns_warning(self) -> None: + from unittest.mock import patch + from tools.photo_analyzer import generate_report, PIL_AVAILABLE + with patch("tools.photo_analyzer.PIL_AVAILABLE", False): + result = generate_report("/any/path", "小美") + assert "Pillow" in result or "⚠️" in result + + def test_empty_directory_returns_no_photos_message(self, tmp_path: Path) -> None: + from unittest.mock import patch + from tools.photo_analyzer import generate_report + with patch("tools.photo_analyzer.PIL_AVAILABLE", True): + with patch("tools.photo_analyzer.scan_photos", return_value=[]): + result = generate_report(str(tmp_path), "小美") + assert "照片元数据分析报告" in result + assert "未找到任何照片" in result + + def test_output_path_writes_file(self, tmp_path: Path) -> None: + from unittest.mock import patch + from tools.photo_analyzer import generate_report + out = tmp_path / "report.md" + with patch("tools.photo_analyzer.PIL_AVAILABLE", True): + with patch("tools.photo_analyzer.scan_photos", return_value=[]): + generate_report(str(tmp_path), "小美", str(out)) + assert out.exists() \ No newline at end of file diff --git a/tests/test_skill_writer.py b/tests/test_skill_writer.py new file mode 100644 index 0000000..d272b4e --- /dev/null +++ b/tests/test_skill_writer.py @@ -0,0 +1,324 @@ +""" +tests/test_skill_writer.py +pytest 测试套件 · skill_writer.py +""" + +import json +import logging +import pytest +from pathlib import Path + +from tools.skill_writer import ( + backup_crush, + init_crush, + list_crushes, + list_versions, + rollback_crush, + update_meta, + SIGNAL_SCORE_MIN, + SIGNAL_SCORE_MAX, +) + + +@pytest.fixture +def tmp_base(tmp_path: Path) -> Path: + return tmp_path / "crushes" + + +# --------------------------------------------------------------------------- +# init_crush +# --------------------------------------------------------------------------- + + +class TestInitCrush: + def test_creates_directory_structure(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + crush_dir = tmp_base / "xiaomei" + assert crush_dir.is_dir() + assert (crush_dir / "profile.md").exists() + assert (crush_dir / "state.md").exists() + assert (crush_dir / "events.jsonl").exists() + assert (crush_dir / "strategy.md").exists() + assert (crush_dir / "meta.json").exists() + assert (crush_dir / "memories" / "chats").is_dir() + assert (crush_dir / "memories" / "social").is_dir() + assert (crush_dir / "memories" / "photos").is_dir() + assert (crush_dir / "versions").is_dir() + assert (crush_dir / "snapshots").is_dir() + + def test_meta_json_defaults(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["slug"] == "xiaomei" + assert meta["version"] == "v1" + assert meta["mode"] == "hybrid" + assert meta["signal_score"] is None + assert meta["current_stage"] == "未知" + assert meta["nickname"] == "[待填写]" + assert meta["event_count"] == 0 + assert meta["last_snapshot"] is None + + def test_profile_md_has_yaml_frontmatter(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + content = (tmp_base / "xiaomei" / "profile.md").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "slug: xiaomei" in content + assert "personality_type:" in content + assert "created_at:" in content + + def test_state_md_has_yaml_frontmatter(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + content = (tmp_base / "xiaomei" / "state.md").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "current_stage:" in content + assert "signal_score:" in content + assert "milestones_done:" in content + + def test_events_jsonl_is_empty_on_init(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + content = (tmp_base / "xiaomei" / "events.jsonl").read_text(encoding="utf-8") + assert content == "" + + def test_does_not_overwrite_existing_profile(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + profile_path = tmp_base / "xiaomei" / "profile.md" + profile_path.write_text("custom content", encoding="utf-8") + init_crush("xiaomei", tmp_base) + assert profile_path.read_text(encoding="utf-8") == "custom content" + + def test_does_not_overwrite_existing_state(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + state_path = tmp_base / "xiaomei" / "state.md" + state_path.write_text("custom state", encoding="utf-8") + init_crush("xiaomei", tmp_base) + assert state_path.read_text(encoding="utf-8") == "custom state" + + def test_does_not_overwrite_existing_events(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + events_path = tmp_base / "xiaomei" / "events.jsonl" + events_path.write_text('{"type":"test"}\n', encoding="utf-8") + init_crush("xiaomei", tmp_base) + assert events_path.read_text(encoding="utf-8") == '{"type":"test"}\n' + + def test_idempotent_on_repeated_calls(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + init_crush("xiaomei", tmp_base) + assert (tmp_base / "xiaomei" / "meta.json").exists() + + +# --------------------------------------------------------------------------- +# list_crushes +# --------------------------------------------------------------------------- + + +class TestListCrushes: + def test_nonexistent_base_dir(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.INFO): + list_crushes(tmp_base / "nonexistent") + assert "还没有任何心上人档案" in caplog.text + + def test_empty_base_dir(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: + tmp_base.mkdir() + with caplog.at_level(logging.INFO): + list_crushes(tmp_base) + assert "还没有任何心上人档案" in caplog.text + + def test_lists_existing_crushes(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: + init_crush("xiaomei", tmp_base) + with caplog.at_level(logging.INFO): + list_crushes(tmp_base) + assert "xiaomei" in caplog.text + + def test_shows_score_as_not_evaluated_when_none( + self, tmp_base: Path, caplog: pytest.LogCaptureFixture + ) -> None: + init_crush("xiaomei", tmp_base) + with caplog.at_level(logging.INFO): + list_crushes(tmp_base) + assert "未评估" in caplog.text + + def test_shows_score_when_set(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, signal_score=18) + with caplog.at_level(logging.INFO): + list_crushes(tmp_base) + assert "18/25" in caplog.text + + +# --------------------------------------------------------------------------- +# backup_crush +# --------------------------------------------------------------------------- + + +class TestBackupCrush: + def test_backup_creates_version_directory(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + version_name = backup_crush("xiaomei", tmp_base) + assert version_name != "" + assert (tmp_base / "xiaomei" / "versions" / version_name).is_dir() + + def test_backup_copies_core_files(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + version_name = backup_crush("xiaomei", tmp_base) + version_dir = tmp_base / "xiaomei" / "versions" / version_name + assert (version_dir / "profile.md").exists() + assert (version_dir / "state.md").exists() + assert (version_dir / "strategy.md").exists() + assert (version_dir / "meta.json").exists() + + def test_backup_increments_version_number(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + backup_crush("xiaomei", tmp_base) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["version"] == "v2" + + def test_backup_nonexistent_slug_returns_empty(self, tmp_base: Path) -> None: + result = backup_crush("nobody", tmp_base) + assert result == "" + + +# --------------------------------------------------------------------------- +# rollback_crush +# --------------------------------------------------------------------------- + + +class TestRollbackCrush: + def test_rollback_restores_profile(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + profile_path = tmp_base / "xiaomei" / "profile.md" + original_content = profile_path.read_text(encoding="utf-8") + + backup_crush("xiaomei", tmp_base) + profile_path.write_text("modified content", encoding="utf-8") + + rollback_crush("xiaomei", "v1", tmp_base) + assert profile_path.read_text(encoding="utf-8") == original_content + + def test_rollback_creates_backup_before_restoring(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + backup_crush("xiaomei", tmp_base) + rollback_crush("xiaomei", "v1", tmp_base) + versions_dir = tmp_base / "xiaomei" / "versions" + assert len(list(versions_dir.iterdir())) == 2 + + def test_rollback_nonexistent_version( + self, tmp_base: Path, caplog: pytest.LogCaptureFixture + ) -> None: + init_crush("xiaomei", tmp_base) + backup_crush("xiaomei", tmp_base) + with caplog.at_level(logging.ERROR): + rollback_crush("xiaomei", "v99", tmp_base) + assert "不存在" in caplog.text + + def test_rollback_no_version_history( + self, tmp_base: Path, caplog: pytest.LogCaptureFixture + ) -> None: + init_crush("xiaomei", tmp_base) + with caplog.at_level(logging.ERROR): + rollback_crush("xiaomei", "v1", tmp_base) + assert "没有找到版本历史" in caplog.text + + +# --------------------------------------------------------------------------- +# list_versions +# --------------------------------------------------------------------------- + + +class TestListVersions: + def test_no_versions(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: + init_crush("xiaomei", tmp_base) + with caplog.at_level(logging.INFO): + list_versions("xiaomei", tmp_base) + assert "没有版本历史" in caplog.text + + def test_lists_existing_versions(self, tmp_base: Path, caplog: pytest.LogCaptureFixture) -> None: + init_crush("xiaomei", tmp_base) + backup_crush("xiaomei", tmp_base) + with caplog.at_level(logging.INFO): + list_versions("xiaomei", tmp_base) + assert "v1_" in caplog.text + + +# --------------------------------------------------------------------------- +# update_meta +# --------------------------------------------------------------------------- + + +class TestUpdateMeta: + def test_updates_stage(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, current_stage="暧昧期") + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["current_stage"] == "暧昧期" + + def test_updates_mode(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, mode="sweet") + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["mode"] == "sweet" + + def test_valid_score_is_saved(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, signal_score=18) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["signal_score"] == 18 + + def test_zero_score_is_saved(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, signal_score=0) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["signal_score"] == 0 + + def test_negative_score_within_range_is_saved(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, signal_score=-5) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["signal_score"] == -5 + + def test_score_above_max_is_rejected(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, signal_score=SIGNAL_SCORE_MAX + 1) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["signal_score"] is None + + def test_score_below_min_is_rejected(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, signal_score=SIGNAL_SCORE_MIN - 1) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["signal_score"] is None + + def test_nonexistent_slug_is_noop( + self, tmp_base: Path, caplog: pytest.LogCaptureFixture + ) -> None: + with caplog.at_level(logging.ERROR): + update_meta("nobody", tmp_base, current_stage="暧昧期") + assert "不存在" in caplog.text + + def test_does_not_lose_existing_fields(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + update_meta("xiaomei", tmp_base, current_stage="升温期") + update_meta("xiaomei", tmp_base, signal_score=12) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["current_stage"] == "升温期" + assert meta["signal_score"] == 12 + assert meta["slug"] == "xiaomei" + + +# --------------------------------------------------------------------------- +# init_crush time tracking (Task 5) +# --------------------------------------------------------------------------- + + +class TestInitCrushTimeTracking: + def test_creates_interactions_jsonl(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + interactions_path = tmp_base / "xiaomei" / "interactions.jsonl" + assert interactions_path.exists() + assert interactions_path.read_text(encoding="utf-8") == "" + + def test_meta_json_has_time_tracking_fields(self, tmp_base: Path) -> None: + init_crush("xiaomei", tmp_base) + meta = json.loads((tmp_base / "xiaomei" / "meta.json").read_text(encoding="utf-8")) + assert meta["interaction_count"] == 0 + assert meta["last_interaction"] is None + assert meta["consecutive_days"] == 0 diff --git a/tests/test_social_parser.py b/tests/test_social_parser.py new file mode 100644 index 0000000..230d657 --- /dev/null +++ b/tests/test_social_parser.py @@ -0,0 +1,226 @@ +""" +tests/test_social_parser.py +pytest 测试套件 · social_parser.py +""" + +import pytest +import json +from pathlib import Path + +from tools.social_parser import ( + detect_platform, + platform_display, + scan_directory, + read_text_file, + parse_json_export, + scan_signals, + generate_report, +) + + +# ───────────────────────────────────────────── +# 纯函数 +# ───────────────────────────────────────────── + +class TestDetectPlatform: + def test_weibo(self) -> None: + assert detect_platform("微博_2024.txt") == "weibo" + assert detect_platform("WB_001.jpg") == "weibo" + + def test_xiaohongshu(self) -> None: + assert detect_platform("小红书截图.png") == "xiaohongshu" + assert detect_platform("xhs_01.jpg") == "xiaohongshu" + assert detect_platform("rednote_post.txt") == "xiaohongshu" + + def test_moments(self) -> None: + assert detect_platform("朋友圈截图.jpg") == "moments" + assert detect_platform("wechat_moments.png") == "moments" + + def test_douyin(self) -> None: + assert detect_platform("抖音视频.jpg") == "douyin" + assert detect_platform("dy_001.png") == "douyin" + + def test_unknown(self) -> None: + assert detect_platform("random_file.jpg") == "未知平台" + assert detect_platform("untitled.txt") == "未知平台" + + +class TestPlatformDisplay: + def test_known_platforms(self) -> None: + assert platform_display("weibo") == "微博" + assert platform_display("xiaohongshu") == "小红书" + assert platform_display("moments") == "微信朋友圈" + assert platform_display("douyin") == "抖音" + assert platform_display("instagram") == "Instagram" + + def test_unknown(self) -> None: + assert platform_display("未知平台") == "未知来源" + assert platform_display("random") == "random" + + +class TestScanSignals: + def test_positive_signals(self) -> None: + result = scan_signals("今天和你在一起很开心,期待下次见面!") + assert "积极信号" in result + assert "开心" in result["积极信号"] or "期待" in result["积极信号"] + + def test_low_mood_signals(self) -> None: + result = scan_signals("今天心情不好,失眠了,好难过") + assert "情绪低落" in result + assert "难过" in result["情绪低落"] + + def test_love_related(self) -> None: + result = scan_signals("暗恋一个人很久了,想表白") + assert "感情相关" in result + assert "暗恋" in result["感情相关"] + + def test_no_signal(self) -> None: + result = scan_signals("今天吃了火锅") + assert result == {} + + def test_catches_multiple_categories(self) -> None: + result = scan_signals("想你啊,好难过") + assert len(result) >= 2 # "积极信号" or "情绪低落" + + +# ───────────────────────────────────────────── +# 文件操作 +# ───────────────────────────────────────────── + +class TestReadTextFile: + def test_read_normal_file(self, tmp_path: Path) -> None: + f = tmp_path / "note.txt" + f.write_text("这是内容", encoding="utf-8") + assert read_text_file(str(f)) == "这是内容" + + def test_truncate_long_file(self, tmp_path: Path) -> None: + f = tmp_path / "long.txt" + f.write_text("x" * 10000, encoding="utf-8") + result = read_text_file(str(f), max_chars=100) + assert len(result) <= 100 + 50 # allow for truncation msg + assert "[... 内容过长" in result + + def test_read_nonexistent(self) -> None: + result = read_text_file("/no/such/file.txt") + assert "[读取失败" in result + + def test_encoding_errors_ignored(self, tmp_path: Path) -> None: + f = tmp_path / "weird.txt" + f.write_bytes(b"\x00\xff\xfe bad content") + result = read_text_file(str(f)) + assert isinstance(result, str) + + +class TestParseJsonExport: + def test_list_format(self, tmp_path: Path) -> None: + f = tmp_path / "export.json" + f.write_text(json.dumps([ + {"text": "微博内容1", "created_at": "2024-01-01"}, + {"text": "微博内容2", "time": "2024-01-02"}, + ]), encoding="utf-8") + posts = parse_json_export(str(f)) + assert len(posts) == 2 + assert posts[0]["text"] == "微博内容1" + + def test_dict_with_data_key(self, tmp_path: Path) -> None: + f = tmp_path / "export.json" + f.write_text(json.dumps({"data": [ + {"content": "小红书笔记", "timestamp": "2024-01-01"}, + ]}), encoding="utf-8") + posts = parse_json_export(str(f)) + assert len(posts) == 1 + assert posts[0]["text"] == "小红书笔记" + + def test_empty_json(self, tmp_path: Path) -> None: + f = tmp_path / "empty.json" + f.write_text("{}", encoding="utf-8") + assert parse_json_export(str(f)) == [] + + def test_max_50_items(self, tmp_path: Path) -> None: + f = tmp_path / "many.json" + items = [{"text": f"post_{i}"} for i in range(100)] + f.write_text(json.dumps(items), encoding="utf-8") + posts = parse_json_export(str(f)) + assert len(posts) == 50 + + def test_invalid_json(self, tmp_path: Path) -> None: + f = tmp_path / "bad.json" + f.write_text("not json", encoding="utf-8") + assert parse_json_export(str(f)) == [] + + def test_handles_other_keys(self, tmp_path: Path) -> None: + f = tmp_path / "other.json" + f.write_text(json.dumps([ + {"description": "微博描述", "date": "2024-01-01"}, + {"body": "帖子正文", "timestamp": "2024-01-02"}, + ]), encoding="utf-8") + posts = parse_json_export(str(f)) + assert len(posts) == 2 + + +class TestScanDirectory: + def test_empty_dir(self, tmp_path: Path) -> None: + result = scan_directory(str(tmp_path)) + assert result["images"] == [] + assert result["texts"] == [] + + def test_finds_images_and_texts(self, tmp_path: Path) -> None: + (tmp_path / "a.jpg").write_text("x") + (tmp_path / "b.png").write_text("y") + (tmp_path / "note.txt").write_text("note") + (tmp_path / "readme.md").write_text("md") + (tmp_path / "data.json").write_text("{}") + result = scan_directory(str(tmp_path)) + assert len(result["images"]) == 2 + assert len(result["texts"]) == 3 + + def test_report_md_excluded(self, tmp_path: Path) -> None: + (tmp_path / "report.md").write_text("x") + result = scan_directory(str(tmp_path)) + names = {p["name"] for p in result["others"]} + assert "report.md" not in names + + def test_nonexistent_dir(self) -> None: + result = scan_directory("/no/such/path") + assert result["images"] == [] + assert result["texts"] == [] + + def test_subdirectory_scan(self, tmp_path: Path) -> None: + sub = tmp_path / "sub" + sub.mkdir() + (sub / "deep.jpg").write_text("x") + result = scan_directory(str(tmp_path)) + assert any(p["rel"].startswith("sub") for p in result["images"]) + + def test_others_category(self, tmp_path: Path) -> None: + (tmp_path / "video.mp4").write_text("x") + result = scan_directory(str(tmp_path)) + assert len(result["others"]) == 1 + + +class TestGenerateReport: + def test_empty_directory(self, tmp_path: Path) -> None: + result = generate_report(str(tmp_path), "小美") + assert "社交媒体内容报告" in result + assert "⚠️" in result + + def test_returns_string(self, tmp_path: Path) -> None: + result = generate_report(str(tmp_path), "小美") + assert isinstance(result, str) + + def test_with_image_files(self, tmp_path: Path) -> None: + (tmp_path / "weibo_01.jpg").write_text("x") + (tmp_path / "小红书截图.png").write_text("x") + result = generate_report(str(tmp_path), "小美") + assert "图片清单" in result + + def test_with_text_files(self, tmp_path: Path) -> None: + (tmp_path / "note.txt").write_text("今天很开心,想你!", encoding="utf-8") + result = generate_report(str(tmp_path), "小美") + assert "文字内容" in result + assert "积极信号" in result + + def test_output_path_writes_file(self, tmp_path: Path) -> None: + out = tmp_path / "report.md" + generate_report(str(tmp_path), "小美", str(out)) + assert out.exists() \ No newline at end of file diff --git a/tests/test_time_tracker.py b/tests/test_time_tracker.py new file mode 100644 index 0000000..5fe04e1 --- /dev/null +++ b/tests/test_time_tracker.py @@ -0,0 +1,384 @@ +"""tests/test_time_tracker.py — time_tracker.py 的 pytest 测试套件""" +import json +import pytest +from datetime import datetime, timedelta +from pathlib import Path + +from tools.time_tracker import ( + VALID_INTERACTION_TYPES, + record_interaction, + get_interactions, + get_reply_times, + get_interaction_frequency, + analyze_timeline, + analyze_reply_times, + analyze_golden_hours, +) + + +@pytest.fixture +def base_dir(tmp_path: Path) -> Path: + return tmp_path / "crushes" + + +@pytest.fixture +def slug() -> str: + return "testcrush" + + +@pytest.fixture +def crush_dir(base_dir: Path, slug: str) -> Path: + d = base_dir / slug + d.mkdir(parents=True) + (d / "interactions.jsonl").touch() + (d / "meta.json").write_text( + json.dumps( + { + "slug": slug, + "event_count": 0, + "updated_at": "2026-01-01T00:00:00", + "interaction_count": 0, + "last_interaction": None, + "consecutive_days": 0, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return d + + +class TestRecordInteraction: + def test_appends_chat_sent(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts = datetime(2026, 5, 15, 22, 30, 0) + record_interaction( + slug, + "chat_sent", + {"content_summary": "问她周末有没有空"}, + ts=ts, + base_dir=base_dir, + ) + lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["type"] == "chat_sent" + assert record["ts"] == "2026-05-15T22:30:00" + assert record["data"]["hour"] == 22 + assert record["data"]["day_of_week"] == "fri" + assert record["data"]["is_initiator"] is True + + def test_appends_chat_received_with_reply_delay(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts = datetime(2026, 5, 15, 22, 32, 0) + record_interaction( + slug, + "chat_received", + {"content_summary": "有空呀", "reply_delay_min": 2}, + ts=ts, + base_dir=base_dir, + ) + lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + record = json.loads(lines[0]) + assert record["data"]["reply_delay_min"] == 2 + assert record["data"]["is_initiator"] is False + + def test_appends_meeting(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts = datetime(2026, 5, 17, 19, 0, 0) + record_interaction( + slug, + "meeting", + {"duration_min": 180, "activity": "咖啡+散步", "location": "外滩"}, + ts=ts, + base_dir=base_dir, + ) + lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + record = json.loads(lines[0]) + assert record["type"] == "meeting" + assert record["data"]["duration_min"] == 180 + + def test_raises_on_unknown_type(self, base_dir: Path, slug: str) -> None: + with pytest.raises(ValueError, match="未知互动类型"): + record_interaction(slug, "unknown_type", {}, base_dir=base_dir) + + def test_raises_on_missing_slug(self, base_dir: Path) -> None: + with pytest.raises(FileNotFoundError): + record_interaction("nonexistent", "chat_sent", {"content_summary": "hi"}, base_dir=base_dir) + + def test_updates_meta_json(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts = datetime(2026, 5, 15, 22, 30, 0) + record_interaction(slug, "chat_sent", {"content_summary": "hi"}, ts=ts, base_dir=base_dir) + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["interaction_count"] == 1 + assert meta["last_interaction"] == "2026-05-15T22:30:00" + + def test_deduplicates_same_ts_and_type(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts = datetime(2026, 5, 15, 22, 30, 0) + data = {"content_summary": "hi"} + record_interaction(slug, "chat_sent", data, ts=ts, base_dir=base_dir) + record_interaction(slug, "chat_sent", data, ts=ts, base_dir=base_dir) + lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + + def test_defaults_ts_to_now(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + record_interaction(slug, "chat_sent", {"content_summary": "hi"}, base_dir=base_dir) + lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() + record = json.loads(lines[0]) + assert record["ts"].startswith("202") + + +class TestGetInteractions: + def test_returns_all_by_default(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + for i in range(5): + ts = datetime(2026, 5, 10 + i, 10, 0, 0) + record_interaction(slug, "chat_sent", {"content_summary": f"msg {i}"}, ts=ts, base_dir=base_dir) + result = get_interactions(slug, base_dir=base_dir) + assert len(result) == 5 + + def test_filters_by_type(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts1 = datetime(2026, 5, 10, 10, 0, 0) + ts2 = datetime(2026, 5, 10, 10, 5, 0) + record_interaction(slug, "chat_sent", {"content_summary": "hi"}, ts=ts1, base_dir=base_dir) + record_interaction(slug, "meeting", {"duration_min": 60, "activity": "coffee"}, ts=ts2, base_dir=base_dir) + result = get_interactions(slug, types=["meeting"], base_dir=base_dir) + assert len(result) == 1 + assert result[0]["type"] == "meeting" + + def test_filters_by_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + old_ts = datetime.now() - timedelta(days=30) + recent_ts = datetime.now() - timedelta(days=1) + record_interaction(slug, "chat_sent", {"content_summary": "old"}, ts=old_ts, base_dir=base_dir) + record_interaction(slug, "chat_sent", {"content_summary": "new"}, ts=recent_ts, base_dir=base_dir) + result = get_interactions(slug, days=7, base_dir=base_dir) + assert len(result) == 1 + assert result[0]["data"]["content_summary"] == "new" + + def test_returns_empty_for_no_file(self, base_dir: Path, slug: str) -> None: + d = base_dir / slug + d.mkdir(parents=True) + (d / "interactions.jsonl").touch() + result = get_interactions(slug, base_dir=base_dir) + assert result == [] + + +class TestGetReplyTimes: + def test_returns_only_received_with_delay(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + ts1 = datetime(2026, 5, 10, 10, 0, 0) + ts2 = datetime(2026, 5, 10, 10, 5, 0) + ts3 = datetime(2026, 5, 10, 10, 10, 0) + record_interaction(slug, "chat_sent", {"content_summary": "hi"}, ts=ts1, base_dir=base_dir) + record_interaction(slug, "chat_received", {"content_summary": "hey", "reply_delay_min": 5}, ts=ts2, base_dir=base_dir) + record_interaction(slug, "chat_received", {"content_summary": "no delay"}, ts=ts3, base_dir=base_dir) + result = get_reply_times(slug, base_dir=base_dir) + assert len(result) == 1 + assert result[0]["data"]["reply_delay_min"] == 5 + + +class TestAnalyzeTimeline: + def _seed_interactions(self, base_dir: Path, slug: str) -> None: + now = datetime.now() + for day_offset in range(7): + day = now - timedelta(days=day_offset) + ts = day.replace(hour=20, minute=0, second=0, microsecond=0) + if day_offset in (3, 5): + continue + record_interaction(slug, "chat_sent", {"content_summary": f"msg d-{day_offset}"}, ts=ts, base_dir=base_dir) + recv_ts = day.replace(hour=20, minute=10, second=0, microsecond=0) + record_interaction( + slug, "chat_received", + {"content_summary": f"reply d-{day_offset}", "reply_delay_min": 10}, + ts=recv_ts, base_dir=base_dir, + ) + + def test_counts_total_interactions(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_interactions(base_dir, slug) + result = analyze_timeline(slug, days=7, base_dir=base_dir) + assert result["total"] == 10 + + def test_consecutive_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_interactions(base_dir, slug) + result = analyze_timeline(slug, days=7, base_dir=base_dir) + assert result["current_streak"] >= 2 + + def test_max_consecutive(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_interactions(base_dir, slug) + result = analyze_timeline(slug, days=7, base_dir=base_dir) + assert result["max_streak"] >= 3 + + def test_initiator_ratio(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_interactions(base_dir, slug) + result = analyze_timeline(slug, days=7, base_dir=base_dir) + assert result["user_ratio"] == 50.0 + + def test_active_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_interactions(base_dir, slug) + result = analyze_timeline(slug, days=7, base_dir=base_dir) + assert result["active_days"] == 5 + assert result["total_days"] == 7 + + +class TestAnalyzeReplyTimes: + def _seed_replies(self, base_dir: Path, slug: str) -> None: + delays = [2, 3, 5, 8, 12, 15, 25, 45, 90, 180, 300, 600, 1200] + now = datetime.now() + for i, delay in enumerate(delays): + ts = now - timedelta(days=i) + record_interaction( + slug, "chat_received", + {"content_summary": f"reply {i}", "reply_delay_min": delay}, + ts=ts, base_dir=base_dir, + ) + + def test_average_and_median(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_replies(base_dir, slug) + result = analyze_reply_times(slug, days=30, base_dir=base_dir) + assert result["average_min"] > 0 + assert result["median_min"] > 0 + + def test_distribution_buckets(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_replies(base_dir, slug) + result = analyze_reply_times(slug, days=30, base_dir=base_dir) + dist = result["distribution"] + assert dist["lte_5min"] > 0 + assert dist["gt_4h"] > 0 + total_pct = sum(dist.values()) + assert abs(total_pct - 100.0) < 1.0 + + def test_empty_data(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + result = analyze_reply_times(slug, days=30, base_dir=base_dir) + assert result["average_min"] is None + assert result["median_min"] is None + + +class TestAnalyzeGoldenHours: + def _seed_hourly(self, base_dir: Path, slug: str) -> None: + base = datetime.now() - timedelta(days=2) + for _ in range(8): + ts = base.replace(hour=21, minute=30, second=0, microsecond=0) + record_interaction( + slug, "chat_received", + {"content_summary": "night reply", "reply_delay_min": 5}, + ts=ts, base_dir=base_dir, + ) + for _ in range(3): + ts = base.replace(hour=12, minute=0, second=0, microsecond=0) + record_interaction( + slug, "chat_received", + {"content_summary": "lunch reply", "reply_delay_min": 10}, + ts=ts, base_dir=base_dir, + ) + ts = base.replace(hour=8, minute=0, second=0, microsecond=0) + record_interaction( + slug, "chat_received", + {"content_summary": "morning reply", "reply_delay_min": 15}, + ts=ts, base_dir=base_dir, + ) + + def test_peak_hour(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_hourly(base_dir, slug) + result = analyze_golden_hours(slug, days=30, base_dir=base_dir) + assert result["peak_hour"] == 21 + + def test_top_windows(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_hourly(base_dir, slug) + result = analyze_golden_hours(slug, days=30, base_dir=base_dir) + assert len(result["top_windows"]) >= 1 + assert result["top_windows"][0]["hour"] == 21 + + def test_empty_data(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + result = analyze_golden_hours(slug, days=30, base_dir=base_dir) + assert result["peak_hour"] is None + assert result["top_windows"] == [] + + +class TestAnalyzeMilestones: + def _seed_profile_and_events(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + (crush_dir / "profile.md").write_text( + "---\nnickname: 小雨\nslug: testcrush\ncreated_at: \"2026-04-10\"\n---\n\n## 性格画像\n\ntest\n", + encoding="utf-8", + ) + events_path = crush_dir / "events.jsonl" + events = [ + {"ts": "2026-04-10T14:00:00", "v": 1, "type": "profile_created", "slug": slug, "data": {"nickname": "小雨"}}, + {"ts": "2026-04-18T10:00:00", "v": 1, "type": "stage_changed", "slug": slug, "data": {"from": "破冰期", "to": "升温期", "trigger": "test"}}, + {"ts": "2026-05-02T10:00:00", "v": 1, "type": "stage_changed", "slug": slug, "data": {"from": "升温期", "to": "暧昧期", "trigger": "test"}}, + ] + with events_path.open("w", encoding="utf-8") as f: + for e in events: + f.write(json.dumps(e, ensure_ascii=False) + "\n") + + def test_stage_durations(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_profile_and_events(crush_dir, base_dir, slug) + from tools.time_tracker import analyze_milestones + result = analyze_milestones(slug, base_dir=base_dir) + stages = result["stages"] + assert len(stages) >= 2 + assert stages[0]["name"] == "破冰期" + assert stages[0]["days"] == 8 + assert stages[1]["name"] == "升温期" + assert stages[1]["days"] == 14 + + def test_baseline_comparison(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_profile_and_events(crush_dir, base_dir, slug) + from tools.time_tracker import analyze_milestones + result = analyze_milestones(slug, base_dir=base_dir) + for stage in result["stages"]: + assert "baseline_lo" in stage + assert "baseline_hi" in stage + assert "status" in stage + + def test_total_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + self._seed_profile_and_events(crush_dir, base_dir, slug) + from tools.time_tracker import analyze_milestones + result = analyze_milestones(slug, base_dir=base_dir) + assert result["total_days"] >= 22 + + +class TestIntegration: + def test_full_workflow(self, crush_dir: Path, base_dir: Path, slug: str) -> None: + now = datetime.now() + for day in range(7): + ts_sent = now - timedelta(days=day, hours=3, minutes=0) + ts_sent = ts_sent.replace(second=0, microsecond=0) + record_interaction(slug, "chat_sent", {"content_summary": f"msg d-{day}"}, ts=ts_sent, base_dir=base_dir) + ts_recv = now - timedelta(days=day, hours=2, minutes=55 - (5 + day)) + ts_recv = ts_recv.replace(second=0, microsecond=0) + record_interaction( + slug, "chat_received", + {"content_summary": f"reply d-{day}", "reply_delay_min": 5 + day}, + ts=ts_recv, base_dir=base_dir, + ) + + meeting_day = now - timedelta(days=4) + ts_meeting = meeting_day.replace(hour=19, minute=0, second=0, microsecond=0) + record_interaction( + slug, "meeting", + {"duration_min": 120, "activity": "coffee"}, + ts=ts_meeting, base_dir=base_dir, + ) + + tl = analyze_timeline(slug, days=30, base_dir=base_dir) + assert tl["total"] == 15 + + rt = analyze_reply_times(slug, days=30, base_dir=base_dir) + assert rt["average_min"] is not None + + gh = analyze_golden_hours(slug, days=30, base_dir=base_dir) + assert gh["peak_hour"] is not None + + meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) + assert meta["interaction_count"] == 15 + + +class TestTruncatedLineRecovery: + """P2 regression (C4): a crash-truncated last line must not swallow the next record.""" + + def test_record_after_truncated_line_keeps_new_record( + self, crush_dir: Path, base_dir: Path, slug: str + ) -> None: + path = crush_dir / "interactions.jsonl" + path.write_text('{"ts": "2026-01-01T00:00:00", "type": "chat_se', encoding="utf-8") + record_interaction( + slug, "chat_sent", {"content_summary": "新消息"}, + ts=datetime(2026, 5, 20, 10, 0, 0), base_dir=base_dir, + ) + records = get_interactions(slug, base_dir=base_dir) + assert any(r["data"].get("content_summary") == "新消息" for r in records) diff --git a/tools/chat_parser.py b/tools/chat_parser.py new file mode 100644 index 0000000..988b028 --- /dev/null +++ b/tools/chat_parser.py @@ -0,0 +1,997 @@ +#!/usr/bin/env python3 +""" +simp-skill · Chat Parser +解析微信/QQ聊天记录,提取信号分析报告 + +支持格式: +- 微信导出 TXT(WeChatMsg/留痕等工具) +- 微信导出 HTML(WeChatMsg) +- 微信导出 CSV(PyWxDump) +- QQ 导出 TXT(QQ消息管理器) +- QQ 导出 MHT/MHTML(QQ消息管理器) +- 通用 JSON 格式 + +用法: + python3 chat_parser.py [--user ] [--output ] + +示例: + python3 chat_parser.py wechat_export.txt 小美 --output output/xiaomei_analysis.md + python3 chat_parser.py qq_log.txt 小美 --user 我 --output output/xiaomei_analysis.md +""" + +import re +import sys +import json +import html +import argparse +from datetime import datetime, timedelta +from pathlib import Path +from collections import Counter, defaultdict +from typing import Optional + + +# ───────────────────────────────────────────── +# 数据结构 +# ───────────────────────────────────────────── + +class Message: + """单条消息""" + def __init__(self, timestamp: datetime, sender: str, content: str, msg_type: str = "text"): + self.timestamp = timestamp + self.sender = sender + self.content = content + self.msg_type = msg_type # text / image / sticker / voice / system + + def __repr__(self): + return f"[{self.timestamp.strftime('%Y-%m-%d %H:%M')}] {self.sender}: {self.content[:30]}" + + +# ───────────────────────────────────────────── +# 格式探测与解析 +# ───────────────────────────────────────────── + +def detect_format(filepath: str) -> str: + """自动探测聊天记录格式""" + path = Path(filepath) + ext = path.suffix.lower() + + if ext == ".json": + return "json" + if ext in (".mht", ".mhtml"): + return "qq_mht" + if ext == ".csv": + return "wechat_csv" + if ext == ".html": + return "wechat_html" + + # TXT 格式需要读取内容判断 + try: + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + sample = f.read(4096) + except Exception: + return "unknown" + + # QQ TXT: "2024-01-01 12:00:00 用户名(12345678)" + if re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} .+\(\d+\)', sample): + return "qq_txt" + + # 微信 WeChatMsg TXT: "2024-01-01 12:00:00\n用户名\n消息内容" + if re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', sample): + return "wechat_txt" + + return "plaintext" + + +def parse_wechat_txt(filepath: str, target_name: str, user_name: str) -> list: + """解析微信导出 TXT(WeChatMsg格式)""" + messages = [] + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + + # 匹配时间戳行 + pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\n(.+?)\n(.*?)(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\n|\Z)' + matches = re.findall(pattern, content, re.DOTALL) + + for ts_str, sender, msg_content in matches: + try: + ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") + except ValueError: + continue + + sender = sender.strip() + msg_content = msg_content.strip() + + if not msg_content or msg_content in ("[图片]", "[语音]", "[视频]", "[文件]"): + msg_type = "image" if "[图片]" in msg_content else ( + "voice" if "[语音]" in msg_content else "media" + ) + if not msg_content: + continue + messages.append(Message(ts, sender, msg_content, msg_type)) + else: + messages.append(Message(ts, sender, msg_content)) + + return messages + + +def parse_qq_txt(filepath: str, target_name: str, user_name: str) -> list: + """解析QQ导出 TXT""" + messages = [] + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + + current_ts = None + current_sender = None + current_content = [] + + header_pattern = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?)(?:\(\d+\))?$') + + def flush(): + if current_ts and current_sender and current_content: + content = "\n".join(current_content).strip() + if content: + messages.append(Message(current_ts, current_sender, content)) + + for line in lines: + line = line.rstrip() + m = header_pattern.match(line) + if m: + flush() + try: + current_ts = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") + except ValueError: + current_ts = None + current_sender = m.group(2).strip() + current_content = [] + elif current_ts is not None: + current_content.append(line) + + flush() + return messages + + +def parse_qq_mht(filepath: str, target_name: str, user_name: str) -> list: + """解析QQ导出 MHT/MHTML""" + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + raw = f.read() + + # 去除HTML标签 + clean = re.sub(r'<[^>]+>', ' ', raw) + clean = html.unescape(clean) + clean = re.sub(r'\s+', ' ', clean) + + # 同QQ TXT 解析 + messages = [] + pattern = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?)(?:\(\d+\))? (.+?)(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\Z)') + for m in pattern.finditer(clean): + try: + ts = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") + sender = m.group(2).strip() + content = m.group(3).strip() + if content: + messages.append(Message(ts, sender, content)) + except ValueError: + continue + + return messages + + +def parse_wechat_html(filepath: str, target_name: str, user_name: str) -> list: + """解析微信导出 HTML(WeChatMsg)""" + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + raw = f.read() + + messages = [] + # 匹配消息块 + msg_pattern = re.compile( + r'
]*>.*?' + r']*>([^<]+).*?' + r']*>([^<]+).*?' + r'
]*>(.*?)
', + re.DOTALL + ) + for m in msg_pattern.finditer(raw): + ts_str = m.group(1).strip() + sender = html.unescape(m.group(2).strip()) + content = html.unescape(re.sub(r'<[^>]+>', '', m.group(3))).strip() + + try: + ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") + except ValueError: + try: + ts = datetime.strptime(ts_str, "%Y/%m/%d %H:%M:%S") + except ValueError: + continue + + if content: + messages.append(Message(ts, sender, content)) + + return messages + + +def parse_wechat_csv(filepath: str, target_name: str, user_name: str) -> list: + """解析 PyWxDump CSV 导出""" + import csv + messages = [] + with open(filepath, "r", encoding="utf-8-sig", errors="ignore") as f: + reader = csv.DictReader(f) + for row in reader: + ts_field = row.get("CreateTime") or row.get("timestamp") or row.get("time", "") + sender_field = row.get("NickName") or row.get("sender") or row.get("from", "") + content_field = row.get("StrContent") or row.get("content") or row.get("msg", "") + + try: + if ts_field.isdigit(): + ts = datetime.fromtimestamp(int(ts_field)) + else: + ts = datetime.strptime(ts_field[:19], "%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + continue + + sender = sender_field.strip() + content = content_field.strip() + if content: + messages.append(Message(ts, sender, content)) + + return messages + + +def parse_json(filepath: str, target_name: str, user_name: str) -> list: + """解析通用 JSON 格式""" + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + + messages = [] + items = data if isinstance(data, list) else data.get("messages", []) + + for item in items: + ts_raw = item.get("timestamp") or item.get("time") or item.get("createTime", "") + sender = item.get("sender") or item.get("from") or item.get("nickName", "") + content = item.get("content") or item.get("text") or item.get("msg", "") + + try: + if isinstance(ts_raw, (int, float)): + ts = datetime.fromtimestamp(ts_raw) + else: + ts = datetime.strptime(str(ts_raw)[:19], "%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + continue + + if content: + messages.append(Message(ts, str(sender).strip(), str(content).strip())) + + return messages + + +def parse_chat(filepath: str, target_name: str, user_name: str = "我") -> list: + """主解析入口:自动选择格式""" + fmt = detect_format(filepath) + parsers = { + "wechat_txt": parse_wechat_txt, + "wechat_html": parse_wechat_html, + "wechat_csv": parse_wechat_csv, + "qq_txt": parse_qq_txt, + "qq_mht": parse_qq_mht, + "json": parse_json, + "plaintext": parse_wechat_txt, # fallback + } + parser = parsers.get(fmt, parse_wechat_txt) + messages = parser(filepath, target_name, user_name) + + # 过滤:只保留目标和用户的消息 + relevant = [m for m in messages if target_name in m.sender or user_name in m.sender] + + # 按时间排序 + relevant.sort(key=lambda m: m.timestamp) + return relevant + + +# ───────────────────────────────────────────── +# 信号分析引擎 +# ───────────────────────────────────────────── + +class SignalAnalyzer: + """信号分析引擎:从聊天记录中提取追求策略相关信号""" + + def __init__(self, messages: list, target_name: str, user_name: str): + self.messages = messages + self.target = target_name + self.user = user_name + self.target_msgs = [m for m in messages if target_name in m.sender] + self.user_msgs = [m for m in messages if user_name in m.sender] + + # ── 基础统计 ────────────────────────────────── + + def message_counts(self) -> dict: + return { + "total": len(self.messages), + "from_target": len(self.target_msgs), + "from_user": len(self.user_msgs), + "target_ratio": round(len(self.target_msgs) / max(len(self.messages), 1) * 100, 1), + } + + def date_range(self) -> dict: + if not self.messages: + return {} + first = self.messages[0].timestamp + last = self.messages[-1].timestamp + days = (last - first).days + 1 + return { + "first_date": first.strftime("%Y-%m-%d"), + "last_date": last.strftime("%Y-%m-%d"), + "total_days": days, + "avg_msgs_per_day": round(len(self.messages) / max(days, 1), 1), + } + + # ── 主动性分析 ───────────────────────────────── + + def initiative_analysis(self) -> dict: + """分析谁更主动开启对话""" + sessions = self._split_sessions() + target_starts = 0 + user_starts = 0 + + for session in sessions: + if not session: + continue + first = session[0] + if self.target in first.sender: + target_starts += 1 + elif self.user in first.sender: + user_starts += 1 + + total = target_starts + user_starts or 1 + return { + "target_initiates": target_starts, + "user_initiates": user_starts, + "target_initiative_ratio": round(target_starts / total * 100, 1), + "user_initiative_ratio": round(user_starts / total * 100, 1), + "verdict": self._initiative_verdict(target_starts, user_starts), + } + + def _initiative_verdict(self, target: int, user: int) -> str: + if user == 0 and target == 0: + return "数据不足" + ratio = target / (target + user) + if ratio >= 0.6: + return "🟢 ta 经常主动找你(强绿灯)" + elif ratio >= 0.4: + return "🟡 双方主动程度差不多" + elif ratio >= 0.2: + return "🟡 你更主动,ta 偶尔主动" + else: + return "🔴 几乎都是你在主动,ta 很少主动" + + # ── 回复速度分析 ─────────────────────────────── + + def reply_speed_analysis(self) -> dict: + """分析回复速度和趋势""" + target_delays = [] + user_delays = [] + + for i in range(1, len(self.messages)): + prev = self.messages[i - 1] + curr = self.messages[i] + delay = (curr.timestamp - prev.timestamp).total_seconds() + + # 超过4小时视为新会话,不计算 + if delay > 14400: + continue + + if self.target in curr.sender and self.user in prev.sender: + target_delays.append(delay) + elif self.user in curr.sender and self.target in prev.sender: + user_delays.append(delay) + + def stats(delays): + if not delays: + return {"avg_seconds": None, "median_seconds": None, "fast_ratio": None} + avg = sum(delays) / len(delays) + sorted_d = sorted(delays) + median = sorted_d[len(sorted_d) // 2] + fast = sum(1 for d in delays if d < 300) / len(delays) # 5分钟内回复 + return { + "avg_seconds": round(avg), + "avg_display": _format_seconds(avg), + "median_display": _format_seconds(median), + "fast_ratio": round(fast * 100, 1), + } + + target_stats = stats(target_delays) + user_stats = stats(user_delays) + + # 速度趋势:比较前半段和后半段 + trend = "数据不足" + if len(target_delays) >= 10: + first_half = sum(target_delays[:len(target_delays)//2]) / (len(target_delays)//2) + second_half = sum(target_delays[len(target_delays)//2:]) / (len(target_delays) - len(target_delays)//2) + if second_half < first_half * 0.7: + trend = "🟢 ta 回复越来越快(温度在升)" + elif second_half > first_half * 1.5: + trend = "🔴 ta 回复越来越慢(需要注意)" + else: + trend = "🟡 回复速度变化不大" + + return { + "target_reply": target_stats, + "user_reply": user_stats, + "speed_comparison": self._speed_verdict(target_stats, user_stats), + "trend": trend, + } + + def _speed_verdict(self, target: dict, user: dict) -> str: + ta = target.get("avg_seconds") + me = user.get("avg_seconds") + if ta is None or me is None: + return "数据不足" + if ta < 120: + return "🟢 ta 回复你很快(秒回/分钟级)" + elif ta < 600: + return "🟢 ta 回复较及时(10分钟内)" + elif ta < me * 0.5: + return "🟡 ta 比你回复略慢,但尚可" + elif ta > me * 2: + return "🔴 ta 回复你明显比你回复ta慢" + else: + return "🟡 双方回复速度差不多" + + # ── 消息长度分析 ─────────────────────────────── + + def message_length_analysis(self) -> dict: + """分析消息长度(情感投入指标)""" + target_lens = [len(m.content) for m in self.target_msgs if m.msg_type == "text"] + user_lens = [len(m.content) for m in self.user_msgs if m.msg_type == "text"] + + def avg(lst): + return round(sum(lst) / len(lst), 1) if lst else 0 + + target_avg = avg(target_lens) + user_avg = avg(user_lens) + + verdict = "" + if target_avg > user_avg * 1.3: + verdict = "🟢 ta 发给你的消息比你的更长(投入度高)" + elif target_avg < user_avg * 0.5: + verdict = "🔴 ta 的消息明显比你短(可能不够投入)" + elif target_avg > 50: + verdict = "🟢 ta 愿意给你发长消息(有话说)" + else: + verdict = "🟡 双方消息长度差不多" + + return { + "target_avg_len": target_avg, + "user_avg_len": user_avg, + "target_long_msgs": sum(1 for l in target_lens if l > 100), + "verdict": verdict, + } + + # ── 深夜信号分析 ─────────────────────────────── + + def late_night_analysis(self) -> dict: + """深夜消息(22:00-02:00)是重要亲密度信号""" + late_night_range = set(range(22, 24)) | set(range(0, 3)) + + target_late = [m for m in self.target_msgs if m.timestamp.hour in late_night_range] + user_late = [m for m in self.user_msgs if m.timestamp.hour in late_night_range] + + target_initiates_late = 0 + for session in self._split_sessions(): + if not session: + continue + first = session[0] + if first.timestamp.hour in late_night_range and self.target in first.sender: + target_initiates_late += 1 + + verdict = "" + if target_initiates_late >= 5: + verdict = "🟢🟢 ta 多次在深夜主动找你(强亲密信号)" + elif target_initiates_late >= 2: + verdict = "🟢 ta 有过深夜主动联系你" + elif len(target_late) > 0: + verdict = "🟡 ta 有在深夜回复你,但不常主动" + else: + verdict = "⚪ 没有明显的深夜互动记录" + + return { + "target_late_msgs": len(target_late), + "target_initiates_late_night": target_initiates_late, + "verdict": verdict, + } + + # ── 话题分析 ─────────────────────────────────── + + def topic_analysis(self) -> dict: + """分析高频话题和ta主动延伸的话题""" + all_words = [] + for m in self.target_msgs: + # 简单分词:按标点和空格切分 + words = re.findall(r'[\u4e00-\u9fff]{2,6}', m.content) + all_words.extend(words) + + # 过滤停用词 + stopwords = { + '什么', '这个', '那个', '一个', '可以', '没有', '知道', '觉得', '感觉', + '就是', '但是', '因为', '所以', '如果', '现在', '时候', '已经', '还是', + '好像', '应该', '可能', '不是', '一样', '这样', '那样', '一下', + } + filtered = [w for w in all_words if w not in stopwords] + top_topics = Counter(filtered).most_common(15) + + # 话题延伸:ta在我发消息后是否追问 + follow_up_count = 0 + for i in range(1, len(self.messages)): + prev = self.messages[i - 1] + curr = self.messages[i] + delay = (curr.timestamp - prev.timestamp).total_seconds() + if (self.user in prev.sender and self.target in curr.sender + and delay < 3600 and '?' in curr.content or '?' in curr.content): + follow_up_count += 1 + + return { + "top_topics": top_topics, + "target_follow_up_questions": follow_up_count, + "follow_up_verdict": ( + "🟢 ta 经常追问你的话(在乎你说的)" if follow_up_count >= 10 + else "🟡 ta 有时会追问" if follow_up_count >= 3 + else "⚪ ta 很少追问" + ), + } + + # ── 语言特征提取 ─────────────────────────────── + + def language_features(self) -> dict: + """提取ta的语言习惯,用于画像构建""" + all_target_text = " ".join(m.content for m in self.target_msgs) + + # 语气词/口头禅检测 + particles = ['哈哈', '哈', '嗯', '啊', '呢', '吧', '哦', '噢', '嘿', '诶', + '好的', '好啊', '好哦', '嗯嗯', '哎', '哎呀', '唉', '哇', '哇哦', + '嗯哦', '好嘞', '行', '行吧', '确实', '对哦', '对的', '对对', '真的', + '真的吗', '没有', '有吗', '是吗', '是哦', '可以', '好可以', '6', '666'] + + particle_counts = {p: all_target_text.count(p) for p in particles if all_target_text.count(p) > 0} + top_particles = sorted(particle_counts.items(), key=lambda x: -x[1])[:8] + + # emoji统计 + emoji_pattern = re.compile( + "[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF" + "\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF" + "\U00002702-\U000027B0\U000024C2-\U0001F251]+", + flags=re.UNICODE + ) + all_emojis = emoji_pattern.findall(all_target_text) + emoji_freq = Counter(all_emojis).most_common(5) + + # 标点习惯 + has_ellipsis = all_target_text.count("...") + all_target_text.count("……") + has_exclaim = all_target_text.count("!") + all_target_text.count("!") + has_question = all_target_text.count("?") + all_target_text.count("?") + total_msgs = max(len(self.target_msgs), 1) + + # 消息风格 + short_msgs = sum(1 for m in self.target_msgs if len(m.content) < 20) + long_msgs = sum(1 for m in self.target_msgs if len(m.content) > 100) + style = ( + "短句连发型" if short_msgs > total_msgs * 0.7 + else "长篇输出型" if long_msgs > total_msgs * 0.2 + else "混合型" + ) + + return { + "top_particles": top_particles, + "top_emojis": emoji_freq, + "exclamation_per_msg": round(has_exclaim / total_msgs, 2), + "question_per_msg": round(has_question / total_msgs, 2), + "ellipsis_count": has_ellipsis, + "message_style": style, + } + + # ── 综合信号评分 ─────────────────────────────── + + def signal_score(self) -> dict: + """计算综合信号评分(满分25)""" + score = 0 + signals = [] + + counts = self.message_counts() + initiative = self.initiative_analysis() + speed = self.reply_speed_analysis() + length = self.message_length_analysis() + late_night = self.late_night_analysis() + topic = self.topic_analysis() + + # 主动性评分(0-6) + ratio = initiative["target_initiative_ratio"] + if ratio >= 50: + score += 6 + signals.append(f"🟢 ta主动开启 {ratio}% 的对话(强绿灯)") + elif ratio >= 35: + score += 3 + signals.append(f"🟡 ta主动开启 {ratio}% 的对话") + elif ratio >= 20: + score += 1 + else: + score -= 2 + signals.append("🔴 ta几乎不主动联系你") + + # 回复速度评分(0-5) + target_avg = speed["target_reply"].get("avg_seconds") + if target_avg is not None: + if target_avg < 120: + score += 5 + signals.append(f"🟢 ta平均 {_format_seconds(target_avg)} 回复你(很快)") + elif target_avg < 600: + score += 3 + signals.append(f"🟢 ta平均 {_format_seconds(target_avg)} 回复你") + elif target_avg > 3600: + score -= 1 + signals.append(f"🔴 ta平均 {_format_seconds(target_avg)} 才回复你(较慢)") + + # 回复速度趋势评分(0-3) + trend = speed.get("trend", "") + if "越来越快" in trend: + score += 3 + signals.append("🟢 ta最近回复你越来越快(温度在升)") + elif "越来越慢" in trend: + score -= 2 + signals.append("🔴 ta最近回复你越来越慢(需注意)") + + # 消息长度评分(0-3) + if "投入度高" in length["verdict"]: + score += 3 + signals.append("🟢 ta发给你的消息比你的长(更用心)") + elif "明显比你短" in length["verdict"]: + score -= 1 + + # 深夜信号评分(0-5) + late_initiates = late_night["target_initiates_late_night"] + if late_initiates >= 5: + score += 5 + signals.append(f"🟢🟢 ta {late_initiates} 次在深夜主动找你") + elif late_initiates >= 2: + score += 2 + signals.append(f"🟢 ta有过深夜主动联系你 ({late_initiates}次)") + elif late_night["target_late_msgs"] > 0: + score += 1 + + # 追问行为评分(0-3) + follow_up = topic["target_follow_up_questions"] + if follow_up >= 10: + score += 3 + signals.append(f"🟢 ta经常追问你 ({follow_up}次),说明ta在意你说的话") + elif follow_up >= 3: + score += 1 + + # 确定等级 + if score >= 18: + level = "🟢🟢🟢 强烈绿灯" + advice = "信号非常明显!是时候认真准备表白了。" + elif score >= 12: + level = "🟢🟡 中度绿灯" + advice = "有明显好感,继续深化情感连接,创造更多1v1机会。" + elif score >= 6: + level = "🟡 模糊信号" + advice = "信号不够明确,可以适当试探,不要急着表白。" + elif score >= 0: + level = "🟡🔴 弱信号" + advice = "目前还没明显兴趣迹象,先建立更稳固的关系基础。" + else: + level = "🔴 警示信号" + advice = "有一些不积极的信号,建议重新评估追求策略。" + + return { + "score": score, + "max_score": 25, + "level": level, + "key_signals": signals, + "advice": advice, + } + + # ── 辅助方法 ─────────────────────────────────── + + def _split_sessions(self, gap_minutes: int = 60) -> list: + """将消息按时间间隔分割成会话""" + if not self.messages: + return [] + sessions = [] + current = [self.messages[0]] + for m in self.messages[1:]: + gap = (m.timestamp - current[-1].timestamp).total_seconds() / 60 + if gap > gap_minutes: + sessions.append(current) + current = [m] + else: + current.append(m) + sessions.append(current) + return sessions + + +# ───────────────────────────────────────────── +# 报告生成 +# ───────────────────────────────────────────── + +def _format_seconds(seconds: float) -> str: + """将秒数格式化为可读字符串""" + if seconds < 60: + return f"{int(seconds)}秒" + elif seconds < 3600: + return f"{int(seconds/60)}分钟" + else: + return f"{seconds/3600:.1f}小时" + + +def generate_report(messages: list, target_name: str, user_name: str, output_path: Optional[str] = None) -> str: + """生成完整的信号分析报告""" + if not messages: + return "❌ 未找到有效消息,请检查文件格式和姓名设置。" + + analyzer = SignalAnalyzer(messages, target_name, user_name) + + counts = analyzer.message_counts() + date_range = analyzer.date_range() + initiative = analyzer.initiative_analysis() + speed = analyzer.reply_speed_analysis() + length = analyzer.message_length_analysis() + late_night = analyzer.late_night_analysis() + topic = analyzer.topic_analysis() + features = analyzer.language_features() + score = analyzer.signal_score() + + lines = [ + f"# 💝 聊天记录信号分析报告", + f"", + f"> 心上人:**{target_name}** | 分析时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}", + f"> 记录时间:{date_range.get('first_date', '?')} ~ {date_range.get('last_date', '?')}({date_range.get('total_days', '?')}天)", + f"", + f"---", + f"", + f"## 📊 综合信号评分", + f"", + f"**{score['score']} / {score['max_score']} 分** — {score['level']}", + f"", + f"**关键信号**:", + ] + + for sig in score["key_signals"]: + lines.append(f"- {sig}") + + lines += [ + f"", + f"> 💡 **建议**:{score['advice']}", + f"", + f"---", + f"", + f"## 📱 基础统计", + f"", + f"| 指标 | 数值 |", + f"|------|------|", + f"| 总消息数 | {counts['total']} 条 |", + f"| ta 发的消息 | {counts['from_target']} 条({counts['target_ratio']}%) |", + f"| 你发的消息 | {counts['from_user']} 条 |", + f"| 日均消息数 | {date_range.get('avg_msgs_per_day', '?')} 条 |", + f"", + f"---", + f"", + f"## 🎯 主动性分析", + f"", + f"- ta 主动开启对话:**{initiative['target_initiates']} 次**({initiative['target_initiative_ratio']}%)", + f"- 你主动开启对话:**{initiative['user_initiates']} 次**({initiative['user_initiative_ratio']}%)", + f"- 判断:{initiative['verdict']}", + f"", + f"---", + f"", + f"## ⚡ 回复速度", + f"", + f"- ta 平均回复速度:**{speed['target_reply'].get('avg_display', '数据不足')}**", + f"- 你的平均回复速度:**{speed['user_reply'].get('avg_display', '数据不足')}**", + f"- ta 5分钟内快速回复比例:{speed['target_reply'].get('fast_ratio', '?')}%", + f"- 速度对比:{speed['speed_comparison']}", + f"- 趋势:{speed['trend']}", + f"", + f"---", + f"", + f"## 📏 消息长度(情感投入度)", + f"", + f"- ta 平均消息长度:**{length['target_avg_len']} 字**", + f"- 你的平均消息长度:**{length['user_avg_len']} 字**", + f"- ta 发给你的长消息(>100字):{length['target_long_msgs']} 条", + f"- 判断:{length['verdict']}", + f"", + f"---", + f"", + f"## 🌙 深夜信号(22:00-02:00)", + f"", + f"- ta 在深夜发的消息:{late_night['target_late_msgs']} 条", + f"- ta 主动在深夜开启对话:{late_night['target_initiates_late_night']} 次", + f"- 判断:{late_night['verdict']}", + f"", + f"---", + f"", + f"## 💬 话题分析", + f"", + f"**ta 的高频词汇 Top 10**:", + ] + + for word, count in topic["top_topics"][:10]: + lines.append(f"- 「{word}」× {count}") + + lines += [ + f"", + f"- ta 追问你的次数:{topic['target_follow_up_questions']} 次", + f"- 判断:{topic['follow_up_verdict']}", + f"", + f"---", + f"", + f"## 🗣️ ta 的语言特征(用于定制情话)", + f"", + f"- 消息风格:**{features['message_style']}**", + f"- 每条消息平均感叹号:{features['exclamation_per_msg']} 个", + f"- 每条消息平均问号:{features['question_per_msg']} 个", + f"", + f"**常用语气词/口头禅**:", + ] + + for particle, count in features["top_particles"]: + lines.append(f"- 「{particle}」× {count}") + + if features["top_emojis"]: + lines.append(f"") + lines.append(f"**常用 Emoji**:") + for emoji, count in features["top_emojis"]: + lines.append(f"- {emoji} × {count}") + + lines += [ + f"", + f"---", + f"", + f"## 🎯 给你的追求建议", + f"", + f"基于以上分析,当前阶段的建议:", + f"", + f"1. **根据信号等级**:{score['advice']}", + f"", + f"2. **基于ta的语言习惯**,你的消息风格建议:", + f" - ta 是「{features['message_style']}」,所以你的消息也不要太长/太短,跟ta的节奏走", + f" - 适当用ta熟悉的语气词,会让ta觉得亲切", + f"", + f"3. **最优互动时间**:", + ] + + if late_night["target_initiates_late_night"] >= 2: + lines.append(f" - ta 有深夜主动联系你的习惯,这是最亲密的互动时段") + lines += [ + f" - 根据回复速度,ta 在快速回复时更活跃,选择那个时间段互动效果更好", + f"", + f"4. **下一步行动**:运行 `/simp analyze` 获取更详细的策略建议", + f"", + f"---", + f"", + f"*由 simp-skill · 追爱军师 生成*", + ] + + report = "\n".join(lines) + + if output_path: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(report) + print(f"✅ 报告已保存到 {output_path}") + + return report + + +# ───────────────────────────────────────────── +# 互动时间数据提取 +# ───────────────────────────────────────────── + +def extract_time_data( + messages: list, + target_name: str, + user_name: str, + slug: str, + base_dir: Path | None = None, +) -> int: + from tools.time_tracker import record_interaction, DEFAULT_BASE_DIR as TRACKER_BASE + + if base_dir is None: + base_dir = TRACKER_BASE + + written = 0 + for i, msg in enumerate(messages): + content_summary = msg.content[:50].replace("\n", " ") + sender = msg.sender + + if target_name in sender: + interaction_type = "chat_received" + data: dict = {"content_summary": content_summary} + elif user_name in sender: + interaction_type = "chat_sent" + data = {"content_summary": content_summary} + else: + continue + + if i > 0: + prev = messages[i - 1] + delay_min = (msg.timestamp - prev.timestamp).total_seconds() / 60 + if delay_min <= 240 and prev.sender != msg.sender: + if interaction_type == "chat_received": + data["reply_delay_min"] = round(delay_min) + + try: + record_interaction(slug, interaction_type, data, ts=msg.timestamp, base_dir=base_dir) + written += 1 + except (FileNotFoundError, ValueError): + continue + + return written + + +# ───────────────────────────────────────────── +# 主程序 +# ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="simp-skill · 聊天记录信号分析器", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python3 chat_parser.py wechat.txt 小美 + python3 chat_parser.py qq_log.txt 小美 --user 我的QQ昵称 + python3 chat_parser.py wechat.html 小美 --output crushes/xiaomei/memories/chats/analysis.md + """ + ) + parser.add_argument("input", help="聊天记录文件路径") + parser.add_argument("target", help="心上人的名字(需与聊天记录中的显示名一致)") + parser.add_argument("--user", default="我", help="你自己的名字(默认:我)") + parser.add_argument("--output", "-o", help="输出文件路径(默认:打印到控制台)") + parser.add_argument("--format", "-f", choices=["wechat_txt", "qq_txt", "qq_mht", "wechat_html", "wechat_csv", "json"], + help="强制指定格式(默认:自动检测)") + parser.add_argument("--track-time", action="store_true", + help="同时将互动时间数据写入 interactions.jsonl") + parser.add_argument("--slug", help="档案 slug(--track-time 时必需)") + + args = parser.parse_args() + + print(f"💝 simp-skill · 聊天记录分析器") + print(f"📂 读取文件:{args.input}") + print(f"🎯 心上人:{args.target}") + print(f"👤 你的名字:{args.user}") + print() + + try: + messages = parse_chat(args.input, args.target, args.user) + except FileNotFoundError: + print(f"❌ 文件不存在:{args.input}") + sys.exit(1) + except Exception as e: + print(f"❌ 解析失败:{e}") + sys.exit(1) + + if not messages: + print(f"⚠️ 未找到有效消息。请确认:") + print(f" 1. 文件格式是否正确") + print(f" 2. 名字「{args.target}」是否与聊天记录中一致(区分大小写)") + print(f" 3. 如果名字包含空格,请用引号括起来") + sys.exit(1) + + print(f"✅ 成功读取 {len(messages)} 条消息") + print(f"🔍 正在分析信号...") + print() + + report = generate_report(messages, args.target, args.user, args.output) + + if args.track_time: + if not args.slug: + print("--track-time 需要 --slug 参数指定档案名") + sys.exit(1) + from tools.chat_parser import extract_time_data + count = extract_time_data(messages, args.target, args.user, args.slug) + print(f"已写入 {count} 条互动时间记录") + + if not args.output: + print(report) + + +if __name__ == "__main__": + main() diff --git a/tools/memory.py b/tools/memory.py new file mode 100644 index 0000000..0f74b10 --- /dev/null +++ b/tools/memory.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +simp-skill · Memory System +心上人档案记忆系统 — 事件追加、状态读写、快照管理 + +用法: + python3 tools/memory.py append '' + python3 tools/memory.py events [--last 5] [--type signal_recorded] + python3 tools/memory.py context [--with-strategy] + python3 tools/memory.py snapshot + python3 tools/memory.py timeline + python3 tools/memory.py rebuild +""" + +import argparse +import json +import logging +from datetime import date, datetime +from pathlib import Path +from typing import Any + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + +DEFAULT_BASE_DIR = Path("crushes") + +VALID_EVENT_TYPES = frozenset({ + "profile_created", + "profile_updated", + "stage_changed", + "signal_recorded", + "analysis_done", + "progress_evaluated", + "strategy_updated", + "crisis_handled", + "quit_evaluated", + "confess_prepared", +}) + +_SECTION_ORDER = [ + "## 当前状态(一句话)", + "## 最近信号(最新3条)", + "## 当前策略方向", + "## 下一步建议", +] + +_TIMELINE_LABELS: dict[str, str] = { + "profile_created": "🌱 档案创建", + "profile_updated": "✏️ 档案更新", + "stage_changed": "📍 阶段变化", + "signal_recorded": "📡 信号记录", + "analysis_done": "🔍 完成分析", + "progress_evaluated": "📊 进度评估", + "strategy_updated": "🗺️ 策略更新", + "crisis_handled": "🆘 危机处理", + "quit_evaluated": "🍃 放弃判断", + "confess_prepared": "💌 表白准备", +} + + +# Frontmatter 中应解析为数字的键白名单。其余键(尤其作为档案主键的 slug)保持字符串, +# 避免 "007" 在 parse->render 往返中被静默转成 7 而损坏目录查找。 +_NUMERIC_FRONTMATTER_KEYS = frozenset({ + "age", "score", "signal_score", "last_signal_score", + "milestones_done", "consecutive_days", +}) + + +def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: + """解析 Markdown YAML frontmatter,返回 (字段dict, 正文)""" + if not content.startswith("---"): + return {}, content + + parts = content.split("---", 2) + if len(parts) < 3: + return {}, content + + fields: dict[str, Any] = {} + for line in parts[1].strip().splitlines(): + if ": " not in line: + continue + key, _, raw = line.partition(": ") + k = key.strip() + value: Any = raw.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + elif value == "null": + value = None + elif k in _NUMERIC_FRONTMATTER_KEYS: + if value.lstrip("-").isdigit(): + value = int(value) + else: + try: + value = float(value) + except ValueError: + pass + fields[k] = value + + return fields, parts[2] + + +def _render_frontmatter(fields: dict[str, Any], body: str) -> str: + """将字段dict和正文渲染回 frontmatter 格式""" + _NEEDS_QUOTE = set(':#{[}],&*?|<>=!%@`') + lines = ["---"] + for key, value in fields.items(): + if value is None: + lines.append(f"{key}: null") + elif isinstance(value, bool): + lines.append(f"{key}: {str(value).lower()}") + elif isinstance(value, (int, float)): + lines.append(f"{key}: {value}") + else: + s = str(value) + if any(c in s for c in _NEEDS_QUOTE) or not s: + lines.append(f'{key}: "{s}"') + else: + lines.append(f"{key}: {s}") + lines.append("---") + return "\n".join(lines) + body + + +def _parse_body_sections(body: str) -> dict[str, str]: + """将 Markdown 正文按 ## 标题拆分为 {标题: 内容} 字典""" + sections: dict[str, str] = {} + current_header: str | None = None + current_lines: list[str] = [] + + for line in body.splitlines(): + if line.startswith("## "): + if current_header is not None: + sections[current_header] = "\n".join(current_lines).strip() + current_header = line + current_lines = [] + elif current_header is not None: + current_lines.append(line) + + if current_header is not None: + sections[current_header] = "\n".join(current_lines).strip() + + return sections + + +def _needs_leading_newline(path: Path) -> bool: + """文件已存在、非空且不以换行结尾(说明上次写入被截断)时返回 True。 + 据此在追加前补一个前导换行,避免把新记录拼接到损坏的半行末尾后一起丢失。""" + if not path.exists() or path.stat().st_size == 0: + return False + with path.open("rb") as f: + f.seek(-1, 2) + return f.read(1) != b"\n" + + +def append_event( + slug: str, + event_type: str, + data: dict[str, Any], + base_dir: Path = DEFAULT_BASE_DIR, +) -> None: + """追加一条事件到 events.jsonl,同时更新 meta.json 的 event_count""" + if event_type not in VALID_EVENT_TYPES: + raise ValueError(f"未知事件类型: {event_type}") + + crush_dir = base_dir / slug + if not crush_dir.exists(): + raise FileNotFoundError(f"档案不存在: {slug}") + + event = { + "ts": datetime.now().isoformat(), + "v": 1, + "type": event_type, + "slug": slug, + "data": data, + } + + events_path = crush_dir / "events.jsonl" + prefix = "\n" if _needs_leading_newline(events_path) else "" + with events_path.open("a", encoding="utf-8") as f: + f.write(prefix + json.dumps(event, ensure_ascii=False) + "\n") + + meta_path = crush_dir / "meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + updated_meta = { + **meta, + "event_count": len(get_recent_events(slug, n=10**9, base_dir=base_dir)), + "updated_at": datetime.now().isoformat(), + } + meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") + + +def get_recent_events( + slug: str, + n: int = 5, + event_types: list[str] | None = None, + base_dir: Path = DEFAULT_BASE_DIR, +) -> list[dict[str, Any]]: + """返回最近 N 条事件,可按 type 列表过滤""" + events_path = base_dir / slug / "events.jsonl" + if not events_path.exists(): + return [] + + events: list[dict[str, Any]] = [] + for idx, line in enumerate(events_path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.strip() + if not stripped: + continue + try: + event = json.loads(stripped) + except json.JSONDecodeError: + logger.warning("⚠️ events.jsonl 第 %d 行损坏,已跳过:%.80s", idx, stripped) + continue + if event_types is None or event.get("type") in event_types: + events.append(event) + + return events[-n:] + + +def load_context( + slug: str, + include_strategy: bool = False, + base_dir: Path = DEFAULT_BASE_DIR, +) -> str: + """拼装 profile.md + state.md,供 Claude 注入 context""" + crush_dir = base_dir / slug + parts: list[str] = [] + + for filename, label in [("profile.md", "[档案]"), ("state.md", "[状态]")]: + path = crush_dir / filename + if path.exists(): + parts.append(f"# {label} {filename}\n\n") + parts.append(path.read_text(encoding="utf-8")) + parts.append("\n\n") + + if include_strategy: + strategy_path = crush_dir / "strategy.md" + if strategy_path.exists(): + parts.append("# [策略] strategy.md\n\n") + parts.append(strategy_path.read_text(encoding="utf-8")) + + return "".join(parts) + + +def update_state( + slug: str, + frontmatter_updates: dict[str, Any], + sections: dict[str, str], + base_dir: Path = DEFAULT_BASE_DIR, +) -> None: + """全量覆盖 state.md;frontmatter_updates 合并 YAML 字段,sections 合并正文章节""" + crush_dir = base_dir / slug + if not crush_dir.exists(): + raise FileNotFoundError(f"档案不存在: {slug}") + + state_path = crush_dir / "state.md" + existing_fm: dict[str, Any] = {} + existing_sections: dict[str, str] = {} + + if state_path.exists(): + existing_fm, body = _parse_frontmatter(state_path.read_text(encoding="utf-8")) + existing_sections = _parse_body_sections(body) + + new_fm = {**existing_fm, **frontmatter_updates, "last_updated": datetime.now().isoformat()} + merged_sections = {**existing_sections, **sections} + + body_parts: list[str] = ["\n"] + for header in _SECTION_ORDER: + content = merged_sections.get(header, "[待生成]") + body_parts.append(f"\n{header}\n\n{content}\n") + + state_path.write_text(_render_frontmatter(new_fm, "".join(body_parts)), encoding="utf-8") + + meta_path = crush_dir / "meta.json" + if meta_path.exists(): + sync_keys = {"current_stage", "signal_score"} + sync_updates = {k: v for k, v in frontmatter_updates.items() if k in sync_keys} + if sync_updates: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + updated_meta = {**meta, **sync_updates, "updated_at": datetime.now().isoformat()} + meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") + + +def take_snapshot( + slug: str, + base_dir: Path = DEFAULT_BASE_DIR, +) -> Path: + """将 meta.json + state.md frontmatter 合并写入 snapshots/YYYY-MM-DD.json""" + crush_dir = base_dir / slug + snapshots_dir = crush_dir / "snapshots" + snapshots_dir.mkdir(exist_ok=True) + + snapshot: dict[str, Any] = { + "slug": slug, + "snapshot_date": date.today().isoformat(), + } + + meta_path = crush_dir / "meta.json" + if meta_path.exists(): + snapshot["meta"] = json.loads(meta_path.read_text(encoding="utf-8")) + + state_path = crush_dir / "state.md" + if state_path.exists(): + state_fm, _ = _parse_frontmatter(state_path.read_text(encoding="utf-8")) + snapshot["state"] = state_fm + + snapshot_path = snapshots_dir / f"{date.today().isoformat()}.json" + snapshot_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8") + + if meta_path.exists(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + updated_meta = {**meta, "last_snapshot": date.today().isoformat()} + meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") + + logger.info("✅ 快照已保存:%s", snapshot_path) + return snapshot_path + + +def rebuild_state_from_events( + slug: str, + base_dir: Path = DEFAULT_BASE_DIR, +) -> dict[str, Any]: + """从 events.jsonl 重放所有事件,返回推断的当前状态""" + events = get_recent_events(slug, n=100_000, base_dir=base_dir) + + state: dict[str, Any] = { + "current_stage": "未知", + "signal_score": None, + "last_signal_score": None, + "score_trend": "stable", + "recommended_mode": "hybrid", + "milestones_done": 0, + } + + for event in events: + etype = event.get("type") + data = event.get("data", {}) + + if etype == "stage_changed": + state = {**state, "current_stage": data.get("to", state["current_stage"])} + elif etype in ("analysis_done", "progress_evaluated"): + new_score = data.get("score") + prev_score = state.get("signal_score") + trend = "stable" + if new_score is not None and prev_score is not None: + diff = new_score - prev_score + trend = "up" if diff >= 2 else ("down" if diff <= -2 else "stable") + state = { + **state, + "last_signal_score": prev_score, + "signal_score": new_score if new_score is not None else state["signal_score"], + "score_trend": trend, + "current_stage": data.get("stage", state["current_stage"]), + } + if etype == "progress_evaluated" and "milestones_done" in data: + state = {**state, "milestones_done": data["milestones_done"]} + + return state + + +def _format_timeline(events: list[dict[str, Any]]) -> str: + """格式化事件时间线为可读字符串""" + if not events: + return "暂无事件记录" + + _DIRECTION_ICON = {"green": "🟢", "red": "🔴", "neutral": "🟡"} + + lines = [f"📅 事件时间线(共 {len(events)} 条)", ""] + for event in events: + ts = event.get("ts", "")[:16].replace("T", " ") + label = _TIMELINE_LABELS.get(event.get("type", ""), event.get("type", "")) + data = event.get("data", {}) + etype = event.get("type") + + if etype == "stage_changed": + desc = f"{data.get('from', '?')} → {data.get('to', '?')}" + elif etype in ("analysis_done", "progress_evaluated"): + desc = f"评分 {data.get('score', '?')}/25 阶段:{data.get('stage', '?')}" + elif etype == "signal_recorded": + icon = _DIRECTION_ICON.get(data.get("direction", ""), "") + desc = f"{icon} {data.get('content', '')}" + elif etype == "crisis_handled": + desc = data.get("crisis_type", "") + else: + desc = "" + + lines.append(f" {ts} {label} {desc}".rstrip()) + + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description="simp-skill · 记忆系统") + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("append", help="追加事件") + p.add_argument("slug") + p.add_argument("event_type") + p.add_argument("data", help="JSON 字符串") + p.add_argument("--base-dir", default="crushes") + + p = sub.add_parser("events", help="查看最近事件") + p.add_argument("slug") + p.add_argument("--last", type=int, default=5) + p.add_argument("--type", dest="event_type", help="按事件类型过滤") + p.add_argument("--base-dir", default="crushes") + + p = sub.add_parser("context", help="读取档案上下文") + p.add_argument("slug") + p.add_argument("--with-strategy", action="store_true") + p.add_argument("--base-dir", default="crushes") + + p = sub.add_parser("snapshot", help="生成日快照") + p.add_argument("slug") + p.add_argument("--base-dir", default="crushes") + + p = sub.add_parser("timeline", help="查看事件时间线") + p.add_argument("slug") + p.add_argument("--base-dir", default="crushes") + + p = sub.add_parser("rebuild", help="从事件流重建状态") + p.add_argument("slug") + p.add_argument("--base-dir", default="crushes") + + args = parser.parse_args() + base_dir = Path(args.base_dir) + + if args.cmd == "append": + try: + data = json.loads(args.data) + except json.JSONDecodeError as e: + logger.error("❌ data 必须是合法 JSON:%s", e) + return + append_event(args.slug, args.event_type, data, base_dir) + logger.info("✅ 事件已记录:%s", args.event_type) + + elif args.cmd == "events": + types = [args.event_type] if args.event_type else None + events = get_recent_events(args.slug, args.last, types, base_dir) + for event in events: + print(json.dumps(event, ensure_ascii=False)) + if not events: + logger.info("暂无事件记录") + + elif args.cmd == "context": + print(load_context(args.slug, args.with_strategy, base_dir)) + + elif args.cmd == "snapshot": + take_snapshot(args.slug, base_dir) + + elif args.cmd == "timeline": + all_events = get_recent_events(args.slug, n=100_000, base_dir=base_dir) + print(_format_timeline(all_events)) + + elif args.cmd == "rebuild": + state = rebuild_state_from_events(args.slug, base_dir) + print(json.dumps(state, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/photo_analyzer.py b/tools/photo_analyzer.py new file mode 100644 index 0000000..fdb2278 --- /dev/null +++ b/tools/photo_analyzer.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +""" +simp-skill · Photo Analyzer +分析照片的 EXIF 元数据,提取拍摄时间线和地点信息, +并检测可能的约会/见面记录(同地点+同时段的照片聚类)。 + +依赖:pip install Pillow + +支持格式:jpg / jpeg / png / heic / heif + +用法: + python3 photo_analyzer.py --dir crushes/xiaomei/memories/photos + python3 photo_analyzer.py --dir ./photos --target 小美 --output report.md +""" + +import os +import argparse +from pathlib import Path +from datetime import datetime, timedelta +from collections import defaultdict +from typing import Optional + +try: + from PIL import Image + from PIL.ExifTags import TAGS, GPSTAGS + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + + +PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".heic", ".heif"} + + +# ───────────────────────────────────────────── +# EXIF 提取 +# ───────────────────────────────────────────── + +def get_exif_data(filepath: str) -> dict: + """提取照片的 EXIF 元数据""" + if not PIL_AVAILABLE: + return {} + try: + img = Image.open(filepath) + raw_exif = img._getexif() + if not raw_exif: + return {} + + exif = {} + for tag_id, value in raw_exif.items(): + tag = TAGS.get(tag_id, tag_id) + exif[tag] = value + return exif + except Exception: + return {} + + +def get_datetime(exif: dict) -> Optional[datetime]: + """从 EXIF 提取拍摄时间""" + for field in ("DateTimeOriginal", "DateTime", "DateTimeDigitized"): + raw = exif.get(field) + if raw: + try: + return datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S") + except ValueError: + continue + return None + + +def _dms_to_decimal(dms, ref: str) -> float: + """将度分秒坐标转为十进制""" + try: + d = float(dms[0]) + m = float(dms[1]) + s = float(dms[2]) + decimal = d + m / 60 + s / 3600 + if ref in ("S", "W"): + decimal = -decimal + return round(decimal, 6) + except Exception: + return 0.0 + + +def get_gps(exif: dict) -> Optional[dict]: + """从 EXIF 提取 GPS 坐标""" + gps_raw = exif.get("GPSInfo") + if not gps_raw: + return None + + gps = {} + for key, val in gps_raw.items(): + tag = GPSTAGS.get(key, key) + gps[tag] = val + + lat_dms = gps.get("GPSLatitude") + lat_ref = gps.get("GPSLatitudeRef", "N") + lon_dms = gps.get("GPSLongitude") + lon_ref = gps.get("GPSLongitudeRef", "E") + + if lat_dms and lon_dms: + return { + "lat": _dms_to_decimal(lat_dms, lat_ref), + "lon": _dms_to_decimal(lon_dms, lon_ref), + } + return None + + +def get_make_model(exif: dict) -> str: + """提取相机型号(可判断是谁拍的)""" + make = str(exif.get("Make", "")).strip() + model = str(exif.get("Model", "")).strip() + if make and model: + return f"{make} {model}" + return model or make or "" + + +# ───────────────────────────────────────────── +# 扫描与分析 +# ───────────────────────────────────────────── + +def scan_photos(directory: str) -> list: + """扫描目录下所有照片并提取元数据""" + base = Path(directory) + if not base.exists(): + print(f"⚠️ 目录不存在:{directory}") + return [] + + photos = [] + for path in sorted(base.rglob("*")): + if not path.is_file(): + continue + if path.suffix.lower() not in PHOTO_EXTS: + continue + + exif = get_exif_data(str(path)) + dt = get_datetime(exif) + gps = get_gps(exif) + cam = get_make_model(exif) + + photos.append({ + "path": str(path), + "name": path.name, + "rel": str(path.relative_to(base)), + "datetime": dt, + "gps": gps, + "camera": cam, + "size_kb": round(path.stat().st_size / 1024, 1), + }) + + # 按时间排序(无时间的排后面) + photos.sort(key=lambda p: (p["datetime"] is None, p["datetime"] or datetime.min)) + return photos + + +# ───────────────────────────────────────────── +# 约会检测(核心创新功能) +# ───────────────────────────────────────────── + +def _gps_distance_km(a: dict, b: dict) -> float: + """粗略计算两个 GPS 坐标之间的距离(km),使用等经纬度近似""" + import math + lat1, lon1 = math.radians(a["lat"]), math.radians(a["lon"]) + lat2, lon2 = math.radians(b["lat"]), math.radians(b["lon"]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a_ = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 + return 6371 * 2 * math.asin(math.sqrt(a_)) + + +def detect_meetups(photos: list, time_gap_hours: float = 4.0, location_radius_km: float = 2.0) -> list: + """ + 检测可能的约会/见面记录: + - 在同一时间段(time_gap_hours 以内) + - 在同一地点附近(location_radius_km 以内) + 的照片聚类 = 可能的一次见面 + + 返回聚类列表,每个聚类代表一次可能的见面。 + """ + timed = [p for p in photos if p["datetime"] is not None] + if len(timed) < 2: + return [] + + visited = set() + meetups = [] + + for i, photo in enumerate(timed): + if i in visited: + continue + + cluster = [photo] + visited.add(i) + + for j, other in enumerate(timed): + if j in visited or j == i: + continue + + # 时间差检测 + delta = abs((other["datetime"] - photo["datetime"]).total_seconds()) / 3600 + if delta > time_gap_hours: + continue + + # GPS 检测(如果双方都有 GPS) + if photo["gps"] and other["gps"]: + dist = _gps_distance_km(photo["gps"], other["gps"]) + if dist > location_radius_km: + continue + + cluster.append(other) + visited.add(j) + + if len(cluster) >= 2: + cluster.sort(key=lambda p: p["datetime"]) + start = cluster[0]["datetime"] + end = cluster[-1]["datetime"] + duration = (end - start).seconds // 60 + + # 有 GPS 的取第一张的坐标 + gps_ref = next((p["gps"] for p in cluster if p["gps"]), None) + + meetups.append({ + "date": start.strftime("%Y-%m-%d"), + "start": start.strftime("%H:%M"), + "end": end.strftime("%H:%M"), + "duration_min": duration, + "photo_count": len(cluster), + "gps": gps_ref, + "photos": cluster, + }) + + meetups.sort(key=lambda m: m["date"]) + return meetups + + +# ───────────────────────────────────────────── +# 报告生成 +# ───────────────────────────────────────────── + +def generate_report(directory: str, target_name: str, output_path: str = None) -> str: + """生成完整的照片分析报告""" + + if not PIL_AVAILABLE: + warn = ( + "⚠️ 未安装 Pillow,无法读取 EXIF 元数据。\n" + "请运行:pip install Pillow\n\n" + "安装后重新运行本工具以获取完整分析。" + ) + if output_path: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + Path(output_path).write_text(warn, encoding="utf-8") + return warn + + photos = scan_photos(directory) + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + + lines = [ + f"# 📷 照片元数据分析报告", + f"", + f"> 心上人:**{target_name}** | 分析时间:{now_str}", + f"> 来源目录:`{directory}`", + f"", + f"---", + f"", + ] + + if not photos: + lines += [ + f"⚠️ 未找到任何照片文件。", + f"", + f"请将照片(.jpg / .jpeg / .png / .heic)放入 `{directory}/` 后重新运行。", + ] + report = "\n".join(lines) + if output_path: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + Path(output_path).write_text(report, encoding="utf-8") + print(f"✅ 报告已保存到 {output_path}") + return report + + # 统计 + with_time = [p for p in photos if p["datetime"]] + with_gps = [p for p in photos if p["gps"]] + + lines += [ + f"## 📊 概览", + f"", + f"| 指标 | 数值 |", + f"|------|------|", + f"| 照片总数 | {len(photos)} 张 |", + f"| 包含拍摄时间 | {len(with_time)} 张 |", + f"| 包含 GPS 位置 | {len(with_gps)} 张 |", + ] + + if with_time: + first = with_time[0]["datetime"] + last = with_time[-1]["datetime"] + lines += [ + f"| 最早照片 | {first.strftime('%Y-%m-%d')} |", + f"| 最新照片 | {last.strftime('%Y-%m-%d')} |", + f"| 时间跨度 | {(last - first).days} 天 |", + ] + + lines.append(f"") + + # ── 约会检测 ────────────────────────────── + meetups = detect_meetups(photos) + + if meetups: + lines += [ + f"---", + f"", + f"## 🗓️ 可能的见面记录({len(meetups)} 次)", + f"", + f"> 以下是照片聚类检测到的时间+地点相近的拍摄记录,", + f"> 可能代表你们曾经在一起的时刻。", + f"", + ] + + for i, meetup in enumerate(meetups, 1): + dur_str = f"{meetup['duration_min']}分钟内" if meetup["duration_min"] > 0 else "同一时刻" + gps_str = "" + if meetup["gps"]: + g = meetup["gps"] + gps_str = f" 📍 坐标:{g['lat']}, {g['lon']}" + + lines += [ + f"### 第 {i} 次 {meetup['date']}", + f"", + f"- 时间段:{meetup['start']} ~ {meetup['end']}({dur_str})", + f"- 照片数:{meetup['photo_count']} 张", + ] + if gps_str: + lines.append(gps_str) + + lines.append(f"- 照片列表:") + for p in meetup["photos"][:5]: + t = p["datetime"].strftime("%H:%M") if p["datetime"] else "?" + lines.append(f" - `{p['rel']}`({t})") + if len(meetup["photos"]) > 5: + lines.append(f" - ... 共 {len(meetup['photos'])} 张") + lines.append(f"") + + lines += [ + f"**💡 使用建议**:", + f"将这些照片路径告诉 Claude,让它描述照片内容,", + f"从中挖掘可以用于情话的细节(场景、表情、你们的互动)。", + f"", + ] + else: + if with_time: + lines += [ + f"---", + f"", + f"## 🗓️ 见面检测", + f"", + f"未检测到明确的时间/地点聚类,可能原因:", + f"- 照片缺少 EXIF 时间信息(截图、社交媒体下载的图通常无 EXIF)", + f"- 照片拍摄时间间隔超过 4 小时", + f"- 缺少 GPS 数据导致位置无法比对", + f"", + ] + + # ── 完整时间线 ──────────────────────────── + if with_time: + lines += [ + f"---", + f"", + f"## 📅 照片时间线", + f"", + ] + + # 按月分组 + monthly: dict = defaultdict(list) + for p in with_time: + key = p["datetime"].strftime("%Y年%m月") + monthly[key].append(p) + + for month, month_photos in sorted(monthly.items()): + lines.append(f"### {month}({len(month_photos)} 张)") + lines.append(f"") + for p in month_photos: + dt_str = p["datetime"].strftime("%m-%d %H:%M") + cam_str = f" 📱 {p['camera']}" if p["camera"] else "" + gps_str = f" 📍 {p['gps']['lat']:.4f}, {p['gps']['lon']:.4f}" if p["gps"] else "" + lines.append(f"- `{p['rel']}` 🕐 {dt_str}{cam_str}{gps_str}") + lines.append(f"") + + # ── 无时间信息的照片 ───────────────────── + no_time = [p for p in photos if not p["datetime"]] + if no_time: + lines += [ + f"---", + f"", + f"## ❓ 无时间信息的照片({len(no_time)} 张)", + f"", + f"> 这些照片缺少 EXIF 时间数据(常见于截图、从社交媒体保存的图片)。", + f"", + ] + for p in no_time: + lines.append(f"- `{p['rel']}`({p['size_kb']} KB)") + lines.append(f"") + + lines += [ + f"---", + f"", + f"## 📌 后续建议", + f"", + f"1. **有见面记录**:把照片路径告诉 Claude,让它描述内容,", + f" 提取可以用于情话的具体细节", + f"2. **无 EXIF 数据**:说明照片可能来自网络/截图,", + f" 这类照片更适合直接给 Claude 看内容", + f"3. **结合聊天记录**:对比见面日期和聊天记录,", + f" 看看见面当天和之后的消息有没有温度变化", + f"", + f"---", + f"", + f"*由 simp-skill · 追爱军师 生成*", + ] + + report = "\n".join(lines) + + if output_path: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(report) + print(f"✅ 报告已保存到 {output_path}") + + return report + + +# ───────────────────────────────────────────── +# 主程序 +# ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="simp-skill · 照片元数据分析器", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python3 photo_analyzer.py --dir crushes/xiaomei/memories/photos + python3 photo_analyzer.py --dir ./photos --target 小美 --output report.md + python3 photo_analyzer.py --dir ./photos --gap 6 --radius 5 + """, + ) + parser.add_argument("--dir", required=True, help="照片目录路径") + parser.add_argument("--target", default="心上人", help="心上人的名字") + parser.add_argument("--output", "-o", help="输出报告路径(默认:打印到控制台)") + parser.add_argument("--gap", type=float, default=4.0, + help="约会检测时间窗口(小时,默认:4)") + parser.add_argument("--radius", type=float, default=2.0, + help="约会检测地点半径(公里,默认:2)") + + args = parser.parse_args() + + print(f"💝 simp-skill · 照片分析器") + print(f"📂 扫描目录:{args.dir}") + print(f"🎯 心上人:{args.target}") + if not PIL_AVAILABLE: + print(f"⚠️ Pillow 未安装,请运行:pip install Pillow") + print() + print() + + report = generate_report(args.dir, args.target, args.output) + + if not args.output: + print(report) + + +if __name__ == "__main__": + main() diff --git a/tools/skill_writer.py b/tools/skill_writer.py new file mode 100644 index 0000000..82480c2 --- /dev/null +++ b/tools/skill_writer.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +""" +simp-skill · Skill Writer +管理心上人档案的创建、列表和版本控制 + +用法: + python3 skill_writer.py --action list + python3 skill_writer.py --action init --slug xiaomei + python3 skill_writer.py --action backup --slug xiaomei + python3 skill_writer.py --action rollback --slug xiaomei --version v1 +""" + +import json +import logging +import shutil +import argparse +from datetime import datetime +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + +DEFAULT_BASE_DIR = Path("crushes") +SIGNAL_SCORE_MIN = -15 +SIGNAL_SCORE_MAX = 25 + + +def init_crush(slug: str, base_dir: Path = DEFAULT_BASE_DIR) -> None: + """初始化心上人档案目录结构""" + crush_dir = base_dir / slug + + dirs = [ + crush_dir, + crush_dir / "memories" / "chats", + crush_dir / "memories" / "social", + crush_dir / "memories" / "photos", + crush_dir / "versions", + crush_dir / "snapshots", + ] + for d in dirs: + d.mkdir(parents=True, exist_ok=True) + + now = datetime.now() + + profile_path = crush_dir / "profile.md" + if not profile_path.exists(): + profile_path.write_text( + f"---\n" + f"nickname: \"[待填写]\"\n" + f"slug: {slug}\n" + f"gender: \"[待填写]\"\n" + f"age: \"[待填写]\"\n" + f"occupation: \"[待填写]\"\n" + f"city: \"[待填写]\"\n" + f"mbti: \"[待填写]\"\n" + f"zodiac: \"[待填写]\"\n" + f"personality_type: \"[感性型/理性型/傲娇型/温柔型]\"\n" + f"how_met: \"[待填写]\"\n" + f"created_at: \"{now.strftime('%Y-%m-%d')}\"\n" + f"---\n\n" + f"## 性格画像\n\n" + f"[在这里描述ta的性格特征]\n\n" + f"## 最打动ta的方式\n\n" + f"[基于性格分析,什么样的话/行为最有效]\n\n" + f"## 用户自身风格\n\n" + f"[用户的说话风格、消息习惯、偏好模式]\n\n" + f"## 注意事项\n\n" + f"[ta特别在意或反感的事]\n", + encoding="utf-8", + ) + + state_path = crush_dir / "state.md" + if not state_path.exists(): + state_path.write_text( + f"---\n" + f"current_stage: 未知\n" + f"signal_score: null\n" + f"last_signal_score: null\n" + f"score_trend: stable\n" + f"recommended_mode: hybrid\n" + f"last_updated: \"{now.strftime('%Y-%m-%dT%H:%M:%S')}\"\n" + f"milestones_done: 0\n" + f"---\n\n" + f"## 当前状态(一句话)\n\n" + f"[运行 /simp analyze 后自动生成]\n\n" + f"## 最近信号(最新3条)\n\n" + f"[暂无信号记录]\n\n" + f"## 当前策略方向\n\n" + f"[运行 /simp analyze 后生成]\n\n" + f"## 下一步建议\n\n" + f"[运行 /simp analyze 后生成]\n", + encoding="utf-8", + ) + + events_path = crush_dir / "events.jsonl" + if not events_path.exists(): + events_path.touch() + + interactions_path = crush_dir / "interactions.jsonl" + if not interactions_path.exists(): + interactions_path.touch() + + strategy_path = crush_dir / "strategy.md" + if not strategy_path.exists(): + strategy_path.write_text( + f"# 追求策略\n\n" + f"> 由 simp-skill 生成 | 最后更新:{now.strftime('%Y-%m-%d')}\n\n" + f"## 当前阶段\n\n" + f"[待评估]\n\n" + f"## 推荐模式\n\n" + f"[纯情模式/策略模式/混合模式]\n\n" + f"## 本阶段重点\n\n" + f"[待生成]\n\n" + f"## 近期行动计划\n\n" + f"[待生成]\n", + encoding="utf-8", + ) + + meta_path = crush_dir / "meta.json" + if not meta_path.exists(): + meta = { + "slug": slug, + "nickname": "[待填写]", + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + "version": "v1", + "current_stage": "未知", + "signal_score": None, + "mode": "hybrid", + "event_count": 0, + "last_snapshot": None, + "interaction_count": 0, + "last_interaction": None, + "consecutive_days": 0, + } + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + logger.info("✅ 档案目录创建成功:%s/", crush_dir) + logger.info(" ├── profile.md (心上人基本信息)") + logger.info(" ├── state.md (当前状态快照)") + logger.info(" ├── events.jsonl (事件日志)") + logger.info(" ├── strategy.md (追求策略)") + logger.info(" ├── meta.json (元数据)") + logger.info(" ├── snapshots/ (定期快照)") + logger.info(" └── memories/") + logger.info(" ├── chats/ (放聊天记录)") + logger.info(" ├── social/ (放社交媒体截图)") + logger.info(" └── photos/ (放照片)") + logger.info("") + logger.info("下一步:") + logger.info(" 1. 编辑 %s/profile.md 填写心上人信息", crush_dir) + logger.info(" 2. 运行 /simp analyze 开始分析信号") + logger.info(" 3. 把聊天记录放到 %s/memories/chats/ 并运行 chat_parser.py", crush_dir) + + +def list_crushes(base_dir: Path = DEFAULT_BASE_DIR) -> None: + """列出所有心上人档案""" + if not base_dir.exists(): + logger.info("还没有任何心上人档案。运行 /simp create <名字> 开始吧!") + return + + crushes = [d for d in base_dir.iterdir() if d.is_dir()] + if not crushes: + logger.info("还没有任何心上人档案。运行 /simp create <名字> 开始吧!") + return + + logger.info("💝 心上人档案列表(共 %d 个)", len(crushes)) + logger.info("") + for crush_dir in sorted(crushes): + meta_path = crush_dir / "meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + score = meta.get("signal_score") + score_str = f"{score}/25" if score is not None else "未评估" + stage = meta.get("current_stage", "未知") + updated = meta.get("updated_at", "")[:10] + logger.info(" 📁 %s", crush_dir.name) + logger.info(" 阶段:%s | 信号评分:%s | 最后更新:%s", stage, score_str, updated) + else: + logger.info(" 📁 %s", crush_dir.name) + logger.info("") + + +def backup_crush(slug: str, base_dir: Path = DEFAULT_BASE_DIR) -> str: + """备份当前版本,返回版本名称;档案不存在时返回空字符串""" + crush_dir = base_dir / slug + if not crush_dir.exists(): + logger.error("❌ 档案不存在:%s", slug) + return "" + + meta_path = crush_dir / "meta.json" + meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {} + current_version = meta.get("version", "v1") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + version_name = f"{current_version}_{timestamp}" + + version_dir = crush_dir / "versions" / version_name + version_dir.mkdir(parents=True, exist_ok=True) + + for filename in ["profile.md", "state.md", "strategy.md", "meta.json"]: + src = crush_dir / filename + if src.exists(): + shutil.copy2(src, version_dir / filename) + + v_num = int(current_version[1:]) + 1 + new_version = f"v{v_num}" + updated_meta = {**meta, "version": new_version, "updated_at": datetime.now().isoformat()} + meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") + + logger.info("✅ 已备份版本 %s → %s", current_version, version_name) + logger.info(" 当前版本升级为 %s", new_version) + return version_name + + +def rollback_crush(slug: str, version: str, base_dir: Path = DEFAULT_BASE_DIR) -> None: + """回滚到指定版本""" + crush_dir = base_dir / slug + versions_dir = crush_dir / "versions" + + if not versions_dir.exists() or not any(versions_dir.iterdir()): + logger.error("❌ 没有找到版本历史") + return + + matching = [d for d in versions_dir.iterdir() if d.name.startswith(version)] + if not matching: + available = [d.name for d in versions_dir.iterdir()] + logger.error("❌ 版本 %s 不存在", version) + logger.error(" 可用版本:%s", ", ".join(available)) + return + + backup_crush(slug, base_dir) + + target_dir = sorted(matching)[-1] + # events.jsonl 是不可变历史,不参与回滚 + for filename in ["profile.md", "state.md", "strategy.md", "meta.json"]: + src = target_dir / filename + if src.exists(): + shutil.copy2(src, crush_dir / filename) + + logger.info("✅ 已回滚到版本 %s", target_dir.name) + + +def list_versions(slug: str, base_dir: Path = DEFAULT_BASE_DIR) -> None: + """列出版本历史""" + crush_dir = base_dir / slug + versions_dir = crush_dir / "versions" + + if not versions_dir.exists() or not list(versions_dir.iterdir()): + logger.info("档案 %s 没有版本历史", slug) + return + + versions = sorted(versions_dir.iterdir()) + logger.info("📚 %s 的版本历史(共 %d 个版本)", slug, len(versions)) + for v in versions: + meta_path = v / "meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + stage = meta.get("current_stage", "") + score = meta.get("signal_score", "") + logger.info(" - %s 阶段:%s 评分:%s", v.name, stage, score) + else: + logger.info(" - %s", v.name) + + +def update_meta(slug: str, base_dir: Path = DEFAULT_BASE_DIR, **kwargs: object) -> None: + """更新档案元数据""" + crush_dir = base_dir / slug + meta_path = crush_dir / "meta.json" + if not meta_path.exists(): + logger.error("❌ 档案不存在:%s", slug) + return + + if "signal_score" in kwargs: + score = kwargs["signal_score"] + if score is not None and not (SIGNAL_SCORE_MIN <= int(score) <= SIGNAL_SCORE_MAX): + logger.error( + "❌ 信号评分必须在 %d-%d 之间,当前值:%s", + SIGNAL_SCORE_MIN, + SIGNAL_SCORE_MAX, + score, + ) + return + + meta = json.loads(meta_path.read_text(encoding="utf-8")) + updated_meta = {**meta, **kwargs, "updated_at": datetime.now().isoformat()} + meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info("✅ 档案元数据已更新") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="simp-skill · 档案管理器", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--action", + required=True, + choices=["list", "init", "backup", "rollback", "versions", "update-meta"], + help="操作类型", + ) + parser.add_argument("--slug", help="心上人档案名(拼音或英文)") + parser.add_argument("--version", help="版本号(rollback 时使用)") + parser.add_argument("--stage", help="更新当前阶段") + parser.add_argument("--score", type=int, help=f"更新信号评分({SIGNAL_SCORE_MIN}~{SIGNAL_SCORE_MAX})") + parser.add_argument("--mode", choices=["sweet", "strategic", "hybrid"], help="更新追求模式") + parser.add_argument("--base-dir", default="crushes", help="档案根目录(默认:crushes/)") + + args = parser.parse_args() + base_dir = Path(args.base_dir) + + if args.action == "list": + list_crushes(base_dir) + elif args.action == "init": + if not args.slug: + logger.error("❌ 请提供 --slug 参数") + return + init_crush(args.slug, base_dir) + elif args.action == "backup": + if not args.slug: + logger.error("❌ 请提供 --slug 参数") + return + backup_crush(args.slug, base_dir) + elif args.action == "rollback": + if not args.slug or not args.version: + logger.error("❌ 请提供 --slug 和 --version 参数") + return + rollback_crush(args.slug, args.version, base_dir) + elif args.action == "versions": + if not args.slug: + logger.error("❌ 请提供 --slug 参数") + return + list_versions(args.slug, base_dir) + elif args.action == "update-meta": + if not args.slug: + logger.error("❌ 请提供 --slug 参数") + return + kwargs: dict[str, object] = {} + if args.stage: + kwargs["current_stage"] = args.stage + if args.score is not None: + kwargs["signal_score"] = args.score + if args.mode: + kwargs["mode"] = args.mode + update_meta(args.slug, base_dir, **kwargs) + + +if __name__ == "__main__": + main() diff --git a/tools/social_parser.py b/tools/social_parser.py new file mode 100644 index 0000000..573b4d0 --- /dev/null +++ b/tools/social_parser.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +""" +simp-skill · Social Media Parser +扫描社交媒体内容目录(朋友圈截图、微博、小红书等), +提取文字内容,生成心上人社交画像报告。 + +支持内容: +- 图片:jpg / jpeg / png / gif / webp / bmp / heic +- 文字导出:txt / md / json / csv + +用法: + python3 social_parser.py --dir crushes/xiaomei/memories/social --output crushes/xiaomei/memories/social/report.md + python3 social_parser.py --dir ./social_screenshots --target 小美 +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from collections import Counter + + +# ───────────────────────────────────────────── +# 常量 +# ───────────────────────────────────────────── + +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".heif"} +TEXT_EXTS = {".txt", ".md", ".json", ".csv"} + +# 平台关键词(用于从文件名推测来源平台) +PLATFORM_HINTS = { + "weibo": ["微博", "weibo", "wb_"], + "xiaohongshu": ["小红书", "红书", "xhs", "xiaohongshu", "rednote"], + "moments": ["朋友圈", "moments", "wechat"], + "douyin": ["抖音", "douyin", "dy_"], + "instagram": ["instagram", "ig_"], + "twitter": ["twitter", "tweet"], + "bilibili": ["bilibili", "bili", "b站"], +} + +# 情感信号关键词(用于文字内容的信号扫描) +SIGNAL_KEYWORDS = { + "积极信号": [ + "喜欢", "开心", "快乐", "幸福", "期待", "想你", "念你", "陪伴", + "一起", "约", "见面", "等你", "好想", "最近怎么样", "你在吗", + "miss", "happy", "love", "together", "date", + ], + "情绪低落": [ + "难过", "伤心", "哭", "失眠", "想太多", "孤独", "一个人", + "失落", "心情不好", "烦", "累", "sad", "lonely", "cry", + ], + "感情相关": [ + "喜欢一个人", "暗恋", "表白", "心动", "心跳", "脸红", + "好看", "好温柔", "好厉害", "崇拜", + "crush", "like someone", "confession", + ], +} + + +# ───────────────────────────────────────────── +# 扫描目录 +# ───────────────────────────────────────────── + +def scan_directory(directory: str) -> dict: + """递归扫描目录,按类型分类文件""" + result = {"images": [], "texts": [], "others": []} + base = Path(directory) + + if not base.exists(): + print(f"⚠️ 目录不存在:{directory}") + return result + + for path in sorted(base.rglob("*")): + if not path.is_file(): + continue + ext = path.suffix.lower() + rel = str(path.relative_to(base)) + + if ext in IMAGE_EXTS: + result["images"].append({"path": str(path), "rel": rel, "name": path.name, "size": path.stat().st_size}) + elif ext in TEXT_EXTS: + result["texts"].append({"path": str(path), "rel": rel, "name": path.name, "size": path.stat().st_size}) + elif path.name.startswith(".") or path.name == "report.md": + continue # 跳过隐藏文件和已生成的报告 + else: + result["others"].append({"path": str(path), "rel": rel, "name": path.name}) + + return result + + +# ───────────────────────────────────────────── +# 平台识别 +# ───────────────────────────────────────────── + +def detect_platform(filename: str) -> str: + """从文件名推测社交平台来源""" + name_lower = filename.lower() + for platform, hints in PLATFORM_HINTS.items(): + for hint in hints: + if hint.lower() in name_lower: + return platform + return "未知平台" + + +def platform_display(platform: str) -> str: + display = { + "weibo": "微博", + "xiaohongshu": "小红书", + "moments": "微信朋友圈", + "douyin": "抖音", + "instagram": "Instagram", + "twitter": "Twitter / X", + "bilibili": "哔哩哔哩", + "未知平台": "未知来源", + } + return display.get(platform, platform) + + +# ───────────────────────────────────────────── +# 文字内容提取与分析 +# ───────────────────────────────────────────── + +def read_text_file(filepath: str, max_chars: int = 5000) -> str: + """读取文本文件,限制长度""" + try: + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + content = f.read(max_chars) + if len(content) == max_chars: + content += "\n\n[... 内容过长,已截断 ...]" + return content.strip() + except Exception as e: + return f"[读取失败:{e}]" + + +def parse_json_export(filepath: str) -> list: + """尝试解析 JSON 导出(微博/小红书等平台的数据导出)""" + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + + posts = [] + # 尝试常见的 JSON 结构 + items = ( + data if isinstance(data, list) + else data.get("data", data.get("posts", data.get("items", []))) + ) + + for item in items[:50]: # 最多取50条 + text = ( + item.get("text") or item.get("content") or + item.get("description") or item.get("body") or "" + ) + created = ( + item.get("created_at") or item.get("time") or + item.get("timestamp") or item.get("date") or "" + ) + posts.append({"text": str(text).strip(), "time": str(created)}) + + return [p for p in posts if p["text"]] + except Exception: + return [] + + +def scan_signals(text: str) -> dict: + """扫描文字内容中的情感信号关键词""" + found = {} + text_lower = text.lower() + for category, keywords in SIGNAL_KEYWORDS.items(): + hits = [kw for kw in keywords if kw in text_lower] + if hits: + found[category] = hits + return found + + +# ───────────────────────────────────────────── +# 报告生成 +# ───────────────────────────────────────────── + +def generate_report(directory: str, target_name: str, output_path: str = None) -> str: + """生成社交媒体内容分析报告""" + files = scan_directory(directory) + now = datetime.now().strftime("%Y-%m-%d %H:%M") + + images = files["images"] + texts = files["texts"] + + lines = [ + f"# 📱 社交媒体内容报告", + f"", + f"> 心上人:**{target_name}** | 分析时间:{now}", + f"> 来源目录:`{directory}`", + f"", + f"---", + f"", + f"## 📊 内容概览", + f"", + f"| 类型 | 数量 |", + f"|------|------|", + f"| 图片 | {len(images)} 张 |", + f"| 文字文件 | {len(texts)} 个 |", + f"| 合计 | {len(images) + len(texts)} 个文件 |", + f"", + ] + + # ── 图片清单 ────────────────────────────── + if images: + # 按平台分组 + platform_groups: dict = {} + for img in images: + plat = detect_platform(img["name"]) + platform_groups.setdefault(plat, []).append(img) + + lines += [ + f"---", + f"", + f"## 🖼️ 图片清单({len(images)} 张)", + f"", + f"> 图片需要通过 Claude 的视觉能力分析,", + f"> 可将图片路径告诉 Claude,让其直接读取图片内容。", + f"", + ] + + for plat, imgs in sorted(platform_groups.items()): + lines.append(f"### {platform_display(plat)}({len(imgs)} 张)") + lines.append(f"") + for img in imgs: + size_kb = round(img["size"] / 1024, 1) + lines.append(f"- `{img['rel']}`({size_kb} KB)") + lines.append(f"") + + lines += [ + f"**使用建议**:", + f"将以上图片路径逐一告诉 Claude,配合以下提示词分析:", + f"", + f"```", + f"请分析这张图片,告诉我:", + f"1. 图片内容是什么?(文字/场景/情绪)", + f"2. 有没有关于 {target_name} 性格或生活状态的信息?", + f"3. 有没有可以用于定制情话的细节?", + f"```", + f"", + ] + + # ── 文字内容 ────────────────────────────── + if texts: + lines += [ + f"---", + f"", + f"## 📝 文字内容({len(texts)} 个文件)", + f"", + ] + + all_signals: dict = {} + + for tf in texts: + filepath = tf["path"] + filename = tf["name"] + ext = Path(filename).suffix.lower() + plat = detect_platform(filename) + + lines += [ + f"### 📄 {tf['rel']}", + f"", + f"**来源平台**:{platform_display(plat)}", + f"", + ] + + # JSON 导出特殊处理 + if ext == ".json": + posts = parse_json_export(filepath) + if posts: + lines.append(f"**解析到 {len(posts)} 条内容**:") + lines.append(f"") + for i, post in enumerate(posts[:10], 1): + time_str = f"({post['time']})" if post["time"] else "" + lines.append(f"{i}. {time_str}{post['text'][:200]}") + if len(posts) > 10: + lines.append(f"... 共 {len(posts)} 条,仅展示前10条") + lines.append(f"") + # 合并所有文字做信号扫描 + combined = " ".join(p["text"] for p in posts) + signals = scan_signals(combined) + else: + content = read_text_file(filepath) + lines.append(f"```") + lines.append(content) + lines.append(f"```") + lines.append(f"") + signals = scan_signals(content) + else: + content = read_text_file(filepath) + lines.append(f"```") + lines.append(content) + lines.append(f"```") + lines.append(f"") + signals = scan_signals(content) + + # 信号标注 + if signals: + lines.append(f"**🔍 检测到的情感关键词**:") + for cat, kws in signals.items(): + lines.append(f"- {cat}:{', '.join(f'`{k}`' for k in kws)}") + lines.append(f"") + # 累计 + for cat, kws in signals.items(): + all_signals.setdefault(cat, []).extend(kws) + + # 全局信号汇总 + if all_signals: + lines += [ + f"---", + f"", + f"## 🎯 社交内容信号汇总", + f"", + f"从所有文字内容中检测到以下情感关键词:", + f"", + ] + for cat, kws in all_signals.items(): + freq = Counter(kws).most_common(5) + lines.append(f"**{cat}**:{', '.join(f'{k}×{c}' if c > 1 else k for k, c in freq)}") + lines += [ + f"", + f"> 💡 这些关键词可以帮助你了解 {target_name} 近期的情绪状态和关注点,", + f"> 用于定制更贴近ta当下心情的情话。", + f"", + ] + + # ── 空目录提示 ──────────────────────────── + if not images and not texts: + lines += [ + f"", + f"⚠️ 目录中没有找到可分析的文件。", + f"", + f"**如何获取社交媒体内容**:", + f"", + f"| 平台 | 方法 |", + f"|------|------|", + f"| 微信朋友圈 | 截图保存为图片 |", + f"| 微博 | 截图 或 使用数据导出工具 |", + f"| 小红书 | 截图 或 复制文字粘贴为 .txt |", + f"| 抖音 | 截图视频封面 |", + f"", + f"将文件放入 `{directory}/` 后重新运行本工具。", + ] + + lines += [ + f"---", + f"", + f"## 📌 后续建议", + f"", + f"1. 将图片路径告诉 Claude 进行视觉分析,补充 `profile.md` 中的画像细节", + f"2. 运行 `/simp analyze` 结合聊天记录与社交内容进行综合信号评估", + f"3. 社交内容揭示的情绪关键词可以作为情话的切入点", + f"", + f"---", + f"", + f"*由 simp-skill · 追爱军师 生成*", + ] + + report = "\n".join(lines) + + if output_path: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(report) + print(f"✅ 报告已保存到 {output_path}") + + return report + + +# ───────────────────────────────────────────── +# 主程序 +# ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="simp-skill · 社交媒体内容解析器", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python3 social_parser.py --dir crushes/xiaomei/memories/social + python3 social_parser.py --dir ./screenshots --target 小美 --output report.md + """, + ) + parser.add_argument("--dir", required=True, help="社交媒体内容目录路径") + parser.add_argument("--target", default="心上人", help="心上人的名字(默认:心上人)") + parser.add_argument("--output", "-o", help="输出报告路径(默认:打印到控制台)") + + args = parser.parse_args() + + print(f"💝 simp-skill · 社交内容解析器") + print(f"📂 扫描目录:{args.dir}") + print(f"🎯 心上人:{args.target}") + print() + + report = generate_report(args.dir, args.target, args.output) + + if not args.output: + print(report) + + +if __name__ == "__main__": + main() diff --git a/tools/time_tracker.py b/tools/time_tracker.py new file mode 100644 index 0000000..a0497e2 --- /dev/null +++ b/tools/time_tracker.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +""" +simp-skill · Time Tracker +互动时间记录与分析 — 数据录入、查询、分析 + +用法: + python3 tools/time_tracker.py record [options] + python3 tools/time_tracker.py analyze [--frequency|--milestones|--reply|--golden] [--output file] +""" + +import argparse +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + +DEFAULT_BASE_DIR = Path("crushes") + +VALID_INTERACTION_TYPES = frozenset({ + "chat_sent", + "chat_received", + "meeting", + "call", + "online_interaction", +}) + +_DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") + + +def _needs_leading_newline(path: Path) -> bool: + """文件已存在、非空且不以换行结尾(上次写入被截断)时返回 True, + 据此在追加前补一个前导换行,避免新记录被拼接进损坏的半行后一起丢失。""" + if not path.exists() or path.stat().st_size == 0: + return False + with path.open("rb") as f: + f.seek(-1, 2) + return f.read(1) != b"\n" + + +def record_interaction( + slug: str, + interaction_type: str, + data: dict[str, Any], + ts: datetime | None = None, + base_dir: Path = DEFAULT_BASE_DIR, +) -> None: + if interaction_type not in VALID_INTERACTION_TYPES: + raise ValueError(f"未知互动类型: {interaction_type}") + + crush_dir = base_dir / slug + if not crush_dir.exists(): + raise FileNotFoundError(f"档案不存在: {slug}") + + if ts is None: + ts = datetime.now() + + interactions_path = crush_dir / "interactions.jsonl" + + # Dedup: same ts + same type → skip + if interactions_path.exists(): + last_line = "" + with interactions_path.open("r", encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if stripped: + last_line = stripped + if last_line: + try: + last_record = json.loads(last_line) + if last_record.get("ts") == ts.isoformat() and last_record.get("type") == interaction_type: + logger.info("⏭️ 重复记录已跳过") + return + except json.JSONDecodeError: + pass + + computed = { + **data, + "hour": ts.hour, + "day_of_week": _DAY_NAMES[ts.weekday()], + } + + if interaction_type == "chat_sent": + computed["is_initiator"] = True + elif interaction_type == "chat_received": + computed["is_initiator"] = False + + record = { + "ts": ts.isoformat(), + "v": 1, + "type": interaction_type, + "slug": slug, + "data": computed, + } + + prefix = "\n" if _needs_leading_newline(interactions_path) else "" + with interactions_path.open("a", encoding="utf-8") as f: + f.write(prefix + json.dumps(record, ensure_ascii=False) + "\n") + + meta_path = crush_dir / "meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + updated_meta = { + **meta, + "interaction_count": len(get_interactions(slug, base_dir=base_dir)), + "last_interaction": ts.isoformat(), + "updated_at": datetime.now().isoformat(), + } + meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8") + + +def get_interactions( + slug: str, + days: int | None = None, + types: list[str] | None = None, + base_dir: Path = DEFAULT_BASE_DIR, +) -> list[dict[str, Any]]: + interactions_path = base_dir / slug / "interactions.jsonl" + if not interactions_path.exists(): + return [] + + cutoff = datetime.now() - timedelta(days=days) if days else None + results: list[dict[str, Any]] = [] + + for idx, line in enumerate(interactions_path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.strip() + if not stripped: + continue + try: + record = json.loads(stripped) + except json.JSONDecodeError: + logger.warning("⚠️ interactions.jsonl 第 %d 行损坏,已跳过:%.80s", idx, stripped) + continue + + if types and record.get("type") not in types: + continue + + if cutoff: + try: + record_ts = datetime.fromisoformat(record["ts"]) + if record_ts < cutoff: + continue + except (ValueError, KeyError): + continue + + results.append(record) + + return results + + +def get_reply_times( + slug: str, + days: int = 30, + base_dir: Path = DEFAULT_BASE_DIR, +) -> list[dict[str, Any]]: + interactions = get_interactions(slug, days=days, types=["chat_received"], base_dir=base_dir) + return [i for i in interactions if "reply_delay_min" in i.get("data", {})] + + +def get_interaction_frequency( + slug: str, + days: int = 30, + base_dir: Path = DEFAULT_BASE_DIR, +) -> dict[str, Any]: + interactions = get_interactions(slug, days=days, base_dir=base_dir) + + hour_counts: dict[int, int] = {} + dow_counts: dict[str, int] = {} + + for interaction in interactions: + data = interaction.get("data", {}) + hour = data.get("hour") + dow = data.get("day_of_week") + if hour is not None: + hour_counts[hour] = hour_counts.get(hour, 0) + 1 + if dow: + dow_counts[dow] = dow_counts.get(dow, 0) + 1 + + return { + "total": len(interactions), + "by_hour": dict(sorted(hour_counts.items())), + "by_day_of_week": {d: dow_counts.get(d, 0) for d in _DAY_NAMES}, + } + + +def analyze_timeline( + slug: str, + days: int = 30, + base_dir: Path = DEFAULT_BASE_DIR, +) -> dict[str, Any]: + interactions = get_interactions(slug, days=days, base_dir=base_dir) + + if not interactions: + return { + "total": 0, + "active_days": 0, + "total_days": days, + "current_streak": 0, + "max_streak": 0, + "user_ratio": 0.0, + } + + active_dates: set[str] = set() + user_count = 0 + them_count = 0 + + for interaction in interactions: + ts_str = interaction.get("ts", "")[:10] + if ts_str: + active_dates.add(ts_str) + data = interaction.get("data", {}) + if data.get("is_initiator") is True: + user_count += 1 + elif data.get("is_initiator") is False: + them_count += 1 + + sorted_dates = sorted(active_dates) + max_streak = 1 + current_streak = 1 + + for i in range(1, len(sorted_dates)): + prev = datetime.strptime(sorted_dates[i - 1], "%Y-%m-%d").date() + curr = datetime.strptime(sorted_dates[i], "%Y-%m-%d").date() + if (curr - prev).days == 1: + current_streak += 1 + max_streak = max(max_streak, current_streak) + else: + current_streak = 1 + + today = datetime.now().strftime("%Y-%m-%d") + if sorted_dates and sorted_dates[-1] == today: + display_streak = current_streak + elif sorted_dates: + last_date = datetime.strptime(sorted_dates[-1], "%Y-%m-%d").date() + if (datetime.now().date() - last_date).days == 1: + display_streak = current_streak + else: + display_streak = 0 + else: + display_streak = 0 + + total = len(interactions) + user_ratio = round(user_count / total * 100, 1) if total else 0.0 + + return { + "total": total, + "active_days": len(active_dates), + "total_days": days, + "current_streak": display_streak, + "max_streak": max(max_streak, 1) if sorted_dates else 0, + "user_count": user_count, + "them_count": them_count, + "user_ratio": user_ratio, + } + + +_REPLY_BUCKETS = [ + ("lte_5min", 0, 5), + ("min_5_to_15", 5, 15), + ("min_15_to_60", 15, 60), + ("hr_1_to_4", 60, 240), + ("gt_4h", 240, float("inf")), +] + + +def analyze_reply_times( + slug: str, + days: int = 30, + base_dir: Path = DEFAULT_BASE_DIR, +) -> dict[str, Any]: + replies = get_reply_times(slug, days=days, base_dir=base_dir) + + if not replies: + return { + "total_replies": 0, + "average_min": None, + "median_min": None, + "distribution": {}, + "weekly_trend": [], + } + + delays = [r["data"]["reply_delay_min"] for r in replies if "reply_delay_min" in r.get("data", {})] + if not delays: + return { + "total_replies": 0, + "average_min": None, + "median_min": None, + "distribution": {}, + "weekly_trend": [], + } + + average_min = round(sum(delays) / len(delays), 1) + sorted_delays = sorted(delays) + median_min = sorted_delays[len(sorted_delays) // 2] + + bucket_counts: dict[str, int] = {label: 0 for label, _, _ in _REPLY_BUCKETS} + for d in delays: + for label, lo, hi in _REPLY_BUCKETS: + if lo <= d < hi: + bucket_counts[label] += 1 + break + + total = len(delays) + distribution = {label: round(count / total * 100, 1) for label, count in bucket_counts.items()} + + return { + "total_replies": total, + "average_min": average_min, + "median_min": median_min, + "distribution": distribution, + "weekly_trend": [], + } + + +def analyze_golden_hours( + slug: str, + days: int = 30, + base_dir: Path = DEFAULT_BASE_DIR, +) -> dict[str, Any]: + received = get_interactions(slug, days=days, types=["chat_received"], base_dir=base_dir) + + if not received: + return {"peak_hour": None, "top_windows": [], "weekday_peak": None, "weekend_peak": None} + + hour_counts: dict[int, int] = {} + weekday_hours: dict[int, int] = {} + weekend_hours: dict[int, int] = {} + + for r in received: + data = r.get("data", {}) + hour = data.get("hour") + dow = data.get("day_of_week", "") + if hour is None: + continue + hour_counts[hour] = hour_counts.get(hour, 0) + 1 + if dow in ("sat", "sun"): + weekend_hours[hour] = weekend_hours.get(hour, 0) + 1 + else: + weekday_hours[hour] = weekday_hours.get(hour, 0) + 1 + + peak_hour = max(hour_counts, key=hour_counts.get) if hour_counts else None + + sorted_hours = sorted(hour_counts.items(), key=lambda x: -x[1]) + top_windows = [{"hour": h, "count": c, "pct": round(c / len(received) * 100, 1)} for h, c in sorted_hours[:3]] + + weekday_peak = max(weekday_hours, key=weekday_hours.get) if weekday_hours else None + weekend_peak = max(weekend_hours, key=weekend_hours.get) if weekend_hours else None + + return { + "peak_hour": peak_hour, + "top_windows": top_windows, + "weekday_peak": weekday_peak, + "weekend_peak": weekend_peak, + } + + +_STAGE_BASELINES: dict[str, tuple[int, int]] = { + "破冰期": (7, 14), + "升温期": (10, 21), + "暧昧期": (14, 35), + "表白前": (7, 21), + "表白后-成功": (0, 0), +} + + +def analyze_milestones( + slug: str, + base_dir: Path = DEFAULT_BASE_DIR, +) -> dict[str, Any]: + crush_dir = base_dir / slug + + profile_path = crush_dir / "profile.md" + if not profile_path.exists(): + return {"stages": [], "total_days": 0} + + profile_text = profile_path.read_text(encoding="utf-8") + created_at_str: str | None = None + if profile_text.startswith("---"): + parts = profile_text.split("---", 2) + if len(parts) >= 3: + for line in parts[1].strip().splitlines(): + if line.strip().startswith("created_at:"): + created_at_str = line.split(":", 1)[1].strip().strip('"').strip("'") + break + + if not created_at_str: + return {"stages": [], "total_days": 0} + + try: + created_at = datetime.strptime(created_at_str[:10], "%Y-%m-%d").date() + except ValueError: + return {"stages": [], "total_days": 0} + + events_path = crush_dir / "events.jsonl" + stage_transitions: list[dict[str, Any]] = [] + if events_path.exists(): + for line in events_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped: + continue + try: + event = json.loads(stripped) + except json.JSONDecodeError: + continue + if event.get("type") == "stage_changed": + ts_str = event.get("ts", "")[:10] + try: + ts_date = datetime.strptime(ts_str, "%Y-%m-%d").date() + except ValueError: + continue + stage_transitions.append({ + "date": ts_date, + "from": event.get("data", {}).get("from", ""), + "to": event.get("data", {}).get("to", ""), + }) + + today = datetime.now().date() + total_days = (today - created_at).days + + stages: list[dict[str, Any]] = [] + prev_date = created_at + + for i, transition in enumerate(stage_transitions): + days_in_stage = (transition["date"] - prev_date).days + stage_name = transition["from"] + baseline_lo, baseline_hi = _STAGE_BASELINES.get(stage_name, (0, 0)) + + status = "normal" + if baseline_lo > 0 and days_in_stage > baseline_hi: + status = "slow" + elif baseline_lo > 0 and days_in_stage < baseline_lo: + status = "fast" + + stages.append({ + "name": stage_name, + "start": prev_date.isoformat(), + "end": transition["date"].isoformat(), + "days": days_in_stage, + "baseline_lo": baseline_lo, + "baseline_hi": baseline_hi, + "status": status, + }) + prev_date = transition["date"] + + if stage_transitions: + current_stage_name = stage_transitions[-1]["to"] + else: + current_stage_name = "破冰期" + + current_days = (today - prev_date).days + baseline_lo, baseline_hi = _STAGE_BASELINES.get(current_stage_name, (0, 0)) + + current_status = "normal" + if baseline_lo > 0 and current_days > baseline_hi: + current_status = "slow" + elif baseline_lo > 0 and current_days < baseline_lo: + current_status = "fast" + + stages.append({ + "name": current_stage_name, + "start": prev_date.isoformat(), + "end": None, + "days": current_days, + "baseline_lo": baseline_lo, + "baseline_hi": baseline_hi, + "status": current_status, + }) + + return { + "stages": stages, + "total_days": total_days, + "created_at": created_at.isoformat(), + } + + +def _bar(pct: float, width: int = 20) -> str: + filled = int(pct / 100 * width) + return "█" * filled + "░" * (width - filled) + + +def _format_frequency(tl: dict, freq: dict) -> str: + lines = [ + "📊 互动频率分析", + f" 总互动次数: {tl['total']}", + f" 活跃天数: {tl['active_days']}/{tl['total_days']}", + f" 连续互动: 当前 {tl['current_streak']} 天 | 最长 {tl['max_streak']} 天", + f" 主动比例: {tl['user_ratio']}%", + "", + " 时段分布:", + ] + by_hour = freq.get("by_hour", {}) + max_count = max(by_hour.values()) if by_hour else 1 + for hour in range(24): + count = by_hour.get(hour, 0) + if count > 0: + bar_width = int(count / max_count * 15) + lines.append(f" {hour:02d}:00 {'█' * bar_width} ({count})") + return "\n".join(lines) + + +def _format_milestones(ms: dict) -> str: + if not ms["stages"]: + return "🎯 追求进度追踪\n 暂无阶段数据" + lines = [ + "🎯 追求进度追踪", + f" 总天数: {ms['total_days']} 天", + "", + ] + for stage in ms["stages"]: + status_icon = {"fast": "⚡", "normal": "✅", "slow": "🐌"}.get(stage["status"], "❓") + end_str = stage["end"] or "进行中" + baseline_str = f"(基线 {stage['baseline_lo']}-{stage['baseline_hi']} 天)" if stage["baseline_lo"] > 0 else "" + lines.append(f" {status_icon} {stage['name']}: {stage['days']} 天 {baseline_str}") + lines.append(f" {stage['start']} → {end_str}") + return "\n".join(lines) + + +def _format_reply(rt: dict) -> str: + if rt["total_replies"] == 0: + return "⏱️ 回复时间分析\n 暂无回复数据" + lines = [ + "⏱️ 回复时间分析", + f" 平均回复: {rt['average_min']:.0f} 分钟", + f" 中位回复: {rt['median_min']:.0f} 分钟", + "", + " 分布:", + ] + labels = { + "lte_5min": "≤5分钟", + "min_5_to_15": "5-15分钟", + "min_15_to_60": "15-60分钟", + "hr_1_to_4": "1-4小时", + "gt_4h": ">4小时", + } + for key, label in labels.items(): + pct = rt["distribution"].get(key, 0) + lines.append(f" {label:>8s} {_bar(pct)} {pct:.0f}%") + return "\n".join(lines) + + +def _format_golden(gh: dict) -> str: + if gh["peak_hour"] is None: + return "🌟 黄金时段建议\n 暂无数据" + lines = [ + "🌟 黄金时段建议", + f" 最活跃时段: {gh['peak_hour']}:00", + "", + " 最佳发送窗口:", + ] + for w in gh["top_windows"]: + lines.append(f" {w['hour']:02d}:00 ({w['count']} 次, {w['pct']:.0f}%)") + if gh["weekday_peak"] is not None: + lines.append(f" 工作日高峰: {gh['weekday_peak']}:00") + if gh["weekend_peak"] is not None: + lines.append(f" 周末高峰: {gh['weekend_peak']}:00") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description="simp-skill · 互动时间追踪") + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("record", help="记录互动") + p.add_argument("slug") + p.add_argument("type", choices=sorted(VALID_INTERACTION_TYPES)) + p.add_argument("--summary", help="内容摘要") + p.add_argument("--duration", type=int, help="时长(分钟)") + p.add_argument("--activity", help="活动描述") + p.add_argument("--location", help="地点") + p.add_argument("--initiator", choices=["me", "them", "mutual"], help="发起方") + p.add_argument("--time", help="时间(ISO 格式,如 2026-05-15T22:30)") + p.add_argument("--base-dir", default="crushes") + + p = sub.add_parser("analyze", help="分析互动数据") + p.add_argument("slug") + p.add_argument("--frequency", action="store_true") + p.add_argument("--milestones", action="store_true") + p.add_argument("--reply", action="store_true") + p.add_argument("--golden", action="store_true") + p.add_argument("--output", help="导出 Markdown 报告") + p.add_argument("--days", type=int, default=30) + p.add_argument("--base-dir", default="crushes") + + args = parser.parse_args() + base_dir = Path(args.base_dir) + + if args.cmd == "record": + ts = datetime.fromisoformat(args.time) if args.time else None + data: dict[str, Any] = {} + if args.summary: + data["content_summary"] = args.summary + if args.duration: + data["duration_min"] = args.duration + if args.activity: + data["activity"] = args.activity + if args.location: + data["location"] = args.location + if args.initiator: + data["initiator"] = args.initiator + + record_interaction(args.slug, args.type, data, ts=ts, base_dir=base_dir) + logger.info("✅ 互动已记录:%s", args.type) + + elif args.cmd == "analyze": + slug = args.slug + base = Path(args.base_dir) + days = args.days + + sections: list[str] = [] + + if args.frequency or not (args.frequency or args.milestones or args.reply or args.golden): + tl = analyze_timeline(slug, days=days, base_dir=base) + freq = get_interaction_frequency(slug, days=days, base_dir=base) + section = _format_frequency(tl, freq) + sections.append(section) + + if args.milestones or not (args.frequency or args.milestones or args.reply or args.golden): + ms = analyze_milestones(slug, base_dir=base) + section = _format_milestones(ms) + sections.append(section) + + if args.reply or not (args.frequency or args.milestones or args.reply or args.golden): + rt = analyze_reply_times(slug, days=days, base_dir=base) + section = _format_reply(rt) + sections.append(section) + + if args.golden or not (args.frequency or args.milestones or args.reply or args.golden): + gh = analyze_golden_hours(slug, days=days, base_dir=base) + section = _format_golden(gh) + sections.append(section) + + report = "\n\n".join(sections) + + if args.output: + Path(args.output).write_text(report, encoding="utf-8") + logger.info("📊 报告已导出到 %s", args.output) + else: + print(report) + + +if __name__ == "__main__": + main()