This commit is contained in:
@@ -0,0 +1 @@
|
||||
* text=auto
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,288 @@
|
||||
<div align="center">
|
||||
|
||||
# 💝 simp-skill
|
||||
|
||||
> *"茫茫人海,一旦错过就不再。"*
|
||||
|
||||
> *simp,是那个敢于真心喜欢一个人的人。这里教的,是把真心说出口的能力。*
|
||||
|
||||
[](LICENSE)
|
||||
[](https://python.org)
|
||||
[](https://claude.ai/code)
|
||||
[](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)*
|
||||
*愿每一份真心都有回应。*
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`BeamusWayne/simp-skill`
|
||||
- 原始仓库:https://github.com/BeamusWayne/simp-skill
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+282
@@ -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)
|
||||
[](https://python.org)
|
||||
[](https://claude.ai/code)
|
||||
[](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.*
|
||||
@@ -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
|
||||
|
||||
或者直接告诉我现在的情况,我来帮你分析。
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
# Mem-Sys 实现规划书
|
||||
|
||||
> 分支:`Mem-Sys` | 创建于 2026-04-12
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
现有文件结构:
|
||||
```
|
||||
crushes/{slug}/
|
||||
├── profile.md ← 纯 Markdown 散文,无结构化字段,Claude 读写靠理解
|
||||
├── strategy.md ← 同上
|
||||
├── meta.json ← 有结构,但字段稀疏(仅 stage、score、mode)
|
||||
├── memories/chats/ ← 原始聊天记录
|
||||
└── versions/ ← 手动备份,不自动维护
|
||||
```
|
||||
|
||||
**核心问题**:
|
||||
1. `profile.md` 没有 YAML frontmatter,Claude 无法可靠提取/更新单个字段
|
||||
2. 没有"当前状态快照",每次 `/simp progress` 都要重新推断阶段
|
||||
3. 没有事件日志,历史演变不可追溯,`/simp quit` 无法看"感情轨迹"
|
||||
4. `versions/` 是手动备份,不是真正的历史记录
|
||||
|
||||
---
|
||||
|
||||
## 二、新文件结构
|
||||
|
||||
```
|
||||
crushes/{slug}/
|
||||
├── profile.md # 【升级】稳定层 — YAML frontmatter + 叙述体
|
||||
├── state.md # 【新增】动态层 — 当前快照,每次会话后覆盖
|
||||
├── events.jsonl # 【新增】事件流 — 只追加,永不删除
|
||||
├── strategy.md # 【保留】追求策略(不动)
|
||||
├── meta.json # 【扩展】元数据,新增索引字段
|
||||
├── memories/
|
||||
│ ├── chats/
|
||||
│ ├── social/
|
||||
│ └── photos/
|
||||
└── snapshots/ # 【新增】定期快照目录
|
||||
└── YYYY-MM-DD.json
|
||||
```
|
||||
|
||||
**读取优先级**(context window 分配):
|
||||
|
||||
| 文件 | 加载时机 | 预估 tokens |
|
||||
|------|----------|------------|
|
||||
| `profile.md` | 每次必读 | ~300 |
|
||||
| `state.md` | 每次必读 | ~200 |
|
||||
| `events.jsonl` 最近5条 | progress/analyze/quit | ~150 |
|
||||
| `strategy.md` | message/confess/analyze | ~400 |
|
||||
| `events.jsonl` 全部 | 用户明确要求回顾时 | 按需 |
|
||||
|
||||
---
|
||||
|
||||
## 三、文件 Schema 定义
|
||||
|
||||
### 3.1 `profile.md`(稳定层)
|
||||
|
||||
```markdown
|
||||
---
|
||||
nickname: 小雨
|
||||
slug: xiaoyu
|
||||
gender: 女
|
||||
age: 23
|
||||
occupation: 设计师
|
||||
city: 上海
|
||||
mbti: ENFP
|
||||
zodiac: 双鱼
|
||||
personality_type: 感性型
|
||||
how_met: 同学介绍
|
||||
created_at: "2026-04-10"
|
||||
---
|
||||
|
||||
## 性格画像
|
||||
ta 话很多,喜欢用颜文字,情绪外露,容易被细节打动。
|
||||
见面时会不自觉靠近,但如果感觉被冷落会直接不回消息。
|
||||
|
||||
## 最打动ta的方式
|
||||
具体的画面感 > 泛泛的夸奖。说"你刚才皱眉的样子"比"你很可爱"更有效。
|
||||
|
||||
## 用户自身风格
|
||||
说话偏幽默,短句为主,偶尔用表情包,追求混合模式。
|
||||
|
||||
## 注意事项
|
||||
ta 对"套路感"敏感,一旦感觉被设计会直接拉远距离。
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- YAML frontmatter 存结构化字段,Claude 更新时用字段名精确修改
|
||||
- 正文存叙述性内容,只追加,不覆盖
|
||||
- `personality_type` 枚举值:`感性型 | 理性型 | 傲娇型 | 温柔型`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `state.md`(动态层)
|
||||
|
||||
```markdown
|
||||
---
|
||||
current_stage: 暧昧期
|
||||
signal_score: 17
|
||||
last_signal_score: 14
|
||||
score_trend: up
|
||||
recommended_mode: 混合
|
||||
last_updated: "2026-04-12T10:30:00"
|
||||
milestones_done: 7
|
||||
---
|
||||
|
||||
## 当前状态(一句话)
|
||||
关系持续升温,ta 开始主动制造见面机会。
|
||||
|
||||
## 最近信号(最新3条)
|
||||
- 🟢 2026-04-11 ta 主动问周末有没有空
|
||||
- 🟢 2026-04-10 深夜发了消息说"突然想到你"
|
||||
- 🟡 2026-04-08 回复比往常慢了一些
|
||||
|
||||
## 当前策略方向
|
||||
暧昧期推进:制造1v1心跳时刻,评估表白时机是否成熟。
|
||||
|
||||
## 下一步建议
|
||||
本周制造一次晚间1v1见面,用 /simp message 准备好开场话题。
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- 每次 `/simp analyze`、`/simp progress` 结束后**全量覆盖**
|
||||
- `score_trend` 枚举:`up | down | stable`
|
||||
- `current_stage` 枚举:`破冰期 | 升温期 | 暧昧期 | 表白前 | 表白后-成功 | 表白后-被拒 | 友谊区 | 重启期`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `events.jsonl`(事件流)
|
||||
|
||||
每行一个独立 JSON 对象,严格只追加:
|
||||
|
||||
```jsonl
|
||||
{"ts":"2026-04-10T14:00:00","v":1,"type":"profile_created","slug":"xiaoyu","data":{"nickname":"小雨","personality_type":"感性型"}}
|
||||
{"ts":"2026-04-11T20:30:00","v":1,"type":"signal_recorded","slug":"xiaoyu","data":{"direction":"green","content":"深夜主动发消息说突然想到你","score_delta":3}}
|
||||
{"ts":"2026-04-12T10:30:00","v":1,"type":"stage_changed","slug":"xiaoyu","data":{"from":"升温期","to":"暧昧期","trigger":"连续3天主动联系 + 深夜消息"}}
|
||||
{"ts":"2026-04-12T10:30:00","v":1,"type":"analysis_done","slug":"xiaoyu","data":{"score":17,"stage":"暧昧期","summary":"ta 主动性明显增强,信号偏绿"}}
|
||||
```
|
||||
|
||||
**事件类型字典**:
|
||||
|
||||
| type | 触发指令 | data 必填字段 |
|
||||
|------|---------|--------------|
|
||||
| `profile_created` | `/simp create` | nickname, personality_type |
|
||||
| `profile_updated` | `/simp update` | field, old_value, new_value |
|
||||
| `stage_changed` | analyze/progress | from, to, trigger |
|
||||
| `signal_recorded` | `/simp analyze` | direction(green/red/neutral), content, score_delta |
|
||||
| `analysis_done` | `/simp analyze` | score, stage, summary |
|
||||
| `progress_evaluated` | `/simp progress` | score, stage, milestones_done, next_actions |
|
||||
| `strategy_updated` | 阶段变化时 | old_stage, new_stage, reason |
|
||||
| `crisis_handled` | `/simp crisis` | crisis_type, resolution |
|
||||
| `quit_evaluated` | `/simp quit` | verdict(continue/reconsider/let_go), reason |
|
||||
| `confess_prepared` | `/simp confess` | method, readiness_score |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `meta.json`(扩展)
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "xiaoyu",
|
||||
"nickname": "小雨",
|
||||
"created_at": "2026-04-10T14:00:00",
|
||||
"updated_at": "2026-04-12T10:30:00",
|
||||
"version": "v1",
|
||||
"current_stage": "暧昧期",
|
||||
"signal_score": 17,
|
||||
"mode": "hybrid",
|
||||
"event_count": 4,
|
||||
"last_snapshot": "2026-04-12"
|
||||
}
|
||||
```
|
||||
|
||||
新增字段:`nickname`(用于 list 展示)、`event_count`(快速统计)、`last_snapshot`
|
||||
|
||||
---
|
||||
|
||||
## 四、每条指令的 Memory Protocol
|
||||
|
||||
```
|
||||
指令 读取 写入
|
||||
─────────────────────────────────────────────────────────
|
||||
/simp create 无 profile.md(新建)
|
||||
state.md(空模板)
|
||||
events.jsonl(新建)
|
||||
meta.json(新建)
|
||||
|
||||
/simp analyze profile.md state.md(覆盖)
|
||||
state.md events.jsonl(追加:
|
||||
signal_recorded
|
||||
analysis_done
|
||||
stage_changed 若变化)
|
||||
|
||||
/simp message profile.md 无
|
||||
state.md
|
||||
strategy.md
|
||||
|
||||
/simp progress profile.md state.md(覆盖)
|
||||
state.md events.jsonl(追加:
|
||||
events.jsonl 最近5条 progress_evaluated)
|
||||
meta.json(更新score/stage)
|
||||
|
||||
/simp update profile.md profile.md(更新frontmatter)
|
||||
events.jsonl(追加:
|
||||
profile_updated)
|
||||
|
||||
/simp confess profile.md events.jsonl(追加:
|
||||
state.md confess_prepared)
|
||||
strategy.md
|
||||
|
||||
/simp crisis profile.md state.md(更新状态)
|
||||
state.md events.jsonl(追加:
|
||||
crisis_handled)
|
||||
|
||||
/simp daily state.md 仅此 无
|
||||
|
||||
/simp quit profile.md events.jsonl(追加:
|
||||
state.md quit_evaluated)
|
||||
events.jsonl 全部
|
||||
|
||||
/simp list meta.json(所有slug) 无
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、新增 Python 工具:`tools/memory.py`
|
||||
|
||||
### 5.1 模块职责
|
||||
|
||||
`memory.py` 是记忆系统的唯一入口,`skill_writer.py` 调用它,Claude 通过 shell 调用它。
|
||||
|
||||
### 5.2 CLI 接口
|
||||
|
||||
```bash
|
||||
# 追加事件
|
||||
python3 tools/memory.py append <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.md,frontmatter_updates 更新 YAML 字段"""
|
||||
|
||||
def take_snapshot(
|
||||
slug: str,
|
||||
base_dir: Path = DEFAULT_BASE_DIR
|
||||
) -> Path:
|
||||
"""将 meta.json + state.md frontmatter 合并写入 snapshots/YYYY-MM-DD.json"""
|
||||
|
||||
def rebuild_state_from_events(
|
||||
slug: str,
|
||||
base_dir: Path = DEFAULT_BASE_DIR
|
||||
) -> dict:
|
||||
"""从 events.jsonl 重放,返回推断的当前状态(用于校验或恢复)"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、`skill_writer.py` 改动
|
||||
|
||||
`init_crush()` 新增三步:
|
||||
|
||||
1. 在 `profile.md` 模板顶部加 YAML frontmatter
|
||||
2. 创建 `state.md`(空模板)
|
||||
3. 创建 `events.jsonl`(空文件)
|
||||
4. 创建 `snapshots/` 目录
|
||||
5. `meta.json` 新增 `nickname`、`event_count: 0`、`last_snapshot: null` 字段
|
||||
|
||||
`backup_crush()` 扩展:同步备份 `state.md`(events.jsonl 不备份,它是不可变历史)。
|
||||
|
||||
`update_meta()` 扩展:支持更新 `nickname`、`event_count`、`last_snapshot`。
|
||||
|
||||
---
|
||||
|
||||
## 七、`SKILL.md` 改动(Phase 3)
|
||||
|
||||
在「运行规则」之前新增「记忆操作协议」章节,定义每条指令的读写规则。
|
||||
|
||||
---
|
||||
|
||||
## 八、实施步骤
|
||||
|
||||
### Phase 1:Schema 落地(基础)✅
|
||||
- [x] 更新 `init_crush()` — 新增 `state.md`、`events.jsonl`、`snapshots/` 的创建
|
||||
- [x] 更新 `profile.md` 模板 — 加 YAML frontmatter
|
||||
- [x] 扩展 `meta.json` schema — 新增字段
|
||||
- [x] 更新测试
|
||||
|
||||
### Phase 2:memory.py 工具 ✅
|
||||
- [x] 实现 `append_event()`
|
||||
- [x] 实现 `get_recent_events()`
|
||||
- [x] 实现 `update_state()`
|
||||
- [x] 实现 `load_context()`
|
||||
- [x] 实现 `take_snapshot()`
|
||||
- [x] 实现 `rebuild_state_from_events()`
|
||||
- [x] CLI 入口
|
||||
- [x] 测试覆盖(见 `tests/test_memory.py`)
|
||||
|
||||
### Phase 3:SKILL.md 集成 ✅
|
||||
- [x] 新增「记忆操作协议」章节
|
||||
- [x] 每条指令补充 Memory Protocol 说明
|
||||
- [x] `progress_tracker.md` 更新读取路径
|
||||
|
||||
### Phase 4:迁移工具 · ❌ 不再计划
|
||||
|
||||
早期阶段无存量用户档案,迁移工具的实际价值低于维护成本。新档案统一通过 `init_crush()` 创建,已自带 YAML frontmatter / `state.md` / `events.jsonl`,无需迁移。若未来出现存量数据需要升级,再单独立项。
|
||||
|
||||
---
|
||||
|
||||
## 九、测试计划
|
||||
|
||||
```
|
||||
tests/
|
||||
├── test_skill_writer.py # 已有,补充新字段断言 ✅
|
||||
└── test_memory.py # 新增(Phase 2)✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、一句话原则备忘
|
||||
|
||||
| 文件 | 一句话规则 |
|
||||
|------|-----------|
|
||||
| `profile.md` | 只增不减,YAML 字段可更新,正文只追加 |
|
||||
| `state.md` | 代表"现在",每次全量覆盖 |
|
||||
| `events.jsonl` | 代表"历史",只追加,永不删除 |
|
||||
| `strategy.md` | 阶段变化时更新,其他时候只读 |
|
||||
| `snapshots/` | 每日自动生成,用于跨会话快速恢复 |
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
# simp-skill · 产品设计文档 (PRD)
|
||||
|
||||
> 版本:v1.6
|
||||
> 作者:[@BeamusWayne](https://github.com/BeamusWayne)
|
||||
> 最后更新:2026-05-02
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么做这个
|
||||
|
||||
这个 skill 系列里已经有了模拟前任的、模拟心上人的、模拟同事的。
|
||||
|
||||
但它们有一个共同的角色设定:**它们都是「对方」**。
|
||||
|
||||
没有一个是站在你这边的。
|
||||
|
||||
simp-skill 想做的事情不一样——它不扮演任何人,它只做一件事:**陪你追人**。
|
||||
|
||||
---
|
||||
|
||||
## 二、核心定位
|
||||
|
||||
```
|
||||
不是情感陪伴 App
|
||||
不是恋爱模拟游戏
|
||||
不是话术库
|
||||
|
||||
是一个懂感情、有策略、永远站你这边的追求教练。
|
||||
```
|
||||
|
||||
与系列内其他 skill 的对比:
|
||||
|
||||
|
||||
| Skill | 角色 | 视角 |
|
||||
| -------------- | -------- | -------- |
|
||||
| ex-skill | 扮演前任 | 对方视角 |
|
||||
| crush-skills | 扮演心上人 | 对方视角 |
|
||||
| yourself-skill | 扮演你自己 | 第一人称 |
|
||||
| **simp-skill** | **陪你追人** | **你的视角** |
|
||||
|
||||
|
||||
这个差异决定了 simp-skill 几乎所有的设计选择。
|
||||
|
||||
---
|
||||
|
||||
## 三、最重要的一个设计决策:为什么要有两个模式
|
||||
|
||||
这是整个产品里争议最大的设计,值得单独说清楚。
|
||||
|
||||
### 最初的方案:只做纯情模式
|
||||
|
||||
第一稿的设定很简单:只教你说真心话,不玩套路,用真诚打动对方。
|
||||
|
||||
这个方案有一个问题:**它假设所有人、所有阶段都适合同一种方式。**
|
||||
|
||||
现实是,有些人在感情里天然不会解读直白的表达——不是因为他们冷漠,而是因为他们需要一点空间和节奏感。对这类人纯情轰炸,效果可能适得其反。
|
||||
|
||||
### 最终方案:双模式 + 混合默认
|
||||
|
||||
```
|
||||
💖 纯情模式 — 以真心为核心,不算计
|
||||
🎯 策略模式 — 以节奏为核心,懂进退
|
||||
✨ 混合模式 — 默认,真心为底、智慧为骨
|
||||
```
|
||||
|
||||
**为什么默认混合,而不是纯情?**
|
||||
|
||||
因为大多数追求失败,不是真心不够,而是时机和方式错了。真心是必要条件,不是充分条件。混合模式假设用户是真心的,但帮助他们更聪明地表达出来。
|
||||
|
||||
**为什么保留纯情模式,而不是全用混合?**
|
||||
|
||||
有些用户明确知道自己不想算计,他们只需要帮助「把心里的话说出来」。纯情模式是给他们的。
|
||||
|
||||
**策略模式会不会变成 PUA?**
|
||||
|
||||
不会——见第五节。策略模式的边界是「让感情推进得更自然」,而不是「制造对方的不安全感」。这两者有明确的区别,被写进了 SKILL.md 的运行规则里。
|
||||
|
||||
---
|
||||
|
||||
## 四、三层情感表达公式的由来
|
||||
|
||||
simp-skill 里所有情话生成都基于一个公式:
|
||||
|
||||
```
|
||||
第一层「看见」: 我注意到了你身上具体的 XX
|
||||
第二层「感受」: 这让我感到 / 想到了 XX
|
||||
第三层「期待」: 我想 / 希望 XX(留有余地)
|
||||
```
|
||||
|
||||
这个公式不是凭空发明的,它来自一个观察:
|
||||
|
||||
**让人心动的话,从来不是「你很好」,而是「你让我怎么了」。**
|
||||
|
||||
「你很漂亮」是评价,「看到你我突然不知道说什么了」是感受。后者更真实,更难伪造,也因此更有力量。
|
||||
|
||||
公式的第三层刻意设计了「留有余地」——不把话说满,让对方有想继续的空间。这是纯情模式和策略模式共享的底层逻辑:**无论多真诚,表达的节奏感都很重要。**
|
||||
|
||||
---
|
||||
|
||||
## 五、伦理边界:我们不生成什么
|
||||
|
||||
这一节的存在,是因为一个追求类 skill 太容易滑向操控工具。
|
||||
|
||||
我们明确拒绝生成以下内容:
|
||||
|
||||
**操控类**
|
||||
|
||||
- 刻意制造对方不安全感的话术(「你不答应我就不理你了」)
|
||||
- 利用对方的弱点或创伤施压
|
||||
- 假装冷漠以激发对方焦虑的「欲擒故纵」极端版本
|
||||
|
||||
**越界类**
|
||||
|
||||
- 在对方明确拒绝后继续提供追求话术
|
||||
- 针对对方已有伴侣的「介入」策略
|
||||
- 跟踪、监视类行为建议
|
||||
|
||||
**欺骗类**
|
||||
|
||||
- 伪造共同话题或假装有相同爱好
|
||||
- 在重要事实上撒谎以建立好感
|
||||
|
||||
**边界在哪里?**
|
||||
|
||||
一个判断标准:**如果对方知道你在用这个方法,ta 会觉得被尊重,还是被耍了?**
|
||||
|
||||
策略模式里的「推拉节奏」「制造缺席感」,如果对方知道,会觉得「这个人懂得分寸」。而 PUA 话术如果被对方知道,ta 会觉得被操控。这是边界。
|
||||
|
||||
### 一条新增的伦理边界:不在用户焦虑时火上浇油
|
||||
|
||||
v1.6 加入「防耗尽机制」时,伦理边界从「不伤害对方」延伸到了「不伤害用户自己」。
|
||||
|
||||
一个追求工具默认会假设:**用户问就给答案,越多越好**。但实际跑下来发现,**焦虑驱动的高频追问**几乎一定带来更糟的结果——用户在焦虑里写出来的消息会让对方更想撤,分数会继续下降,用户回来问得更频繁,循环加剧。
|
||||
|
||||
这种情况下,工具有两条路:
|
||||
|
||||
- **A. 继续输出建议**——用户开心了,对方没事,工具看起来很有用
|
||||
- **B. 拒绝输出,让用户先停下来**——用户当下不爽,但避免了对双方都不好的动作
|
||||
|
||||
我们选 B。这是和「不教 PUA」同等优先级的边界——只是一个保护对方,一个保护用户自己。
|
||||
|
||||
具体规则在 `prompts/daily_coach.md` Step 0 和 `prompts/progress_tracker.md` 风险表里。当 7 天内调用频率过高、焦虑词密度超标、或评分持续下降时,工具会进入「劝歇」分支:不给行动建议,只给陪伴式回应。即使用户坚持要继续,第一次也礼貌坚持一次,第二次才让步并附警告。
|
||||
|
||||
这不是「服从用户」的退让,是「服务用户长期利益」的坚持。
|
||||
|
||||
---
|
||||
|
||||
## 六、信号评分系统的设计逻辑
|
||||
|
||||
simp-skill 的聊天分析器用 25 分制评分,而不是简单的「有没有好感」判断。
|
||||
|
||||
**为什么是 25 分,不是 10 分或 100 分?**
|
||||
|
||||
10 分太粗,无法区分「有一点好感」和「明显喜欢」。100 分会给用户一种虚假的精确感(「83分和84分有什么区别?」)。25 分在精度和可读性之间取了平衡。
|
||||
|
||||
**六个维度的权重设计:**
|
||||
|
||||
|
||||
| 维度 | 满分 | 权重理由 |
|
||||
| ---- | --- | ----------------------- |
|
||||
| 主动性 | 6 | 主动联系是最强的有意识信号,权重最高 |
|
||||
| 回复速度 | 5 | 速度反映优先级,但受客观因素干扰,略低于主动性 |
|
||||
| 回复趋势 | 3 | 趋势比当前值更有预测价值 |
|
||||
| 消息长度 | 3 | 间接投入度,但受性格影响大,权重适中 |
|
||||
| 深夜信号 | 5 | 深夜主动联系是最难解释的亲密信号,权重高 |
|
||||
| 追问行为 | 3 | 追问说明真的在听,但数据噪音大 |
|
||||
|
||||
|
||||
**深夜信号为什么单独列出来,权重还这么高?**
|
||||
|
||||
因为深夜主动找一个人,是很难用「礼貌」或「习惯」解释的。白天的消息可能是客气,深夜的消息很难是。这是这六个维度里「排除噪音」能力最强的一个。
|
||||
|
||||
---
|
||||
|
||||
## 七、功能范围演进
|
||||
|
||||
v1 的设计原则是:**把追求的核心流程做完整,而不是把所有边缘功能都塞进来。** 此后每个小版本只补一类清晰的能力,不堆叠。
|
||||
|
||||
**v1.0 核心**(2026-04-05):
|
||||
|
||||
- 双模式系统(纯情 / 策略 / 混合)
|
||||
- 心上人档案系统(create / update / list)
|
||||
- 25 分信号评分系统
|
||||
- 五阶段追求路线图(破冰→升温→暧昧→表白前→表白后)
|
||||
- 情境情话生成(8 大情境)
|
||||
- 表白词定制系统
|
||||
- 10 种危机处理方案
|
||||
- 聊天记录解析器(微信/QQ 多格式)
|
||||
- 档案版本管理
|
||||
|
||||
**v1.1–v1.6 增量**:
|
||||
|
||||
- v1.1 — 表白模块 prompt 完整化、`signal_reader.md` 评分对齐 25 分、`skill_writer.py` 类型化与日志改造
|
||||
- v1.2 — 今日建议 `/simp daily`、进度追踪 `/simp progress`、放弃判断器 `/simp quit`
|
||||
- v1.3 — `/simp message` 加入 AI 味检测、危机扩展 C-11(对方开始新感情)
|
||||
- v1.4 — 记忆系统 Phase 2+3(`tools/memory.py`、事件流、状态快照、记忆操作协议)、进度可视化
|
||||
- v1.5 — MBTI 分析系统 `/simp mbti`、信号/策略集成 MBTI 校正层
|
||||
- v1.6 — 防耗尽机制(`/simp daily` Step 0 预检、`/simp progress` 风险表新增「用户耗尽风险」、`SKILL.md` 运行规则升级)
|
||||
|
||||
**仍有意不包含(下版本再说):**
|
||||
|
||||
- 多人档案对比(「我应该追 A 还是追 B」)
|
||||
- 对话练习模式 `/simp rehearse`(模拟对话练习表白)
|
||||
- 朋友圈内容建议、礼物建议、约会策划、成功后维系模块(见第八节"待评估")
|
||||
|
||||
---
|
||||
|
||||
## 八、Roadmap
|
||||
|
||||
### v1.6 · ✅ 已发布(2026-04-28)
|
||||
|
||||
- **防耗尽机制**:`prompts/daily_coach.md` 新增 Step 0「防耗尽预检」,从 `events.jsonl` 最近 7 天读取调用频率、焦虑词密度、单日重复、评分趋势、长期低位徘徊 5 类信号,命中高风险时短路返回「劝歇」分支。`prompts/progress_tracker.md` 风险表新增「用户耗尽风险」一行;`SKILL.md` 运行规则新增第 8 条「防耗尽优先」。这条线的核心立场是:当用户处于焦虑驱动状态时,更多的追求建议只会加深耗尽,工具应主动停下来而不是继续输出。
|
||||
|
||||
### v1.5 · ✅ 已发布
|
||||
|
||||
- **MBTI 分析系统**:新增 `/simp mbti` 指令。含三个模块:① 行为维度推断(E/I、N/S、T/F、J/P)② 16 型专属追求策略(含核心需求、节奏、雷区、试探方式)③ 双方兼容性分析。信号解读与策略规划模块已集成 MBTI 校正层。
|
||||
|
||||
### v1.4 · ✅ 已发布
|
||||
|
||||
- **记忆系统 Phase 2+3**:`tools/memory.py` 完整实现(事件追加、状态读写、快照管理、时间线查看、状态重建)。`SKILL.md` 新增记忆操作协议,定义每条指令的读写规范。
|
||||
- **进度可视化**:`/simp progress` 输出新增阶段进度条(纯文本图示)和热度趋势对比,从 `state.md` + `events.jsonl` 读取历史数据。
|
||||
|
||||
### v1.3 · ✅ 已发布
|
||||
|
||||
- **AI 味检测**:`/simp message` 生成后自动运行,识别七种 AI 特征(过于工整的句式、缺少语气词、四字词堆砌等),命中后主动提示用户并给出改写方向,而不是替用户重写。
|
||||
- **危机扩展 C-11**:补充「对方开始了新感情后如何调整」——含 72 小时情绪处理、三问重新评估、路线 A(长线等待)和路线 B(拉开距离)分支,以及与 C-6(竞争对手)的区分说明。
|
||||
|
||||
### v1.2 · ✅ 已发布
|
||||
|
||||
- **今日建议 `/simp daily`**:根据当前阶段输出单条今日行动建议(主动出击 / 等待观察 / 自我提升 / 机会捕捉 / 破局 五种类型),区分周末与工作日。
|
||||
- **进度追踪 `/simp progress`**:阶段评估、热度分历史趋势、里程碑追踪、三条具体行动。
|
||||
- **放弃判断器 `/simp quit`**:帮用户区分真心与执念,输出四种结论(继续 / 调整策略 / 先暂停 / 认真考虑放下)。
|
||||
|
||||
### v1.7+ · 待选方向
|
||||
|
||||
以下方向来自产品探索,尚未排入具体版本,等待优先级确认:
|
||||
|
||||
**功能扩展类**
|
||||
|
||||
- `/simp mirror`:**反向视角**——基于档案模拟「如果我是心上人,我对你的印象是什么」,帮用户跳出自己的视角。
|
||||
- `/simp weekly`:**每周复盘**——本周高点/低点/下周策略,比 `/simp daily` 更长维度的回顾。
|
||||
- `/simp rehearse`:**对话练习模式**——基于心上人档案构建简单的对方性格模拟,让用户在正式表白前「彩排」一遍。借鉴 crush-skills 的人物模拟能力,但用途完全不同——不是为了「和心上人对话」,而是为了「练习如何面对心上人」。
|
||||
- `/simp date`:**约会策划**——根据心上人档案推荐约会场景、时机、话题,不走「找个电影院」的烂大街路线。
|
||||
- `/simp post`:**朋友圈内容建议**——今天发什么让 ta 注意到你,风格上怎么展示你最好的状态。
|
||||
- `/simp gift`:**礼物建议**——结合档案推荐有记忆点的礼物,而不是通用的节日礼物。
|
||||
- `/simp after`:**成功后模块**——在一起之后怎么维系热情、如何处理初期磨合,目前 v1 完全缺失这块。
|
||||
|
||||
**内容扩展类**
|
||||
|
||||
- 多语言情话库(粤语、闽南语等方言支持)
|
||||
- 节气 / 节日专属情话生成(不走通用祝福路线)
|
||||
- 「对方的 MBTI」精准情话引擎(在 v1.5 校正层基础上做深)
|
||||
|
||||
### v2.0 · 全局视角
|
||||
|
||||
v2 的核心问题是:**单纯追求一个人,是不是最好的策略?**
|
||||
|
||||
v2 计划加入「个人吸引力评估」模块——在帮用户追人之前,先帮用户评估自己哪些地方可以提升。最好的追求策略永远是让自己变得更好,而不只是说对话。
|
||||
|
||||
这个模块需要和 yourself-skill 生态打通,目前在设计阶段。
|
||||
|
||||
---
|
||||
|
||||
## 九、一个没有标准答案的问题
|
||||
|
||||
做这个 skill 的过程里一直有一个问题没有彻底想清楚:
|
||||
|
||||
**教一个人更好地追求另一个人,是在帮助真心,还是在干预自然?**
|
||||
|
||||
我们的立场是:帮助表达,不帮助欺骗。
|
||||
|
||||
一个人喜欢另一个人,但不知道怎么说出来,这是表达能力的问题,不是感情真不真的问题。simp-skill 试图解决的是前者。
|
||||
|
||||
但我们也承认:用语言影响另一个人的感情决策,本身就带有一定的干预性。我们能做的是让这种干预停留在「帮助真心被看见」这一侧,而不是越过「制造虚假感情」那条线。
|
||||
|
||||
这条线需要被持续审视。
|
||||
|
||||
---
|
||||
|
||||
*如果你对这个产品有想法,欢迎开 Issue 或 PR。*
|
||||
*如果这个 skill 帮你追到了心上人,也欢迎来告诉我。*
|
||||
|
||||
---
|
||||
|
||||
*simp-skill · 愿每一份真心都有回应*
|
||||
@@ -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 获取处理方案
|
||||
```
|
||||
@@ -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新感情的尊重。
|
||||
|
||||
---
|
||||
|
||||
### 一句话原则
|
||||
|
||||
对方有了新感情,不是你失败了。
|
||||
是两件事:**时机没到** 或者 **不是对的人**。
|
||||
这两个结论,你现在都还不知道是哪个。
|
||||
所以现在唯一要做的事,是先把自己过好。
|
||||
@@ -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 更新一下,建议会更准。"
|
||||
@@ -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` 补充信息
|
||||
@@ -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 都已知 | 自动附上兼容性分析 |
|
||||
@@ -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句 | 简洁,不要显得太慌 |
|
||||
@@ -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/`
|
||||
@@ -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%:对方主动性更强,是好信号,继续保持
|
||||
|
||||
---
|
||||
|
||||
## 运行规则
|
||||
|
||||
- 进度报告应该让用户看完知道"我今天该做什么",不能只给状态描述
|
||||
- 下一步行动必须具体,不能是"继续深化关系"这种没有操作性的话
|
||||
- 不美化现状,也不过度悲观——用数据说话
|
||||
- 若用户最近刚经历了重大事件(被拒/冷战/竞争对手出现),优先处理该事件,不走标准流程
|
||||
@@ -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}
|
||||
|
||||
{结论对应的输出文字}
|
||||
```
|
||||
@@ -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条}
|
||||
|
||||
💬 推荐今日消息:
|
||||
"{生成一条适合当前阶段的消息}"
|
||||
```
|
||||
@@ -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/陪伴感) |
|
||||
| 认识更早 | 制造强烈的新记忆,冲淡原有的印象 |
|
||||
| 对方明显在喜欢对手 | 重新评估,不要在明确无望的局面里耗尽自己 |
|
||||
|
||||
---
|
||||
|
||||
## 通用策略原则
|
||||
|
||||
### 「稳定」是最强吸引力
|
||||
不因对方的冷热而情绪波动,不因一次冷落就消失,不因一次热情就失去分寸。稳定的人让人觉得安全,安全感让人靠近。
|
||||
|
||||
### 「自己」是最好的筹码
|
||||
追求别人的同时,继续做好自己的事。有自己的目标、生活、有趣的事在做——这本身就是吸引力。不要把所有精力都放在追求这一件事上。
|
||||
|
||||
### 「时间」是朋友,也是敌人
|
||||
- 有些情感需要时间积累(不要急)
|
||||
- 有些窗口期是有限的(不要拖太久)
|
||||
- 关键是:读懂当前阶段,按阶段节奏推进
|
||||
|
||||
### 「真心」永远有效
|
||||
无论纯情模式还是策略模式,所有技巧的底层都是真心。如果你对这个人是真的在意,这份真心最终会让对方感受到。策略是加速器,不是替代品。
|
||||
@@ -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 增强可读性,但不过度
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,997 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
simp-skill · Chat Parser
|
||||
解析微信/QQ聊天记录,提取信号分析报告
|
||||
|
||||
支持格式:
|
||||
- 微信导出 TXT(WeChatMsg/留痕等工具)
|
||||
- 微信导出 HTML(WeChatMsg)
|
||||
- 微信导出 CSV(PyWxDump)
|
||||
- QQ 导出 TXT(QQ消息管理器)
|
||||
- QQ 导出 MHT/MHTML(QQ消息管理器)
|
||||
- 通用 JSON 格式
|
||||
|
||||
用法:
|
||||
python3 chat_parser.py <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:
|
||||
"""解析微信导出 TXT(WeChatMsg格式)"""
|
||||
messages = []
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
|
||||
# 匹配时间戳行
|
||||
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\n(.+?)\n(.*?)(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\n|\Z)'
|
||||
matches = re.findall(pattern, content, re.DOTALL)
|
||||
|
||||
for ts_str, sender, msg_content in matches:
|
||||
try:
|
||||
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
sender = sender.strip()
|
||||
msg_content = msg_content.strip()
|
||||
|
||||
if not msg_content or msg_content in ("[图片]", "[语音]", "[视频]", "[文件]"):
|
||||
msg_type = "image" if "[图片]" in msg_content else (
|
||||
"voice" if "[语音]" in msg_content else "media"
|
||||
)
|
||||
if not msg_content:
|
||||
continue
|
||||
messages.append(Message(ts, sender, msg_content, msg_type))
|
||||
else:
|
||||
messages.append(Message(ts, sender, msg_content))
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def parse_qq_txt(filepath: str, target_name: str, user_name: str) -> list:
|
||||
"""解析QQ导出 TXT"""
|
||||
messages = []
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
current_ts = None
|
||||
current_sender = None
|
||||
current_content = []
|
||||
|
||||
header_pattern = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?)(?:\(\d+\))?$')
|
||||
|
||||
def flush():
|
||||
if current_ts and current_sender and current_content:
|
||||
content = "\n".join(current_content).strip()
|
||||
if content:
|
||||
messages.append(Message(current_ts, current_sender, content))
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip()
|
||||
m = header_pattern.match(line)
|
||||
if m:
|
||||
flush()
|
||||
try:
|
||||
current_ts = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
current_ts = None
|
||||
current_sender = m.group(2).strip()
|
||||
current_content = []
|
||||
elif current_ts is not None:
|
||||
current_content.append(line)
|
||||
|
||||
flush()
|
||||
return messages
|
||||
|
||||
|
||||
def parse_qq_mht(filepath: str, target_name: str, user_name: str) -> list:
|
||||
"""解析QQ导出 MHT/MHTML"""
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
raw = f.read()
|
||||
|
||||
# 去除HTML标签
|
||||
clean = re.sub(r'<[^>]+>', ' ', raw)
|
||||
clean = html.unescape(clean)
|
||||
clean = re.sub(r'\s+', ' ', clean)
|
||||
|
||||
# 同QQ TXT 解析
|
||||
messages = []
|
||||
pattern = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?)(?:\(\d+\))? (.+?)(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\Z)')
|
||||
for m in pattern.finditer(clean):
|
||||
try:
|
||||
ts = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
|
||||
sender = m.group(2).strip()
|
||||
content = m.group(3).strip()
|
||||
if content:
|
||||
messages.append(Message(ts, sender, content))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def parse_wechat_html(filepath: str, target_name: str, user_name: str) -> list:
|
||||
"""解析微信导出 HTML(WeChatMsg)"""
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
raw = f.read()
|
||||
|
||||
messages = []
|
||||
# 匹配消息块
|
||||
msg_pattern = re.compile(
|
||||
r'<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
@@ -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.md;frontmatter_updates 合并 YAML 字段,sections 合并正文章节"""
|
||||
crush_dir = base_dir / slug
|
||||
if not crush_dir.exists():
|
||||
raise FileNotFoundError(f"档案不存在: {slug}")
|
||||
|
||||
state_path = crush_dir / "state.md"
|
||||
existing_fm: dict[str, Any] = {}
|
||||
existing_sections: dict[str, str] = {}
|
||||
|
||||
if state_path.exists():
|
||||
existing_fm, body = _parse_frontmatter(state_path.read_text(encoding="utf-8"))
|
||||
existing_sections = _parse_body_sections(body)
|
||||
|
||||
new_fm = {**existing_fm, **frontmatter_updates, "last_updated": datetime.now().isoformat()}
|
||||
merged_sections = {**existing_sections, **sections}
|
||||
|
||||
body_parts: list[str] = ["\n"]
|
||||
for header in _SECTION_ORDER:
|
||||
content = merged_sections.get(header, "[待生成]")
|
||||
body_parts.append(f"\n{header}\n\n{content}\n")
|
||||
|
||||
state_path.write_text(_render_frontmatter(new_fm, "".join(body_parts)), encoding="utf-8")
|
||||
|
||||
meta_path = crush_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
sync_keys = {"current_stage", "signal_score"}
|
||||
sync_updates = {k: v for k, v in frontmatter_updates.items() if k in sync_keys}
|
||||
if sync_updates:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
updated_meta = {**meta, **sync_updates, "updated_at": datetime.now().isoformat()}
|
||||
meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def take_snapshot(
|
||||
slug: str,
|
||||
base_dir: Path = DEFAULT_BASE_DIR,
|
||||
) -> Path:
|
||||
"""将 meta.json + state.md frontmatter 合并写入 snapshots/YYYY-MM-DD.json"""
|
||||
crush_dir = base_dir / slug
|
||||
snapshots_dir = crush_dir / "snapshots"
|
||||
snapshots_dir.mkdir(exist_ok=True)
|
||||
|
||||
snapshot: dict[str, Any] = {
|
||||
"slug": slug,
|
||||
"snapshot_date": date.today().isoformat(),
|
||||
}
|
||||
|
||||
meta_path = crush_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
snapshot["meta"] = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
|
||||
state_path = crush_dir / "state.md"
|
||||
if state_path.exists():
|
||||
state_fm, _ = _parse_frontmatter(state_path.read_text(encoding="utf-8"))
|
||||
snapshot["state"] = state_fm
|
||||
|
||||
snapshot_path = snapshots_dir / f"{date.today().isoformat()}.json"
|
||||
snapshot_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
if meta_path.exists():
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
updated_meta = {**meta, "last_snapshot": date.today().isoformat()}
|
||||
meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
logger.info("✅ 快照已保存:%s", snapshot_path)
|
||||
return snapshot_path
|
||||
|
||||
|
||||
def rebuild_state_from_events(
|
||||
slug: str,
|
||||
base_dir: Path = DEFAULT_BASE_DIR,
|
||||
) -> dict[str, Any]:
|
||||
"""从 events.jsonl 重放所有事件,返回推断的当前状态"""
|
||||
events = get_recent_events(slug, n=100_000, base_dir=base_dir)
|
||||
|
||||
state: dict[str, Any] = {
|
||||
"current_stage": "未知",
|
||||
"signal_score": None,
|
||||
"last_signal_score": None,
|
||||
"score_trend": "stable",
|
||||
"recommended_mode": "hybrid",
|
||||
"milestones_done": 0,
|
||||
}
|
||||
|
||||
for event in events:
|
||||
etype = event.get("type")
|
||||
data = event.get("data", {})
|
||||
|
||||
if etype == "stage_changed":
|
||||
state = {**state, "current_stage": data.get("to", state["current_stage"])}
|
||||
elif etype in ("analysis_done", "progress_evaluated"):
|
||||
new_score = data.get("score")
|
||||
prev_score = state.get("signal_score")
|
||||
trend = "stable"
|
||||
if new_score is not None and prev_score is not None:
|
||||
diff = new_score - prev_score
|
||||
trend = "up" if diff >= 2 else ("down" if diff <= -2 else "stable")
|
||||
state = {
|
||||
**state,
|
||||
"last_signal_score": prev_score,
|
||||
"signal_score": new_score if new_score is not None else state["signal_score"],
|
||||
"score_trend": trend,
|
||||
"current_stage": data.get("stage", state["current_stage"]),
|
||||
}
|
||||
if etype == "progress_evaluated" and "milestones_done" in data:
|
||||
state = {**state, "milestones_done": data["milestones_done"]}
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def _format_timeline(events: list[dict[str, Any]]) -> str:
|
||||
"""格式化事件时间线为可读字符串"""
|
||||
if not events:
|
||||
return "暂无事件记录"
|
||||
|
||||
_DIRECTION_ICON = {"green": "🟢", "red": "🔴", "neutral": "🟡"}
|
||||
|
||||
lines = [f"📅 事件时间线(共 {len(events)} 条)", ""]
|
||||
for event in events:
|
||||
ts = event.get("ts", "")[:16].replace("T", " ")
|
||||
label = _TIMELINE_LABELS.get(event.get("type", ""), event.get("type", ""))
|
||||
data = event.get("data", {})
|
||||
etype = event.get("type")
|
||||
|
||||
if etype == "stage_changed":
|
||||
desc = f"{data.get('from', '?')} → {data.get('to', '?')}"
|
||||
elif etype in ("analysis_done", "progress_evaluated"):
|
||||
desc = f"评分 {data.get('score', '?')}/25 阶段:{data.get('stage', '?')}"
|
||||
elif etype == "signal_recorded":
|
||||
icon = _DIRECTION_ICON.get(data.get("direction", ""), "")
|
||||
desc = f"{icon} {data.get('content', '')}"
|
||||
elif etype == "crisis_handled":
|
||||
desc = data.get("crisis_type", "")
|
||||
else:
|
||||
desc = ""
|
||||
|
||||
lines.append(f" {ts} {label} {desc}".rstrip())
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="simp-skill · 记忆系统")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("append", help="追加事件")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("event_type")
|
||||
p.add_argument("data", help="JSON 字符串")
|
||||
p.add_argument("--base-dir", default="crushes")
|
||||
|
||||
p = sub.add_parser("events", help="查看最近事件")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--last", type=int, default=5)
|
||||
p.add_argument("--type", dest="event_type", help="按事件类型过滤")
|
||||
p.add_argument("--base-dir", default="crushes")
|
||||
|
||||
p = sub.add_parser("context", help="读取档案上下文")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--with-strategy", action="store_true")
|
||||
p.add_argument("--base-dir", default="crushes")
|
||||
|
||||
p = sub.add_parser("snapshot", help="生成日快照")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--base-dir", default="crushes")
|
||||
|
||||
p = sub.add_parser("timeline", help="查看事件时间线")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--base-dir", default="crushes")
|
||||
|
||||
p = sub.add_parser("rebuild", help="从事件流重建状态")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--base-dir", default="crushes")
|
||||
|
||||
args = parser.parse_args()
|
||||
base_dir = Path(args.base_dir)
|
||||
|
||||
if args.cmd == "append":
|
||||
try:
|
||||
data = json.loads(args.data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("❌ data 必须是合法 JSON:%s", e)
|
||||
return
|
||||
append_event(args.slug, args.event_type, data, base_dir)
|
||||
logger.info("✅ 事件已记录:%s", args.event_type)
|
||||
|
||||
elif args.cmd == "events":
|
||||
types = [args.event_type] if args.event_type else None
|
||||
events = get_recent_events(args.slug, args.last, types, base_dir)
|
||||
for event in events:
|
||||
print(json.dumps(event, ensure_ascii=False))
|
||||
if not events:
|
||||
logger.info("暂无事件记录")
|
||||
|
||||
elif args.cmd == "context":
|
||||
print(load_context(args.slug, args.with_strategy, base_dir))
|
||||
|
||||
elif args.cmd == "snapshot":
|
||||
take_snapshot(args.slug, base_dir)
|
||||
|
||||
elif args.cmd == "timeline":
|
||||
all_events = get_recent_events(args.slug, n=100_000, base_dir=base_dir)
|
||||
print(_format_timeline(all_events))
|
||||
|
||||
elif args.cmd == "rebuild":
|
||||
state = rebuild_state_from_events(args.slug, base_dir)
|
||||
print(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user