chore: import upstream snapshot with attribution
tests / test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:36:27 +08:00
commit 1d6fd29cda
37 changed files with 10141 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto
+27
View File
@@ -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
+25
View File
@@ -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/
+106
View File
@@ -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)
+21
View File
@@ -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.
+288
View File
@@ -0,0 +1,288 @@
<div align="center">
# 💝 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)
<br>
不教套路,不玩 PUA,只相信一件事——**真心,是最强的攻略。**<br>
提供聊天记录、社交媒体截图、照片,生成信号分析报告。<br>
**量化感情温度,定制追求策略,把心上人追到手。**
<br>
[快速开始](#快速开始) · [指令列表](#指令完整列表) · [危机处理](#危机处理覆盖范围) · [更新日志](CHANGELOG.md) · [English](README_EN.md)
</div>
---
## 它能帮你做什么
| 功能 | 描述 |
| ------------ | ----------------------- |
| 🔍 **信号解读** | 帮你分析聊天记录,判断对方发出了什么信号 |
| 📝 **情话生成** | 根据对象性格和当前情境,生成专属情话 |
| 🗺️ **策略规划** | 制定从破冰到表白的完整追求路线图 |
| 💌 **表白定制** | 帮你准备让对方难忘的表白词 |
| 🆘 **危机处理** | 被拒/冷落/翻车/友谊区,每种危机都有应对方案 |
| 📊 **聊天分析** | 自动分析微信/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)*
*愿每一份真心都有回应。*
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`BeamusWayne/simp-skill`
- 原始仓库:https://github.com/BeamusWayne/simp-skill
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+282
View File
@@ -0,0 +1,282 @@
<div align="center">
# 💝 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)
<br>
No manipulation. No games. Just one belief — **authenticity is the strongest move.**<br>
Feed it your chat logs, social media, and photos. Get a signal analysis report back.<br>
**Quantify the warmth, build the strategy, win your crush.**
<br>
[Quick Start](#quick-start) · [Commands](#full-command-reference) · [Crisis Playbook](#crisis-playbook) · [中文](README.md)
</div>
---
## 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 <name>` | Create a crush profile |
| `/simp list` | View all crush profiles |
| `/simp analyze [description]` | Read signals, assess current stage |
| `/simp message <situation>` | Generate a situation-specific message |
| `/simp confess` | Confession strategy + custom script |
| `/simp daily` | Today's small flirting suggestion |
| `/simp crisis <situation>` | 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 <name>` | 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.*
+416
View File
@@ -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 <sweet|strategic|hybrid> — 切换模式
/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
或者直接告诉我现在的情况,我来帮你分析。
```
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
+372
View File
@@ -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 frontmatterClaude 无法可靠提取/更新单个字段
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 <slug> <event_type> '<json_data>'
# 读取最近N条事件
python3 tools/memory.py events <slug> --last 5
# 读取当前上下文(profile.md + state.md 合并输出)
python3 tools/memory.py context <slug>
# 从事件流重建 state.md
python3 tools/memory.py rebuild <slug>
# 生成日快照
python3 tools/memory.py snapshot <slug>
# 查看事件时间线
python3 tools/memory.py timeline <slug>
```
### 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.mdfrontmatter_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 1Schema 落地(基础)✅
- [x] 更新 `init_crush()` — 新增 `state.md``events.jsonl``snapshots/` 的创建
- [x] 更新 `profile.md` 模板 — 加 YAML frontmatter
- [x] 扩展 `meta.json` schema — 新增字段
- [x] 更新测试
### Phase 2memory.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 3SKILL.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/` | 每日自动生成,用于跨会话快速恢复 |
+285
View File
@@ -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.1v1.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 · 愿每一份真心都有回应*
+194
View File
@@ -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 获取处理方案
```
+493
View File
@@ -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新感情的尊重。
---
### 一句话原则
对方有了新感情,不是你失败了。
是两件事:**时机没到** 或者 **不是对的人**
这两个结论,你现在都还不知道是哪个。
所以现在唯一要做的事,是先把自己过好。
+224
View File
@@ -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 更新一下,建议会更准。"
+148
View File
@@ -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: <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` 补充信息
+274
View File
@@ -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 都已知 | 自动附上兼容性分析 |
+261
View File
@@ -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句 | 简洁,不要显得太慌 |
+159
View File
@@ -0,0 +1,159 @@
# 心上人画像构建 · Crush Persona Builder
## 用途
基于用户提供的信息(文字描述 + 可选的聊天记录/社交媒体内容),构建心上人的性格画像。
画像用于后续情话生成、信号解读、策略制定的个性化定制。
---
## 五层画像框架
### Layer 0:硬约束(优先级最高)
生成情话和策略时,必须遵守:
- 情话风格必须与对方性格匹配,不强行套模板
- 不生成任何 PUA 话术、操控语言、让对方产生不安全感的内容
- 不建议跨越对方明确划定的边界
- 如果对方已明确拒绝,帮助用户优雅接受,而非继续强行追求
- 保持对对方真实性格的尊重,不美化或污名化
### Layer 1:身份锚点
```
name: <昵称/称呼>
age: <年龄段:学生/职场新人/成熟职场人>
gender: <性别>
occupation: <职业>
city: <城市>
mbti: <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: <ta对感兴趣的人是怎么表现的>
```
### 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/`
+304
View File
@@ -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%:对方主动性更强,是好信号,继续保持
---
## 运行规则
- 进度报告应该让用户看完知道"我今天该做什么",不能只给状态描述
- 下一步行动必须具体,不能是"继续深化关系"这种没有操作性的话
- 不美化现状,也不过度悲观——用数据说话
- 若用户最近刚经历了重大事件(被拒/冷战/竞争对手出现),优先处理该事件,不走标准流程
+291
View File
@@ -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}
{结论对应的输出文字}
```
+225
View File
@@ -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条}
💬 推荐今日消息:
"{生成一条适合当前阶段的消息}"
```
+218
View File
@@ -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/陪伴感) |
| 认识更早 | 制造强烈的新记忆,冲淡原有的印象 |
| 对方明显在喜欢对手 | 重新评估,不要在明确无望的局面里耗尽自己 |
---
## 通用策略原则
### 「稳定」是最强吸引力
不因对方的冷热而情绪波动,不因一次冷落就消失,不因一次热情就失去分寸。稳定的人让人觉得安全,安全感让人靠近。
### 「自己」是最好的筹码
追求别人的同时,继续做好自己的事。有自己的目标、生活、有趣的事在做——这本身就是吸引力。不要把所有精力都放在追求这一件事上。
### 「时间」是朋友,也是敌人
- 有些情感需要时间积累(不要急)
- 有些窗口期是有限的(不要拖太久)
- 关键是:读懂当前阶段,按阶段节奏推进
### 「真心」永远有效
无论纯情模式还是策略模式,所有技巧的底层都是真心。如果你对这个人是真的在意,这份真心最终会让对方感受到。策略是加速器,不是替代品。
+107
View File
@@ -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} <type> [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 增强可读性,但不过度
+5
View File
@@ -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
+526
View File
@@ -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(
"<p>2024-01-01 10:00:00 小美(12345) 你好呀~</p>"
"<p>2024-01-01 10:05:00 我(999) 嗨!</p>",
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("<b>2024-01-01 10:00:00</b> 小美(1) <i>消息内容</i>", 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(
'<div class="message"><span class="time">2024-01-01 10:00:00</span>'
'<span class="sender">小美</span><div class="content">你好呀~</div></div>',
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(
'<div class="message"><span class="time">2024/01/01 10:00:00</span>'
'<span class="sender">小美</span><div class="content">test</div></div>',
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"]
+336
View File
@@ -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)
+283
View File
@@ -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()
+324
View File
@@ -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
+226
View File
@@ -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()
+384
View File
@@ -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)
+997
View File
@@ -0,0 +1,997 @@
#!/usr/bin/env python3
"""
simp-skill · Chat Parser
解析微信/QQ聊天记录,提取信号分析报告
支持格式:
- 微信导出 TXTWeChatMsg/留痕等工具)
- 微信导出 HTMLWeChatMsg
- 微信导出 CSVPyWxDump
- QQ 导出 TXT(QQ消息管理器)
- QQ 导出 MHT/MHTMLQQ消息管理器)
- 通用 JSON 格式
用法:
python3 chat_parser.py <input_file> <target_name> [--user <your_name>] [--output <output_file>]
示例:
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:
"""解析微信导出 TXTWeChatMsg格式)"""
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:
"""解析微信导出 HTMLWeChatMsg"""
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
raw = f.read()
messages = []
# 匹配消息块
msg_pattern = re.compile(
r'<div class="message[^"]*"[^>]*>.*?'
r'<span class="time"[^>]*>([^<]+)</span>.*?'
r'<span class="sender"[^>]*>([^<]+)</span>.*?'
r'<div class="content"[^>]*>(.*?)</div>',
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()
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env python3
"""
simp-skill · Memory System
心上人档案记忆系统 — 事件追加、状态读写、快照管理
用法:
python3 tools/memory.py append <slug> <event_type> '<json_data>'
python3 tools/memory.py events <slug> [--last 5] [--type signal_recorded]
python3 tools/memory.py context <slug> [--with-strategy]
python3 tools/memory.py snapshot <slug>
python3 tools/memory.py timeline <slug>
python3 tools/memory.py rebuild <slug>
"""
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.mdfrontmatter_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()
+468
View File
@@ -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()
+348
View File
@@ -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()
+405
View File
@@ -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()
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env python3
"""
simp-skill · Time Tracker
互动时间记录与分析 — 数据录入、查询、分析
用法:
python3 tools/time_tracker.py record <slug> <type> [options]
python3 tools/time_tracker.py analyze <slug> [--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()