Jelajahi Sumber

feat: restore graduation project from PR #828

Original PR: https://github.com/datawhalechina/hello-agents/pull/828
Original commits:
d8b78e64f573c7414e83cc3432def258c6c2ef9d
1f62068162425038ee1d7bf8538dde168171b204
aedf78144f15d6db2d93654743615528905b98b8
748e1f26da4247bb946f2f2757fab71210b69fb1
336ab8216fd9ff8b8a7a8e9e38ba2e04deaee095
c753a25f722639b771f3affd1ed619c10e8c5ff8

CoralGarden52 1 bulan lalu
induk
melakukan
c56d505d99
54 mengubah file dengan 7055 tambahan dan 0 penghapusan
  1. 10 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/.env.example
  2. 25 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/.gitignore
  3. 215 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/README.md
  4. 66 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_01.csv
  5. 18 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_02.csv
  6. 16 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_03.csv
  7. 15 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_04.csv
  8. 17 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_05.csv
  9. 200 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/main.py
  10. 0 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/.gitkeep
  11. 738 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/money_mirror_report.json
  12. 196 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/money_mirror_report.md
  13. 764 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_02_money_mirror_report.json
  14. 112 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_02_money_mirror_report.md
  15. 779 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_03_money_mirror_report.json
  16. 145 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_03_money_mirror_report.md
  17. 9 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/requirements.txt
  18. 5 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/__init__.py
  19. 5 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/__init__.py
  20. 134 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/conversation_agent.py
  21. 201 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/coordinator.py
  22. 36 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/goal_agent.py
  23. 46 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/pattern_agent.py
  24. 377 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/persona_agent.py
  25. 415 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/quest_agent.py
  26. 131 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/reflection_agent.py
  27. 481 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/runtime.py
  28. 41 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/transaction_agent.py
  29. 122 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/config/personas.json
  30. 5 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/memory/__init__.py
  31. 161 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/memory/sqlite_memory.py
  32. 136 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/models.py
  33. 21 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/__init__.py
  34. 61 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/anomaly_detection.py
  35. 52 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/budget_calculator.py
  36. 160 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/csv_import.py
  37. 58 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/goal_projection.py
  38. 126 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/hello_agents_registry.py
  39. 73 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/quest_progress.py
  40. 89 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/statistics.py
  41. 68 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/subscription_detector.py
  42. 53 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/transaction_category.py
  43. 0 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/__init__.py
  44. 88 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/fakes.py
  45. 53 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_conversation_agent.py
  46. 45 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_coordinator.py
  47. 32 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_csv_import.py
  48. 74 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_dynamic_quest_orchestration.py
  49. 15 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_memory_and_category.py
  50. 156 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_persona_agent.py
  51. 10 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_quest_progress.py
  52. 82 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_sample_files.py
  53. 54 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_terminal_quests.py
  54. 64 0
      Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_tools.py

+ 10 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/.env.example

@@ -0,0 +1,10 @@
+LLM_BASE_URL=https://api.deepseek.com
+LLM_MODEL_ID=deepseek-v4-flash
+LLM_API_KEY=your_deepseek_api_key_here
+LLM_TIMEOUT=90
+LLM_MAX_TOKENS=16384
+LLM_CONTEXT_MAX_TOKENS=100000
+LLM_TEMPERATURE=0.2
+
+MONEYMIRROR_USER_ID=local_user
+LOG_LEVEL=INFO

+ 25 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/.gitignore

@@ -0,0 +1,25 @@
+# Secrets and local state
+.env
+outputs/*.db
+outputs/*.sqlite
+outputs/*.sqlite3
+
+# Python
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.coverage
+.venv/
+
+
+# The parent hello-agents repository has broad patterns for its own tests and
+# memory directories; these are intentional project source files here.
+!src/memory/
+!src/memory/**
+
+
+# Re-included project directories must still exclude generated bytecode.
+src/memory/__pycache__/
+tests/__pycache__/
+tool-output
+docs

+ 215 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/README.md

@@ -0,0 +1,215 @@
+# MoneyMirrorAgent —— 智能理财助手
+
+> 将账单转化为可理解的消费镜像,并通过多智能体对话帮助用户制定下一步行动。
+
+## 📝 项目简介
+
+MoneyMirrorAgent 面向日常个人账单场景,接收用户提供的 CSV 交易记录,完成账单标准化、消费分类、行为分析、目标规划和游戏化行动编排。
+
+项目将确定性计算与大模型能力分开:Python 工具负责金额、比例、趋势、异常、预算、目标投影和任务进度;Hello-Agents 负责调用智能体进行分类补全、行为解释、消费人格表达、分步引导、Money Quest 编排、月度 Reflection 和 Markdown 月报生成。
+
+适用场景包括:
+
+- 想了解自己消费结构和行为模式的个人用户;
+- 希望围绕预算、储蓄或某一消费类别制定行动计划的用户;
+
+## ✨ 核心功能
+
+- [x] **账单导入与标准化**:支持常见中英文列名、日期格式、收入/支出字段,以及 UTF-8 和 GB18030 编码。
+- [x] **智能消费分类**:优先查询 SQLite Memory 和规则,低置信度交易交给 LLM 判断;用户纠正后会影响后续同商户分类。
+- [x] **消费行为分析**:统计收入、支出、结余、储蓄率、类别占比、日/周/月趋势、深夜消费、周末消费、发薪后消费和高频小额消费。
+- [x] **异常与订阅检测**:使用 IQR、Z-score、历史水平和周期性扣费规则发现行为信号,并由 LLM 生成解释。
+- [x] **消费人格**:根据真实消费特征向量和配置化人格原型生成有证据支持的个性化表达。
+- [x] **目标与预算规划**:根据现金流、历史消费和目标截止日期计算目标可行性、月度储蓄额度和动态预算。
+- [x] **Money Quest**:LLM 根据真实行为信号编排个性化任务,Python 校验目标、金额、进度、EXP 和完成状态。
+- [x] **等级与成就**:记录任务完成情况、连续完成天数、经验值和阶段性成就。
+- [x] **长期 Memory**:使用 SQLite 保存分类修正、目标、预算、Quest、成就、历史快照、Reflection 和引导对话。
+- [x] **月度 Reflection**:比较计划、实际消费、预算、目标进度和 Quest 完成情况,生成下一周期策略。
+- [x] **分步 AI 引导与 Markdown 月报**:终端中流式输出观察、建议和追问,用户结束引导后生成 Markdown 月报。
+
+## 🛠️ 技术栈
+
+- **Hello-Agents**:`ToolRegistry`、`ReActAgent`、`PlanSolveAgent`、`ReflectionAgent`、`SimpleAgent`、`ContextBuilder`
+- **大模型**:OpenAI 兼容 API,可配置 DeepSeek 等模型服务
+- **数据处理**:Python 标准库、CSV、日期处理和统计计算
+- **长期存储**:SQLite
+- **交互方式**:Python CLI 和流式终端对话
+
+### Agent 架构
+
+```text
+CSV 账单
+   ↓
+MoneyMirrorCoordinator
+   ├── TransactionAgent   ReActAgent:分类、规则与 Memory
+   ├── PatternAgent       PlanSolveAgent:统计、异常与订阅分析
+   ├── PersonaAgent       特征提取、原型评分与 LLM 表达
+   ├── GoalAgent          目标投影与预算规划
+   ├── QuestAgent         真实信号与 LLM 任务编排
+   ├── ReflectionAgent    计划、实际与下一周期策略
+   └── ConversationAgent  分步引导与 Markdown 月报
+```
+
+项目中的确定性工具包括:
+
+- `CSVImportTool`
+- `TransactionCategoryTool`
+- `StatisticsTool`
+- `AnomalyDetectionTool`
+- `BudgetCalculatorTool`
+- `GoalProjectionTool`
+- `SubscriptionDetectorTool`
+- `QuestProgressTool`
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- 可访问的 OpenAI 兼容模型服务
+- Linux、macOS 或 Windows
+
+### 安装依赖
+
+```bash
+cd Co-creation-projects/CoralGarden52-MoneyMirrorAgent
+python -m venv .venv
+source .venv/bin/activate       # Windows: .venv\\Scripts\\activate
+pip install -r requirements.txt
+```
+
+### 配置 API 密钥
+
+复制配置文件并填写模型服务信息:
+
+```bash
+cp .env.example .env
+```
+
+`.env` 中的主要配置项如下:
+
+```dotenv
+LLM_BASE_URL=https://api.deepseek.com
+LLM_MODEL_ID=deepseek-v4-flash
+LLM_API_KEY=your_api_key_here
+LLM_MAX_TOKENS=16384
+LLM_CONTEXT_MAX_TOKENS=100000
+LLM_TEMPERATURE=0.2
+```
+
+### 运行项目
+
+使用 `--csv` 指定账单路径,项目不会依赖固定文件名,用户可以替换为自己的 CSV 文件:
+
+```bash
+# 完整分析:导入、分类、统计、目标、预算、Quest、Reflection 和 Markdown 月报
+python main.py --csv data/sample_01.csv --reset
+
+# 使用自己的账单
+python main.py --csv /path/to/your_transactions.csv --reset
+
+# 进入 CLI 分步对话
+python main.py --interactive --csv data/sample_01.csv
+```
+
+运行结果写入 `outputs/`:
+
+```text
+outputs/<输入文件名>_money_mirror_report.json
+outputs/<输入文件名>_money_mirror_report.md
+outputs/moneymirror.db
+```
+
+JSON 文件保存已验证的事实快照,Markdown 文件由大模型根据账单事实、Memory 和用户对话生成。
+
+## 📖 使用示例
+
+### CSV 输入
+
+```csv
+日期,商户,金额,收支,备注
+2026-07-05 09:00,公司工资,7600,收入,七月工资到账
+2026-07-06 22:42,美团外卖-火锅,76,支出,深夜外卖
+2026-07-07 08:20,地铁,6,支出,通勤
+```
+
+程序也支持以下常见字段名:`date`、`日期`、`交易时间`、`merchant`、`商户`、`amount`、`金额`、`direction`、`收支`、`type`,以及独立的收入和支出列。
+
+### 创建目标和修正分类
+
+```bash
+python main.py --csv data/sample_01.csv --reset \
+  --goal "三个月旅行基金|travel|10000|2800|2026-10-31"
+
+python main.py --csv /path/to/your_transactions.csv \
+  --correct "星巴克:餐饮"
+```
+
+### CLI 分步引导
+
+启动终端分步对话:
+
+```bash
+python main.py --interactive --csv data/sample_01.csv
+```
+
+也可以替换为自己的账单路径:
+
+```bash
+python main.py --interactive --csv /path/to/your_transactions.csv
+```
+
+```text
+MoneyMirrorAgent> 这份账单中观察到深夜餐饮支出较集中。
+MoneyMirrorAgent> 你想先从减少深夜外卖、控制周末消费,还是检查连续扣费开始?
+你> 我想先控制深夜外卖
+MoneyMirrorAgent> 我们先设定一个本周可完成的小目标。最容易触发深夜点单的时间通常是什么时候?
+你> 晚上加班以后
+你> /done
+```
+
+输入 `/done`、`/quit` 或 `退出` 后,系统会综合本轮对话和已验证账单数据生成 Markdown 月报。输入 `/quests` 可以查看当前任务,输入 `/complete <quest_id>` 可以记录人工确认的任务完成情况。
+
+## 🎯 项目亮点
+
+- **数据与推理分工明确**:金额和统计由 Python 完成,大模型负责理解、解释、规划和表达。
+- **真实信号驱动行动**:消费人格和 Money Quest 来自账单中的实际行为特征。
+- **对话逐步推进**:MoneyMirrorAgent 通过观察、建议和追问引导用户,完成交流后再生成月报。
+
+## 📊 性能评估
+
+当前版本已完成以下本地验证:
+
+- 自动化测试:`28 passed`
+- 编译检查:`python -m compileall -q src main.py tests` 通过
+- CSV 导入、分类、Memory、统计、异常检测、预算、目标、Quest、Reflection 和对话流程均有测试覆盖
+- 已使用配置的 OpenAI 兼容模型完成完整 CLI 分析,并生成 JSON 事实快照和 Markdown 月报
+
+## 🔮 未来计划
+
+- [ ] 扩展更多银行、支付平台和记账软件的 CSV 字段映射
+- [ ] 支持跨月账单合并和更长周期的趋势比较
+- [ ] 增加更多可配置的成就和 Quest 进度事件
+- [ ] 增加脱敏导出和本地报告归档管理
+- [ ] 补充不同账单格式下的端到端回归样例
+
+## 🤝 贡献指南
+
+欢迎通过 Issue 或 Pull Request 提出改进建议。提交代码时请:
+
+1. 保持确定性计算与大模型推理职责分离;
+2. 为新增工具、Agent 或数据格式补充测试;
+3. 不提交 `.env`、API Key、SQLite 数据库和运行生成文件;
+4. 同步更新 README。
+
+## 📄 许可证
+
+本项目采用MIT许可证。
+
+## 👤 作者
+
+- GitHub: [@CoralGarden52](https://github.com/CoralGarden52)
+
+## 🙏 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目提供的教程、框架与共创平台。

+ 66 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_01.csv

@@ -0,0 +1,66 @@
+日期,商户,金额,收支,备注
+2026-06-05 09:00,公司工资,7600,收入,六月工资到账
+2026-06-05 10:30,房东-六月房租,2200,支出,固定住房
+2026-06-06 08:20,地铁,4,支出,通勤
+2026-06-06 12:20,园区食堂,22,支出,午餐
+2026-06-06 22:48,饿了么-烧烤,58,支出,深夜外卖
+2026-06-07 15:30,星巴克,36,支出,咖啡
+2026-06-07 20:10,万达影院,68,支出,周末电影
+2026-06-08 08:15,地铁,4,支出,通勤
+2026-06-08 12:10,便利店,18,支出,零食
+2026-06-09 12:05,园区食堂,25,支出,午餐
+2026-06-10 21:45,淘宝-收纳,89,支出,生活购物
+2026-06-12 09:10,腾讯视频会员,25,支出,自动续费
+2026-06-12 12:25,园区食堂,23,支出,午餐
+2026-06-13 11:15,滴滴出行,32,支出,周末出行
+2026-06-13 16:30,奈雪的茶,29,支出,奶茶
+2026-06-14 19:00,健身房月卡,199,支出,运动
+2026-06-15 09:30,网易云音乐,18,支出,自动续费
+2026-06-15 12:10,园区食堂,24,支出,午餐
+2026-06-16 08:20,地铁,4,支出,通勤
+2026-06-16 22:55,美团外卖-炸鸡,46,支出,深夜外卖
+2026-06-18 12:10,园区食堂,26,支出,午餐
+2026-06-19 18:30,京东-耳机,399,支出,数码购物
+2026-06-20 14:00,线下书店,86,支出,技术书籍
+2026-06-20 21:00,Livehouse门票,180,支出,周末娱乐
+2026-06-22 08:20,地铁,4,支出,通勤
+2026-06-22 12:10,园区食堂,22,支出,午餐
+2026-06-24 22:35,饿了么-奶茶,31,支出,深夜外卖
+2026-06-25 12:20,园区食堂,24,支出,午餐
+2026-06-27 15:00,星巴克,34,支出,咖啡
+2026-06-28 18:20,超市,125,支出,日用品
+2026-07-05 09:00,公司工资,7600,收入,七月工资到账
+2026-07-05 10:20,房东-七月房租,2200,支出,固定住房
+2026-07-05 13:10,京东-电脑配件商城,1288,支出,显示器支架和配件
+2026-07-05 21:20,万达影院,75,支出,周末电影
+2026-07-06 08:20,地铁,4,支出,通勤
+2026-07-06 12:15,园区食堂,25,支出,午餐
+2026-07-06 22:42,美团外卖-火锅,76,支出,深夜外卖
+2026-07-07 08:20,地铁,4,支出,通勤
+2026-07-07 15:00,星巴克,38,支出,咖啡
+2026-07-08 12:15,园区食堂,23,支出,午餐
+2026-07-09 19:10,淘宝-夏装,266,支出,服饰购物
+2026-07-10 12:10,园区食堂,26,支出,午餐
+2026-07-12 09:10,腾讯视频会员,25,支出,自动续费
+2026-07-12 14:30,滴滴出行,35,支出,周末出行
+2026-07-12 20:00,演唱会门票,880,支出,周末娱乐
+2026-07-13 16:20,奈雪的茶,31,支出,奶茶
+2026-07-14 08:20,地铁,4,支出,通勤
+2026-07-14 12:15,园区食堂,24,支出,午餐
+2026-07-15 09:30,网易云音乐,18,支出,自动续费
+2026-07-15 22:50,饿了么-烧烤,61,支出,深夜外卖
+2026-07-16 12:10,园区食堂,27,支出,午餐
+2026-07-17 18:40,健身房月卡,199,支出,运动
+2026-07-18 11:00,线下书店,128,支出,设计课程书籍
+2026-07-18 21:30,剧本杀,168,支出,周末娱乐
+2026-07-19 14:00,星巴克,36,支出,咖啡
+2026-07-20 08:20,地铁,4,支出,通勤
+2026-07-20 12:12,便利店,22,支出,零食
+2026-07-21 22:36,美团外卖-炸鸡,56,支出,深夜外卖
+2026-07-22 12:10,园区食堂,25,支出,午餐
+2026-07-24 19:20,Steam游戏平台,118,支出,游戏
+2026-07-25 12:10,园区食堂,23,支出,午餐
+2026-07-26 18:00,超市,146,支出,日用品
+2026-07-27 22:58,饿了么-奶茶,35,支出,深夜外卖
+2026-07-29 12:15,园区食堂,24,支出,午餐
+2026-07-30 18:10,京东-耳机,460,支出,数码购物

+ 18 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_02.csv

@@ -0,0 +1,18 @@
+日期,商户,金额,收支,备注
+2026-06-05 09:00,公司工资,7200,收入,六月工资到账
+2026-06-05 10:00,房东-六月房租,2200,支出,固定住房
+2026-06-08 12:30,健康餐厅,85,支出,午餐
+2026-06-11 12:30,健康餐厅,82,支出,午餐
+2026-06-15 08:20,地铁月票,120,支出,通勤
+2026-06-17 19:00,健身房月卡,199,支出,运动
+2026-06-20 14:00,技术书店,260,支出,学习资料
+2026-06-24 12:30,健康餐厅,86,支出,午餐
+2026-07-06 09:00,公司工资,7200,收入,七月工资到账
+2026-07-06 10:00,房东-七月房租,2200,支出,固定住房
+2026-07-08 12:30,健康餐厅,88,支出,午餐
+2026-07-11 12:30,健康餐厅,86,支出,午餐
+2026-07-14 08:20,地铁月票,120,支出,通勤
+2026-07-17 19:00,健身房月卡,199,支出,运动
+2026-07-19 14:00,设计课程平台,320,支出,在线课程
+2026-07-22 12:30,健康餐厅,90,支出,午餐
+2026-07-26 12:30,健康餐厅,87,支出,午餐

+ 16 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_03.csv

@@ -0,0 +1,16 @@
+日期,商户,金额,收支,备注
+2026-06-08 09:00,公司工资,6800,收入,六月工资到账
+2026-06-08 10:00,房东-六月房租,2100,支出,固定住房
+2026-06-13 15:00,万达影院,75,支出,周末电影
+2026-06-14 19:00,城市餐厅,180,支出,周末聚餐
+2026-06-20 16:00,线下书店,90,支出,周末阅读
+2026-06-22 08:20,地铁月票,120,支出,通勤
+2026-07-06 09:00,公司工资,6800,收入,七月工资到账
+2026-07-06 10:00,房东-七月房租,2100,支出,固定住房
+2026-07-11 14:30,独立咖啡馆,78,支出,周末咖啡
+2026-07-11 20:00,音乐节门票,360,支出,周末娱乐
+2026-07-12 13:00,城市餐厅,260,支出,周末聚餐
+2026-07-12 17:00,文创商场,420,支出,周末购物
+2026-07-18 15:00,万达影院,85,支出,周末电影
+2026-07-19 19:00,剧本杀,168,支出,周末娱乐
+2026-07-21 08:20,地铁月票,120,支出,通勤

+ 15 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_04.csv

@@ -0,0 +1,15 @@
+日期,商户,金额,收支,备注
+2026-06-08 09:00,公司工资,7600,收入,六月工资到账
+2026-06-08 10:00,房东-六月房租,2200,支出,固定住房
+2026-06-10 19:00,京东-工作设备,980,支出,工作配件
+2026-06-11 18:30,天猫-夏装,360,支出,服饰购物
+2026-06-12 12:30,园区餐厅,72,支出,午餐
+2026-06-18 08:20,地铁月票,120,支出,通勤
+2026-07-06 09:00,公司工资,7600,收入,七月工资到账
+2026-07-06 10:00,房东-七月房租,2200,支出,固定住房
+2026-07-06 19:20,京东-显示器支架,1080,支出,数码购物
+2026-07-07 18:40,天猫-夏装,480,支出,服饰购物
+2026-07-08 20:10,淘宝-收纳用品,260,支出,购物冲动
+2026-07-10 12:30,园区餐厅,75,支出,午餐
+2026-07-16 08:20,地铁月票,120,支出,通勤
+2026-07-23 19:00,健身房月卡,199,支出,运动

+ 17 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/data/sample_05.csv

@@ -0,0 +1,17 @@
+日期,商户,金额,收支,备注
+2026-05-08 09:00,公司工资,6500,收入,五月工资到账
+2026-05-08 10:00,房东-五月房租,2000,支出,固定住房
+2026-05-12 09:10,腾讯视频会员,25,支出,自动续费
+2026-05-15 09:30,网易云音乐,18,支出,自动续费
+2026-05-20 12:30,园区餐厅,78,支出,午餐
+2026-06-08 09:00,公司工资,6500,收入,六月工资到账
+2026-06-08 10:00,房东-六月房租,2000,支出,固定住房
+2026-06-12 09:10,腾讯视频会员,25,支出,自动续费
+2026-06-15 09:30,网易云音乐,18,支出,自动续费
+2026-06-20 12:30,园区餐厅,80,支出,午餐
+2026-07-06 09:00,公司工资,6500,收入,七月工资到账
+2026-07-06 10:00,房东-七月房租,2000,支出,固定住房
+2026-07-12 09:10,腾讯视频会员,25,支出,自动续费
+2026-07-15 09:30,网易云音乐,18,支出,自动续费
+2026-07-21 12:30,园区餐厅,82,支出,午餐
+2026-07-23 08:20,地铁月票,120,支出,通勤

+ 200 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/main.py

@@ -0,0 +1,200 @@
+"""CLI entry point for MoneyMirrorAgent."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import logging
+import os
+import sys
+from datetime import date
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+from src.agents.coordinator import MoneyMirrorCoordinator
+from src.agents.runtime import LLMCallError, RuntimeConfigurationError
+from src.models import Goal
+
+ROOT = Path(__file__).resolve().parent
+
+
+def _parse_goal(specification: str) -> Goal:
+    """Parse a repeatable CLI financial goal without inventing user data.
+
+    Format: title|type|target_amount|current_amount|deadline
+    For category_limit, append: |category|monthly_limit
+    """
+    fields = [field.strip() for field in specification.split("|")]
+    if len(fields) not in {5, 7}:
+        raise ValueError(
+            "格式为 标题|类型|目标金额|当前金额|截止日期;类别限额目标追加 |类别|月限额"
+        )
+    title, goal_type, target_raw, current_raw, deadline = fields[:5]
+    if not title:
+        raise ValueError("目标标题不能为空")
+    if goal_type not in {"savings", "travel", "category_limit"}:
+        raise ValueError("目标类型必须是 savings、travel 或 category_limit")
+    try:
+        target_amount = float(target_raw)
+        current_amount = float(current_raw)
+        date.fromisoformat(deadline)
+    except ValueError as exc:
+        raise ValueError("目标金额必须是数字,截止日期必须为 YYYY-MM-DD") from exc
+    if target_amount <= 0 or current_amount < 0:
+        raise ValueError("目标金额必须大于 0,当前金额不能小于 0")
+
+    category: str | None = None
+    monthly_limit: float | None = None
+    if goal_type == "category_limit":
+        if len(fields) != 7 or not fields[5]:
+            raise ValueError("category_limit 需要追加类别和月限额")
+        category = fields[5]
+        try:
+            monthly_limit = float(fields[6])
+        except ValueError as exc:
+            raise ValueError("类别月限额必须是数字") from exc
+        if monthly_limit <= 0:
+            raise ValueError("类别月限额必须大于 0")
+    elif len(fields) != 5:
+        raise ValueError("只有 category_limit 可以追加类别和月限额")
+
+    digest = hashlib.sha256(specification.encode("utf-8")).hexdigest()[:12]
+    return Goal(
+        goal_id=f"cli_{digest}",
+        title=title,
+        goal_type=goal_type,
+        target_amount=target_amount,
+        current_amount=current_amount,
+        deadline=deadline,
+        category=category,
+        monthly_limit=monthly_limit,
+    )
+
+def parser() -> argparse.ArgumentParser:
+    command = argparse.ArgumentParser(description="MoneyMirrorAgent: 智能理财助手")
+    command.add_argument("--interactive", action="store_true", help="分析账单后进入 LLM 引导对话,输入 /done 后生成最终 Markdown")
+    command.add_argument("--csv", type=Path, required=True, metavar="账单CSV", help="要分析的 CSV 账单路径(必填)")
+    command.add_argument("--month", help="指定分析月份,例如 2026-07")
+    command.add_argument("--db", type=Path, default=ROOT / "outputs" / "moneymirror.db", help="SQLite Memory 路径")
+    command.add_argument("--output-dir", type=Path, default=ROOT / "outputs", help="报告输出目录")
+    command.add_argument("--reset", action="store_true", help="删除现有 SQLite Memory 后运行")
+    command.add_argument("--correct", metavar="商户:类别", action="append", default=[], help="写入商户分类纠正,可重复指定")
+    command.add_argument(
+        "--goal",
+        action="append",
+        default=[],
+        metavar="标题|类型|目标金额|当前金额|截止日期[|类别|月限额]",
+        help="添加财务目标;可重复。类型为 savings、travel 或 category_limit",
+    )
+    return command
+
+
+def _print_quest_board(report) -> None:
+    """Show the RPG task board in terminals without a graphical UI."""
+    print("\n🎮 当前 Money Quest:")
+    for quest in report.quests:
+        progress = f"{quest.progress:g}/{quest.target:g}{quest.unit}"
+        status = "✅ 已完成" if quest.status == "completed" else "🔄 进行中"
+        print(f"- [{quest.quest_id}] {quest.title} · {progress} · {status} · +{quest.exp_reward} EXP")
+        print(f"  {quest.description}")
+        print(f"  证据:{quest.evidence}")
+
+
+def _interactive(coordinator: MoneyMirrorCoordinator, report, output_dir: Path, csv_path: Path) -> None:
+    history: list[dict[str, str]] = []
+    _print_quest_board(report)
+    opening = coordinator.conversation_agent.opening(report)
+    history.append({"role": "assistant", "content": opening})
+    print("\n🧭 MoneyMirrorAgent 引导:")
+    print(opening)
+    print(
+        "\n直接输入你的回答即可;/quests 查看任务;"
+        "/complete <quest_id> [备注] 确认订阅检查等人工任务;"
+        "/done、/quit 或 退出 结束对话并生成 Markdown 月报。"
+    )
+    while True:
+        question = input("\n你> ").strip()
+        lowered = question.lower()
+        if lowered in {"/done", "/quit", "退出"}:
+            break
+        if not question:
+            continue
+        if lowered == "/quests":
+            _print_quest_board(report)
+            continue
+        if lowered.startswith("/complete "):
+            parts = question.split(maxsplit=2)
+            if len(parts) < 2:
+                print("用法:/complete <quest_id> [备注]")
+                continue
+            try:
+                result = coordinator.complete_quest(report, parts[1], parts[2] if len(parts) > 2 else "")
+                print(f"✅ 已确认 Quest,获得 {result['gained_exp']} EXP;当前 Lv.{result['level']} / EXP {result['total_exp']}。")
+            except ValueError as exc:
+                print(f"⚠️ {exc}")
+            continue
+        history.append({"role": "user", "content": question})
+        print("\nMoneyMirrorAgent> ", end="", flush=True)
+        chunks = coordinator.runtime.stream_user_guidance(
+            question,
+            coordinator.conversation_agent.payload(report),
+            history,
+        )
+        answer_parts: list[str] = []
+        for chunk in chunks:
+            print(chunk, end="", flush=True)
+            answer_parts.append(chunk)
+        print()
+        history.append({"role": "assistant", "content": "".join(answer_parts).strip()})
+    json_path, markdown_path = coordinator.write_outputs(report, output_dir, history, source_csv=csv_path)
+    print(f"\n✅ 对话结束,LLM Markdown 报告:{markdown_path}")
+    print(f"📦 JSON 事实快照:{json_path}")
+
+
+def main() -> int:
+    load_dotenv(ROOT / ".env")
+    logging.basicConfig(level=getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO), format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+    argument_parser = parser()
+    args = argument_parser.parse_args()
+    try:
+        goals = [_parse_goal(specification) for specification in args.goal]
+    except ValueError as exc:
+        argument_parser.error(f"--goal {exc}")
+    csv_path = args.csv
+    print(f"账单文件: {csv_path}")
+    if args.reset and args.db.exists():
+        args.db.unlink()
+    coordinator = None
+    try:
+        coordinator = MoneyMirrorCoordinator(args.db, os.getenv("MONEYMIRROR_USER_ID", "local_user"))
+        for correction in args.correct:
+            if ":" not in correction:
+                argument_parser.error("--correct 格式必须为 商户:类别,例如 星巴克:餐饮")
+            merchant, category = correction.split(":", 1)
+            coordinator.correct_merchant_category(merchant, category)
+        for goal in goals:
+            coordinator.add_goal(goal)
+        report = coordinator.analyze_csv(csv_path, args.month)
+        if args.interactive:
+            _interactive(coordinator, report, args.output_dir, csv_path)
+        else:
+            json_path, markdown_path = coordinator.write_outputs(report, args.output_dir, source_csv=csv_path)
+            print("\nMoneyMirrorAgent 完整分析已完成")
+            print(f"分析月份: {report.month}")
+            print(f"收入 ¥{report.summary['income']:.2f} | 支出 ¥{report.summary['expense']:.2f} | 结余 ¥{report.summary['balance']:.2f} | 储蓄率 {report.summary['savings_rate']:.2f}%")
+            print(f"消费人格: {report.persona['primary']}")
+            print(f"异常消费: {len(report.anomalies)} 笔 | Quest: {len(report.quests)} 个 | 反思: 已生成")
+            print(f"JSON 报告: {json_path}")
+            print(f"Markdown 报告(LLM 生成): {markdown_path}")
+        return 0
+    except (OSError, ValueError, RuntimeConfigurationError, LLMCallError) as exc:
+        print(f"MoneyMirrorAgent 运行失败: {exc}", file=sys.stderr)
+        return 2
+    finally:
+        if coordinator is not None:
+            coordinator.close()
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 0 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/.gitkeep


+ 738 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/money_mirror_report.json

@@ -0,0 +1,738 @@
+{
+  "user_id": "demo_user",
+  "month": "2026-07",
+  "transactions": [
+    {
+      "transaction_id": "0a8479aa1bf2ef78",
+      "occurred_at": "2026-06-05T09:00",
+      "merchant": "公司工资",
+      "amount": 7200.0,
+      "kind": "income",
+      "category": "收入",
+      "note": "六月工资到账",
+      "source": "data/sample_02.csv",
+      "category_confidence": 1.0
+    },
+    {
+      "transaction_id": "64cb81ba471c6d1f",
+      "occurred_at": "2026-06-05T10:00",
+      "merchant": "房东-六月房租",
+      "amount": 2200.0,
+      "kind": "expense",
+      "category": "住房",
+      "note": "固定住房",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "c391b26ef3f71e94",
+      "occurred_at": "2026-06-08T12:30",
+      "merchant": "健康餐厅",
+      "amount": 85.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "9766268fdf6d4080",
+      "occurred_at": "2026-06-11T12:30",
+      "merchant": "健康餐厅",
+      "amount": 82.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "6232161e1f3d0d5c",
+      "occurred_at": "2026-06-15T08:20",
+      "merchant": "地铁月票",
+      "amount": 120.0,
+      "kind": "expense",
+      "category": "交通",
+      "note": "通勤",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "89f212aa90c6519f",
+      "occurred_at": "2026-06-17T19:00",
+      "merchant": "健身房月卡",
+      "amount": 199.0,
+      "kind": "expense",
+      "category": "健身",
+      "note": "运动",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "84813fdda4faadbe",
+      "occurred_at": "2026-06-20T14:00",
+      "merchant": "技术书店",
+      "amount": 260.0,
+      "kind": "expense",
+      "category": "学习",
+      "note": "学习资料",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "ae7df570d553dd41",
+      "occurred_at": "2026-06-24T12:30",
+      "merchant": "健康餐厅",
+      "amount": 86.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "268905b1a38ce794",
+      "occurred_at": "2026-07-06T09:00",
+      "merchant": "公司工资",
+      "amount": 7200.0,
+      "kind": "income",
+      "category": "收入",
+      "note": "七月工资到账",
+      "source": "data/sample_02.csv",
+      "category_confidence": 1.0
+    },
+    {
+      "transaction_id": "de52201c359d8c00",
+      "occurred_at": "2026-07-06T10:00",
+      "merchant": "房东-七月房租",
+      "amount": 2200.0,
+      "kind": "expense",
+      "category": "住房",
+      "note": "固定住房",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "bd82c407edac7b33",
+      "occurred_at": "2026-07-08T12:30",
+      "merchant": "健康餐厅",
+      "amount": 88.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "866f8d4cdb8a731d",
+      "occurred_at": "2026-07-11T12:30",
+      "merchant": "健康餐厅",
+      "amount": 86.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "df2b4e3eaf97936e",
+      "occurred_at": "2026-07-14T08:20",
+      "merchant": "地铁月票",
+      "amount": 120.0,
+      "kind": "expense",
+      "category": "交通",
+      "note": "通勤",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "15edf8bb951e26d8",
+      "occurred_at": "2026-07-17T19:00",
+      "merchant": "健身房月卡",
+      "amount": 199.0,
+      "kind": "expense",
+      "category": "健身",
+      "note": "运动",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "a026850dbf09bb4e",
+      "occurred_at": "2026-07-19T14:00",
+      "merchant": "设计课程平台",
+      "amount": 320.0,
+      "kind": "expense",
+      "category": "学习",
+      "note": "在线课程",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "7671c327ace86a54",
+      "occurred_at": "2026-07-22T12:30",
+      "merchant": "健康餐厅",
+      "amount": 90.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "e1fbe106ef487e37",
+      "occurred_at": "2026-07-26T12:30",
+      "merchant": "健康餐厅",
+      "amount": 87.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    }
+  ],
+  "summary": {
+    "transaction_count": 9,
+    "income": 7200.0,
+    "expense": 3190.0,
+    "balance": 4010.0,
+    "savings_rate": 55.69,
+    "average_expense": 398.75,
+    "active_days": 8
+  },
+  "category_breakdown": {
+    "住房": 2200.0,
+    "餐饮": 351.0,
+    "学习": 320.0,
+    "健身": 199.0,
+    "交通": 120.0
+  },
+  "trends": {
+    "daily": {
+      "2026-07-06": 2200.0,
+      "2026-07-08": 88.0,
+      "2026-07-11": 86.0,
+      "2026-07-14": 120.0,
+      "2026-07-17": 199.0,
+      "2026-07-19": 320.0,
+      "2026-07-22": 90.0,
+      "2026-07-26": 87.0
+    },
+    "weekly": {
+      "2026-07-06": 2374.0,
+      "2026-07-13": 639.0,
+      "2026-07-20": 177.0
+    },
+    "monthly": {
+      "2026-07": 3190.0
+    }
+  },
+  "patterns": {
+    "late_night": {
+      "count": 0,
+      "amount": 0,
+      "share": 0.0
+    },
+    "weekend": {
+      "count": 3,
+      "amount": 493.0,
+      "share": 15.45
+    },
+    "payday_window": {
+      "count": 2,
+      "amount": 2288.0,
+      "share": 71.72
+    },
+    "frequent_small": {
+      "count": 0,
+      "amount": 0,
+      "average": 0.0
+    },
+    "category_spikes": {
+      "住房": 2200.0,
+      "餐饮": 351.0,
+      "学习": 320.0,
+      "健身": 199.0,
+      "交通": 120.0
+    }
+  },
+  "anomalies": [],
+  "subscriptions": [
+    {
+      "merchant": "健身房月卡",
+      "months": [
+        "2026-06",
+        "2026-07"
+      ],
+      "occurrences": 2,
+      "typical_amount": 199.0,
+      "category": "健身",
+      "low_value_flag": false,
+      "message": "连续多月的会员或续费扣款,建议检查是否仍有使用价值"
+    }
+  ],
+  "persona": {
+    "primary": "稳健规划玩家",
+    "archetype": "steady_planner",
+    "score": 85.0,
+    "confidence": 0.85,
+    "labels": [
+      "稳健规划玩家",
+      "学习投入玩家"
+    ],
+    "secondary": [
+      {
+        "archetype": "learning_investor",
+        "name": "学习投入玩家",
+        "score": 59.49,
+        "confidence": 0.59,
+        "evidence": [
+          "学习消费占比 10.0%",
+          "有学习消费的月份数 2"
+        ]
+      }
+    ],
+    "evidence": [
+      "储蓄率 55.7%",
+      "工资到账后消费占比 71.7%",
+      "深夜消费占比 0.0%"
+    ],
+    "feature_vector": {
+      "night": 0.0,
+      "weekend": 36.25,
+      "frequent_small": 0.0,
+      "flexible_spend": 0.0,
+      "food": 27.51,
+      "subscription": 11.67,
+      "learning": 62.66,
+      "learning_consistency": 50.0,
+      "savings": 100.0,
+      "planning": 75.0,
+      "planning_inverse": 25.0,
+      "impulse": 30.0
+    },
+    "narrative": "你是“稳健规划玩家”,可以叫“月光绝缘体”——稳定原型匹配分 85.0,生活节奏稳到像自带一张消费蓝图。数据也印证:储蓄率 55.7%,工资到账后的消费占 71.7%,深夜消费 0.0%,说明你习惯先把钱和计划安排明白,再慢慢享受生活。"
+  },
+  "budget": {
+    "month": "2026-07",
+    "categories": {
+      "交通": {
+        "bucket": "necessary",
+        "historical_median": 120.0,
+        "recommended": 116.4,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "住房": {
+        "bucket": "fixed",
+        "historical_median": 2200.0,
+        "recommended": 2200.0,
+        "rationale": "固定支出,原则上不做大幅削减"
+      },
+      "健身": {
+        "bucket": "necessary",
+        "historical_median": 199.0,
+        "recommended": 193.03,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "学习": {
+        "bucket": "necessary",
+        "historical_median": 260.0,
+        "recommended": 252.2,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "餐饮": {
+        "bucket": "necessary",
+        "historical_median": 253.0,
+        "recommended": 245.41,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      }
+    },
+    "recommended_total": 3007.04,
+    "historical_months": [
+      "2026-06"
+    ],
+    "principle": "基于历史中位数,固定支出不削减,必要/弹性/可选支出分层温和调整"
+  },
+  "goals": [],
+  "quests": [
+    {
+      "quest_id": "subscription_hunter",
+      "title": "订阅大扫除",
+      "description": "发现一笔疑似连续扣费,可能已经悄悄溜走好久啦。咱们一起来翻翻账本,看看它是不是还在为你发光发热,还是该和它说再见咯。\n🧩 小提示:打开订阅列表,找出那笔疑似扣费,确认自己是否还在用;不用的就痛痛快快取消掉,让钱包松口气。\n🎯 已核验目标:检查 1 项疑似连续扣费,并仅保留仍会使用的服务。",
+      "quest_type": "subscription_review",
+      "target": 1.0,
+      "progress": 0.0,
+      "unit": "项",
+      "exp_reward": 90,
+      "status": "active",
+      "evidence": "需要用户在后续 CLI 引导中确认完成"
+    },
+    {
+      "quest_id": "learning_loot_log",
+      "title": "学习小侦探",
+      "description": "有一笔学习相关的开销,但不知道它有没有真正帮到你。像小侦探一样去查一查,看看这门课或资料到底有没有被翻牌,顺便给自己定个下次学习的小约定。\n🧩 小提示:找出那笔学习支出对应的服务,记录今天有没有使用它,并顺手在日历上标一个下次使用的时间点,让每一分钱都花在成长上。\n🎯 已核验目标:记录 1 次学习服务是否真正被使用,并标注下次使用时间。",
+      "quest_type": "manual",
+      "target": 1.0,
+      "progress": 0,
+      "unit": "次",
+      "exp_reward": 70,
+      "status": "active",
+      "evidence": "等待用户更新进度"
+    }
+  ],
+  "achievements": [
+    {
+      "key": "savings_rate_20",
+      "title": "储蓄率破 20%",
+      "description": "储蓄率首次达到或超过 20%。",
+      "unlocked": true
+    },
+    {
+      "key": "zero_spend_start",
+      "title": "零消费日初体验",
+      "description": "完成至少一个零消费日。",
+      "unlocked": false
+    },
+    {
+      "key": "quest_ready",
+      "title": "任务上线",
+      "description": "已生成基于真实账单的 Money Quest。",
+      "unlocked": true
+    },
+    {
+      "key": "late_night_awareness",
+      "title": "深夜雷达启动",
+      "description": "已识别深夜消费行为并生成应对任务。",
+      "unlocked": false
+    },
+    {
+      "key": "zero_spend_streak_3",
+      "title": "三日无消费连击",
+      "description": "连续 3 天没有支出记录。",
+      "unlocked": true
+    }
+  ],
+  "gamification": {
+    "level": 1,
+    "total_exp": 0,
+    "exp_gained_this_cycle": 0,
+    "current_streak_days": 3,
+    "longest_streak_days": 3
+  },
+  "reflection": {
+    "month": "2026-07",
+    "previous_month": "2026-06",
+    "has_previous_snapshot": true,
+    "budget_deviations": [
+      {
+        "category": "交通",
+        "planned": 116.4,
+        "actual": 120.0,
+        "difference": 3.6,
+        "on_budget": false
+      },
+      {
+        "category": "住房",
+        "planned": 2200.0,
+        "actual": 2200.0,
+        "difference": 0.0,
+        "on_budget": true
+      },
+      {
+        "category": "健身",
+        "planned": 193.03,
+        "actual": 199.0,
+        "difference": 5.97,
+        "on_budget": false
+      },
+      {
+        "category": "学习",
+        "planned": 252.2,
+        "actual": 320.0,
+        "difference": 67.8,
+        "on_budget": false
+      },
+      {
+        "category": "餐饮",
+        "planned": 245.41,
+        "actual": 351.0,
+        "difference": 105.59,
+        "on_budget": false
+      }
+    ],
+    "quest_completion": {
+      "completed": 0,
+      "total": 2
+    },
+    "goal_progress": {},
+    "effective": [
+      "预算和任务均基于实际账单计算"
+    ],
+    "needs_adjustment": [
+      "餐饮",
+      "学习",
+      "健身",
+      "交通"
+    ],
+    "next_strategy": [
+      "优先为 餐饮 保留明确额度,而不是一刀切禁止消费。",
+      "下阶段只保留 2 个可执行任务,降低任务负担。"
+    ],
+    "next_cycle_month": "2026-08",
+    "next_cycle_budget": {
+      "source": "monthly_reflection",
+      "categories": {
+        "交通": {
+          "bucket": "necessary",
+          "historical_median": 120.0,
+          "recommended": 126.0,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "住房": {
+          "bucket": "fixed",
+          "historical_median": 2200.0,
+          "recommended": 2200.0,
+          "rationale": "固定支出,原则上不做大幅削减"
+        },
+        "健身": {
+          "bucket": "necessary",
+          "historical_median": 199.0,
+          "recommended": 208.95,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "学习": {
+          "bucket": "necessary",
+          "historical_median": 260.0,
+          "recommended": 336.0,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "餐饮": {
+          "bucket": "necessary",
+          "historical_median": 253.0,
+          "recommended": 368.12,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        }
+      }
+    },
+    "next_cycle_quests": [
+      {
+        "quest_id": "carry_subscription_hunter",
+        "title": "延续:订阅大扫除",
+        "quest_type": "subscription_review",
+        "target": 1.0,
+        "unit": "项",
+        "exp_reward": 90,
+        "reason": "上周期未完成,Reflection 建议降低摩擦后继续"
+      },
+      {
+        "quest_id": "carry_learning_loot_log",
+        "title": "延续:学习小侦探",
+        "quest_type": "manual",
+        "target": 1.0,
+        "unit": "次",
+        "exp_reward": 70,
+        "reason": "上周期未完成,Reflection 建议降低摩擦后继续"
+      },
+      {
+        "quest_id": "reflection_餐饮",
+        "title": "餐饮缓冲预算挑战",
+        "quest_type": "category_limit",
+        "target": 368.12,
+        "unit": "餐饮",
+        "exp_reward": 100,
+        "reason": "根据本月最大预算偏差生成"
+      }
+    ],
+    "narrative": "按计划-实际-调整来看,本月储蓄率55.69%稳健,但餐饮(实际351元,超支105.59元)和学习(实际320元,超支67.8元)是主要失血点,且两项任务均未完成。下一周期先砍餐饮弹性支出、回归245.41元预算线,同时把学习花费与任务进度挂钩,避免“花钱未推进”。完成这2项任务前暂不开启新的非必要支出,下周期再检验调整效果。"
+  },
+  "agent_trace": [
+    {
+      "agent": "MoneyMirrorCoordinator",
+      "architecture": "Transaction → Pattern → Persona → Goal → Quest → Reflection",
+      "runtime": {
+        "available": true,
+        "enabled": true,
+        "reason": "已启用 Hello-Agents + OpenAI 兼容 LLM:deepseek-v4-flash",
+        "registry_name": "HelloAgents ToolRegistry (8 MoneyMirror tools)",
+        "paradigms": [
+          "ReActAgent",
+          "PlanSolveAgent",
+          "ReflectionAgent",
+          "Context Engineering"
+        ],
+        "registered_tools": [
+          "CSVImportTool",
+          "TransactionCategoryTool",
+          "StatisticsTool",
+          "AnomalyDetectionTool",
+          "BudgetCalculatorTool",
+          "GoalProjectionTool",
+          "SubscriptionDetectorTool",
+          "QuestProgressTool"
+        ],
+        "provider": "OpenAI-compatible",
+        "model": "deepseek-v4-flash",
+        "base_url": "https://api.deepseek.com"
+      }
+    },
+    {
+      "agent": "TransactionAgent",
+      "paradigm": "ReActAgent-style: inspect transaction → consult memory/rules → resolve only uncertainty",
+      "classification_sources": {
+        "income_rule": 2,
+        "keyword_rule": 15
+      }
+    },
+    {
+      "agent": "PatternAgent",
+      "paradigm": "PlanAndSolveAgent-style: plan metrics → call statistical tools → return evidence",
+      "tools": [
+        "StatisticsTool",
+        "AnomalyDetectionTool",
+        "SubscriptionDetectorTool"
+      ],
+      "evidence": {
+        "late_night_count": 0,
+        "anomaly_count": 0,
+        "subscription_count": 1
+      },
+      "planning_note": "下一步应把唯一订阅项作为消费模式解读重点,同时确认夜间消费与异常项均为0,无需额外排查。"
+    },
+    {
+      "agent": "PersonaAgent",
+      "paradigm": "Feature vector → configurable scoring → evidence validation → LLM narrative",
+      "config_path": "/home/nyc/hello-agents/Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/config/personas.json",
+      "grounded_metrics": {
+        "savings_rate": 55.69,
+        "late_night_share": 0.0,
+        "late_night_count": 0,
+        "weekend_share": 15.45,
+        "weekend_count": 3,
+        "payday_share": 71.72,
+        "frequent_small_count": 0,
+        "frequent_small_share": 0.0,
+        "food_share": 11.0,
+        "flexible_spend_share": 0.0,
+        "subscription_share": 0.0,
+        "subscription_count": 1,
+        "learning_share": 10.03,
+        "learning_active_months": 2,
+        "impulse": 30.0
+      },
+      "feature_vector": {
+        "night": 0.0,
+        "weekend": 36.25,
+        "frequent_small": 0.0,
+        "flexible_spend": 0.0,
+        "food": 27.51,
+        "subscription": 11.67,
+        "learning": 62.66,
+        "learning_consistency": 50.0,
+        "savings": 100.0,
+        "planning": 75.0,
+        "planning_inverse": 25.0,
+        "impulse": 30.0
+      },
+      "candidates": [
+        {
+          "archetype": "steady_planner",
+          "name": "稳健规划玩家",
+          "score": 85.0,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "learning_investor",
+          "name": "学习投入玩家",
+          "score": 59.49,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "weekend_experience",
+          "name": "周末体验玩家",
+          "score": 23.56,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "subscription_collector",
+          "name": "数字订阅整理师",
+          "score": 14.34,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "frequent_small_spend",
+          "name": "高频小额行动派",
+          "score": 10.13,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "late_night_focus",
+          "name": "夜行消费探索者",
+          "score": 7.5,
+          "evidence_valid": false
+        }
+      ],
+      "llm_role": "仅生成年轻化解释,不决定人格原型、分数或证据"
+    },
+    {
+      "agent": "GoalAgent",
+      "paradigm": "PlanAndSolveAgent-style goal feasibility projection",
+      "tools": [
+        "GoalProjectionTool"
+      ],
+      "goal_count": 0,
+      "planning_note": "由于当前没有已验证的工具输出可引用,无法给出具体金额;一般做法是:将目标投影与当前实际值的差额,按剩余月份分解为每月需执行的可操作储蓄额,并绑定到发薪日自动转账或支出限额规则上,作为下一步的具体行动。"
+    },
+    {
+      "agent": "QuestAgent",
+      "paradigm": "规则发现真实信号 → PlanSolveAgent 动态编排 → Python 强校验与进度计算",
+      "tools": [
+        "StatisticsTool",
+        "BudgetCalculatorTool",
+        "SubscriptionDetectorTool",
+        "QuestProgressTool"
+      ],
+      "signal_catalog": [
+        {
+          "signal_id": "subscriptions",
+          "priority": "required",
+          "verified_observation": "发现 1 项疑似连续扣费。",
+          "locked_constraint": "检查 1 项疑似连续扣费,并仅保留仍会使用的服务。",
+          "completion_source": "用户 CLI 确认后由 SQLite Memory 记录"
+        },
+        {
+          "signal_id": "learning_followthrough",
+          "priority": "optional",
+          "verified_observation": "学习类支出为 ¥320.00,占本月支出 10.03%。",
+          "locked_constraint": "记录 1 次学习服务是否真正被使用,并标注下次使用时间。",
+          "completion_source": "用户 CLI 确认后由 SQLite Memory 记录"
+        }
+      ],
+      "llm_orchestration": {
+        "candidate_count": 2,
+        "accepted_signal_ids": [
+          "subscriptions",
+          "learning_followthrough"
+        ],
+        "validation": {
+          "attempts": 1,
+          "repaired": false,
+          "rejected": []
+        },
+        "numeric_authority": "Python only: target / progress / EXP / status are derived from locked blueprints and QuestProgressTool."
+      },
+      "quest_evidence": [
+        "需要用户在后续 CLI 引导中确认完成",
+        "等待用户更新进度"
+      ]
+    },
+    {
+      "agent": "ReflectionAgent",
+      "paradigm": "ReflectionAgent-style: plan → actual → deviation → adjustment",
+      "context_sources": [
+        "SQLiteMemory: previous snapshot",
+        "budget",
+        "quest outcomes",
+        "goal projection"
+      ]
+    }
+  ]
+}

+ 196 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/money_mirror_report.md

@@ -0,0 +1,196 @@
+# 📊 MoneyMirror 月度报告 · 2026年7月
+
+> 你好呀,这个月你一共记了 9 笔账,收入 7200 元,支出 3190 元,结余 4010 元。整体来看,这是一份「稳中有惊喜」的账单。下面我们按 **计划 → 实际 → 调整** 的节奏,一起看看钱都去了哪里,以及下一步怎么走。
+
+---
+
+## 🪞 财务镜像
+
+| 指标 | 数值 |
+|------|------|
+| 月收入 | 7200 元 |
+| 月支出 | 3190 元 |
+| 月结余 | 4010 元 |
+| 储蓄率 | 55.69% |
+| 平均每日支出 | 398.75 元 |
+| 记账活跃天数 | 8 天 |
+
+**储蓄率 55.69%** 意味着你稳稳地把超过一半的收入留了下来,这是一个非常健康的信号。结余 4010 元,已为未来积累了一笔不错的空间。虽然支出笔数不多(9 笔),但每笔都有记录,说明你没有被零碎消费带跑。
+
+---
+
+## 🧾 消费分类与趋势
+
+这个月的支出分布如下:
+
+| 类别 | 金额 | 占比 |
+|------|------|------|
+| 住房 | 2200 元 | 约 68.97% |
+| 餐饮 | 351 元 | 约 11.00% |
+| 学习 | 320 元 | 约 10.03% |
+| 健身 | 199 元 | 约 6.24% |
+| 交通 | 120 元 | 约 3.76% |
+
+**周度趋势:**
+- 第 1 周(7/6 起):2374 元 —— 主要是住房等刚性支出
+- 第 2 周(7/13 起):639 元
+- 第 3 周(7/20 起):177 元
+
+明显能看出:月初是支出高峰,后面两周逐渐回落,第 3 周已经压缩到 177 元,说明你的消费自控力在月底表现得很好。
+
+---
+
+## 🧠 行为模式
+
+- **深夜消费:0 笔 / 0 元** ✅ 深夜购物冲动为零,非常克制!
+- **周末消费:3 笔 / 493 元,占 15.45%** —— 周末有放松,但没有失控。
+- **工资日窗口消费:2 笔 / 2288 元,占 71.72%** —— 到账后先把大头(住房、固定类)安排好,规划感很强。
+- **高频小额消费:无** —— 没有被碎钞机式的小额支出拖累。
+
+这些模式说明你是「先把钱安排好,再慢慢享受」的类型。
+
+---
+
+## ⚠️ 异常消费解释
+
+**未检测到异常消费。**
+
+简单说:这个月没有一笔支出明显「偏离剧情」。唯一值得留意的是**工资日当天消费占比较高(71.72%)**,但这更偏向「计划内集中支付」而不是冲动消费。第 3 周支出大幅下降(177 元),某种程度上也在给你的钱包做「主动降温」。
+
+---
+
+## 🧑‍🎤 消费人格:稳健规划玩家(匹配度 85.0)
+
+你被识别为 **「稳健规划玩家」**,可以叫「月光绝缘体」😎。数据也支持这个诊断:
+
+- 储蓄率 55.7%
+- 工资到账后消费占比 71.7%
+- 深夜消费占比 0.0%
+
+辅助人格是 **学习投入玩家(59.49)**:本月学习消费占比 10.0%,且有跨月学习消费记录,说明你在「自我成长」上愿意花钱,并且有连续性。
+
+> 简单说:你花得有条理,也愿意为成长投入。这个组合很有长久竞争力。
+
+---
+
+## 🔁 订阅提醒:健身房月卡
+
+我们在账单中发现:
+
+- **商户:健身房月卡**
+- 已连续扣费月份:2026-06、2026-07
+- 典型金额:199 元 / 月
+- 类别:健身
+
+**建议行动:** 检查一下最近是否还真的在使用这张卡。如果只是「心理安慰型续费」,可以考虑暂停或取消,每月能省出 199 元。这并不是说健身不好,而是要确认这笔钱是否真的创造了价值。
+
+---
+
+## 🎯 目标进度:暂无数据
+
+当前周期没有设定独立的结构化目标(goals 为空)。不过别担心——下面的预算和 Quest 就是在帮你建目标。
+
+---
+
+## 💰 动态预算:7 月复盘与 8 月预算
+
+### 7 月预算执行情况(计划 vs 实际)
+
+| 类别 | 计划值 | 实际值 | 偏差 |
+|------|--------|--------|------|
+| 交通 | 116.40 元 | 120 元 | +3.60 元 |
+| 住房 | 2200 元 | 2200 元 | 0 元 ✅ |
+| 健身 | 193.03 元 | 199 元 | +5.97 元 |
+| 学习 | 252.20 元 | 320 元 | +67.80 元 |
+| 餐饮 | 245.41 元 | 351 元 | +105.59 元 |
+
+**最大失血点是餐饮(+105.59 元)和学习(+67.80 元)**。住房完全在计划内,做得非常好。
+
+### 8 月推荐预算(基于 reflection 修正)
+
+| 类别 | 推荐值 | 说明 |
+|------|--------|------|
+| 住房 | 2200 元 | 固定支出,保持不变 |
+| 餐饮 | 368.12 元 | 增加缓冲,避免一刀切 |
+| 学习 | 336 元 | 适度上调,兼顾成长投入 |
+| 健身 | 208.95 元 | 微调缓冲 |
+| 交通 | 126 元 | 增加少量弹性 |
+
+**8 月推荐总预算:3007.04 元**。
+
+原则是:固定支出不削减,必要支出参考历史中位数并保留缓冲,不搞极端节流,让预算更「可执行」。
+
+---
+
+## 🎮 Money Quest(当前进行中)
+
+### 1. 订阅大扫除 🔍
+- 目标:检查 1 项疑似连续扣费,并仅保留仍会使用的服务
+- 进度:0 / 1
+- 奖励:90 EXP
+- 对应对象:健身房月卡
+
+### 2. 学习小侦探 🕵️
+- 目标:记录 1 次学习服务是否真正被使用,并标注下次使用时间
+- 进度:0 / 1
+- 奖励:70 EXP
+- 核心逻辑:让 320 元学习支出「花得明白」
+
+> 两项任务本月均未完成。下个周期会延续,并降低摩擦后再挑战。
+
+---
+
+## ⭐ 等级与成就
+
+**当前等级:Lv.1**,总经验 0,连续记账 3 天(最长 3 天)。
+
+已解锁成就:
+- ✅ 储蓄率破 20%(储蓄率首次 ≥20%)
+- ✅ 任务上线(已生成基于真实账单的 Money Quest)
+- ✅ 三日无消费连击(连续 3 天无支出记录)
+
+未解锁:
+- ❌ 零消费日初体验(尚未有完整零消费日)
+- ❌ 深夜雷达启动(尚未识别深夜消费行为)
+
+---
+
+## 🔁 月度 Reflection:计划 → 实际 → 调整
+
+**做得好的:**
+- 储蓄率 55.69%,比很多人的目标线高出一大截
+- 深夜消费为 0,未出现冲动夜购
+- 住房支出完全按预算执行,固定支出控制得非常稳
+
+**需要调整的:**
+- 餐饮超支 105.59 元,实际 351 元,是本月最大偏差
+- 学习超支 67.80 元,实际 320 元,且「花钱是否真的推进了学习」存疑
+- 两项 Quest 均未完成,说明「计划」和「执行」之间还有衔接缝隙
+
+**下阶段策略:**
+1. 优先为餐饮保留明确额度,而不是一刀切禁止消费 → 8 月餐饮预算放宽至 368.12 元
+2. 学习花费与任务进度挂钩,避免「花钱未推进」
+3. **完成这 2 项任务前,暂不开启新的非必要支出**
+
+---
+
+## 📋 下一周期行动清单(2026-08)
+
+1. ✅ 完成「订阅大扫除」:检查健身房月卡是否仍值得继续扣费,不常用就取消
+2. ✅ 完成「学习小侦探」:找出 320 元对应学习服务,记录使用情况,并设定下次使用时间
+3. ✅ 执行「餐饮缓冲预算挑战」:8 月餐饮目标控制在 368.12 元以内(奖励 EXP+100)
+4. 💡 8 月总预算控制在 3007.04 元内,尝试让每一笔钱都有归属
+5. 🎯 尝试创造至少 1 个「零消费日」,解锁对应成就
+6. 🔇 在完成上述两项任务前,不新增任何非必要订阅或课程
+
+---
+
+## 🛡️ 安全边界
+
+- 本报告所有数据均来自你本月的真实记账记录,以实际账单为准。
+- 预算建议基于历史数据和消费模式,实际执行时请结合真实生活需要灵活调整。
+- 如有任何大额或异常支出,请以你当前的真实财务状况为最高优先级,理性决策。
+
+---
+
+下个月见呀~你已经比 90% 的人更懂自己的钱了。继续加油,让每一分钱都花得明白、存得安心 🌱

+ 764 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_02_money_mirror_report.json

@@ -0,0 +1,764 @@
+{
+  "user_id": "demo_user",
+  "month": "2026-07",
+  "transactions": [
+    {
+      "transaction_id": "0a8479aa1bf2ef78",
+      "occurred_at": "2026-06-05T09:00",
+      "merchant": "公司工资",
+      "amount": 7200.0,
+      "kind": "income",
+      "category": "收入",
+      "note": "六月工资到账",
+      "source": "data/sample_02.csv",
+      "category_confidence": 1.0
+    },
+    {
+      "transaction_id": "64cb81ba471c6d1f",
+      "occurred_at": "2026-06-05T10:00",
+      "merchant": "房东-六月房租",
+      "amount": 2200.0,
+      "kind": "expense",
+      "category": "住房",
+      "note": "固定住房",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "c391b26ef3f71e94",
+      "occurred_at": "2026-06-08T12:30",
+      "merchant": "健康餐厅",
+      "amount": 85.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "9766268fdf6d4080",
+      "occurred_at": "2026-06-11T12:30",
+      "merchant": "健康餐厅",
+      "amount": 82.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "6232161e1f3d0d5c",
+      "occurred_at": "2026-06-15T08:20",
+      "merchant": "地铁月票",
+      "amount": 120.0,
+      "kind": "expense",
+      "category": "交通",
+      "note": "通勤",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "89f212aa90c6519f",
+      "occurred_at": "2026-06-17T19:00",
+      "merchant": "健身房月卡",
+      "amount": 199.0,
+      "kind": "expense",
+      "category": "健身",
+      "note": "运动",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "84813fdda4faadbe",
+      "occurred_at": "2026-06-20T14:00",
+      "merchant": "技术书店",
+      "amount": 260.0,
+      "kind": "expense",
+      "category": "学习",
+      "note": "学习资料",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "ae7df570d553dd41",
+      "occurred_at": "2026-06-24T12:30",
+      "merchant": "健康餐厅",
+      "amount": 86.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "268905b1a38ce794",
+      "occurred_at": "2026-07-06T09:00",
+      "merchant": "公司工资",
+      "amount": 7200.0,
+      "kind": "income",
+      "category": "收入",
+      "note": "七月工资到账",
+      "source": "data/sample_02.csv",
+      "category_confidence": 1.0
+    },
+    {
+      "transaction_id": "de52201c359d8c00",
+      "occurred_at": "2026-07-06T10:00",
+      "merchant": "房东-七月房租",
+      "amount": 2200.0,
+      "kind": "expense",
+      "category": "住房",
+      "note": "固定住房",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "bd82c407edac7b33",
+      "occurred_at": "2026-07-08T12:30",
+      "merchant": "健康餐厅",
+      "amount": 88.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "866f8d4cdb8a731d",
+      "occurred_at": "2026-07-11T12:30",
+      "merchant": "健康餐厅",
+      "amount": 86.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "df2b4e3eaf97936e",
+      "occurred_at": "2026-07-14T08:20",
+      "merchant": "地铁月票",
+      "amount": 120.0,
+      "kind": "expense",
+      "category": "交通",
+      "note": "通勤",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "15edf8bb951e26d8",
+      "occurred_at": "2026-07-17T19:00",
+      "merchant": "健身房月卡",
+      "amount": 199.0,
+      "kind": "expense",
+      "category": "健身",
+      "note": "运动",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "a026850dbf09bb4e",
+      "occurred_at": "2026-07-19T14:00",
+      "merchant": "设计课程平台",
+      "amount": 320.0,
+      "kind": "expense",
+      "category": "学习",
+      "note": "在线课程",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "7671c327ace86a54",
+      "occurred_at": "2026-07-22T12:30",
+      "merchant": "健康餐厅",
+      "amount": 90.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "e1fbe106ef487e37",
+      "occurred_at": "2026-07-26T12:30",
+      "merchant": "健康餐厅",
+      "amount": 87.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "午餐",
+      "source": "data/sample_02.csv",
+      "category_confidence": 0.92
+    }
+  ],
+  "summary": {
+    "transaction_count": 9,
+    "income": 7200.0,
+    "expense": 3190.0,
+    "balance": 4010.0,
+    "savings_rate": 55.69,
+    "average_expense": 398.75,
+    "active_days": 8
+  },
+  "category_breakdown": {
+    "住房": 2200.0,
+    "餐饮": 351.0,
+    "学习": 320.0,
+    "健身": 199.0,
+    "交通": 120.0
+  },
+  "trends": {
+    "daily": {
+      "2026-07-06": 2200.0,
+      "2026-07-08": 88.0,
+      "2026-07-11": 86.0,
+      "2026-07-14": 120.0,
+      "2026-07-17": 199.0,
+      "2026-07-19": 320.0,
+      "2026-07-22": 90.0,
+      "2026-07-26": 87.0
+    },
+    "weekly": {
+      "2026-07-06": 2374.0,
+      "2026-07-13": 639.0,
+      "2026-07-20": 177.0
+    },
+    "monthly": {
+      "2026-07": 3190.0
+    }
+  },
+  "patterns": {
+    "late_night": {
+      "count": 0,
+      "amount": 0,
+      "share": 0.0
+    },
+    "weekend": {
+      "count": 3,
+      "amount": 493.0,
+      "share": 15.45
+    },
+    "payday_window": {
+      "count": 2,
+      "amount": 2288.0,
+      "share": 71.72
+    },
+    "frequent_small": {
+      "count": 0,
+      "amount": 0,
+      "average": 0.0
+    },
+    "category_spikes": {
+      "住房": 2200.0,
+      "餐饮": 351.0,
+      "学习": 320.0,
+      "健身": 199.0,
+      "交通": 120.0
+    }
+  },
+  "anomalies": [],
+  "subscriptions": [
+    {
+      "merchant": "健身房月卡",
+      "months": [
+        "2026-06",
+        "2026-07"
+      ],
+      "occurrences": 2,
+      "typical_amount": 199.0,
+      "category": "健身",
+      "low_value_flag": false,
+      "message": "连续多月的会员或续费扣款,建议检查是否仍有使用价值"
+    }
+  ],
+  "persona": {
+    "primary": "稳健规划玩家",
+    "archetype": "steady_planner",
+    "score": 85.0,
+    "confidence": 0.85,
+    "labels": [
+      "稳健规划玩家",
+      "学习投入玩家"
+    ],
+    "secondary": [
+      {
+        "archetype": "learning_investor",
+        "name": "学习投入玩家",
+        "score": 59.49,
+        "confidence": 0.59,
+        "evidence": [
+          "学习消费占比 10.0%",
+          "有学习消费的月份数 2"
+        ]
+      }
+    ],
+    "evidence": [
+      "储蓄率 55.7%",
+      "工资到账后消费占比 71.7%",
+      "深夜消费占比 0.0%"
+    ],
+    "feature_vector": {
+      "night": 0.0,
+      "weekend": 36.25,
+      "frequent_small": 0.0,
+      "flexible_spend": 0.0,
+      "food": 27.51,
+      "subscription": 11.67,
+      "learning": 62.66,
+      "learning_consistency": 50.0,
+      "savings": 100.0,
+      "planning": 75.0,
+      "planning_inverse": 25.0,
+      "impulse": 30.0
+    },
+    "narrative": "你的匹配人格是**稳健规划玩家**(匹配分 85.0),简直像一位自带节奏感的「发薪日小管家」:储蓄率有 55.7%,而且 71.7% 的消费都集中在工资到账后——说明你习惯钱一到手就先按计划安排必要开销,剩下的安心存住,每一步都踩得很稳。\n\n深夜消费占比 0.0% 更是加分项:你几乎从不在深夜冲动下单,习惯在清醒时做决定,天黑了就让钱包和睡眠一起收工,这种「到点就稳」的自律,就是最温柔也最踏实的攒钱方式呀。"
+  },
+  "budget": {
+    "month": "2026-07",
+    "categories": {
+      "交通": {
+        "bucket": "necessary",
+        "historical_median": 120.0,
+        "recommended": 116.4,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "住房": {
+        "bucket": "fixed",
+        "historical_median": 2200.0,
+        "recommended": 2200.0,
+        "rationale": "固定支出,原则上不做大幅削减"
+      },
+      "健身": {
+        "bucket": "necessary",
+        "historical_median": 199.0,
+        "recommended": 193.03,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "学习": {
+        "bucket": "necessary",
+        "historical_median": 260.0,
+        "recommended": 252.2,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "餐饮": {
+        "bucket": "necessary",
+        "historical_median": 253.0,
+        "recommended": 245.41,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      }
+    },
+    "recommended_total": 3007.04,
+    "historical_months": [
+      "2026-06"
+    ],
+    "principle": "基于历史中位数,固定支出不削减,必要/弹性/可选支出分层温和调整"
+  },
+  "goals": [],
+  "quests": [
+    {
+      "quest_id": "subscription_hunter",
+      "title": "订阅大扫除",
+      "description": "好像有个小东西每月悄悄扣钱,像躲在角落的小怪兽。咱们一起找出它,看看还值不值得留下。\n🧩 小提示:打开支付记录,找出那个连续扣费的项目,问问自己还在用吗?不用就取消它吧。\n🎯 已核验目标:检查 1 项疑似连续扣费,并仅保留仍会使用的服务。",
+      "quest_type": "subscription_review",
+      "target": 1.0,
+      "progress": 0.0,
+      "unit": "项",
+      "exp_reward": 90,
+      "status": "active",
+      "evidence": "需要用户在后续 CLI 引导中确认完成"
+    },
+    {
+      "quest_id": "learning_loot_log",
+      "title": "学习小侦探",
+      "description": "你买了份学习服务,但它有没有真正帮到你呢?就像侦探一样追踪使用痕迹,给未来的自己留个提醒。\n🧩 小提示:回想一下最近一次打开学习服务是什么时候,把它记在心里,顺手定个下次使用的时间吧。\n🎯 已核验目标:记录 1 次学习服务是否真正被使用,并标注下次使用时间。",
+      "quest_type": "manual",
+      "target": 1.0,
+      "progress": 0,
+      "unit": "次",
+      "exp_reward": 70,
+      "status": "active",
+      "evidence": "等待用户更新进度"
+    }
+  ],
+  "achievements": [
+    {
+      "key": "savings_rate_20",
+      "title": "储蓄率破 20%",
+      "description": "储蓄率首次达到或超过 20%。",
+      "unlocked": true
+    },
+    {
+      "key": "zero_spend_start",
+      "title": "零消费日初体验",
+      "description": "完成至少一个零消费日。",
+      "unlocked": false
+    },
+    {
+      "key": "quest_ready",
+      "title": "任务上线",
+      "description": "已生成基于真实账单的 Money Quest。",
+      "unlocked": true
+    },
+    {
+      "key": "late_night_awareness",
+      "title": "深夜雷达启动",
+      "description": "已识别深夜消费行为并生成应对任务。",
+      "unlocked": false
+    },
+    {
+      "key": "zero_spend_streak_3",
+      "title": "三日无消费连击",
+      "description": "连续 3 天没有支出记录。",
+      "unlocked": true
+    }
+  ],
+  "gamification": {
+    "level": 1,
+    "total_exp": 0,
+    "exp_gained_this_cycle": 0,
+    "current_streak_days": 3,
+    "longest_streak_days": 3
+  },
+  "reflection": {
+    "month": "2026-07",
+    "previous_month": "2026-06",
+    "has_previous_snapshot": true,
+    "budget_deviations": [
+      {
+        "category": "交通",
+        "planned": 116.4,
+        "actual": 120.0,
+        "difference": 3.6,
+        "on_budget": false
+      },
+      {
+        "category": "住房",
+        "planned": 2200.0,
+        "actual": 2200.0,
+        "difference": 0.0,
+        "on_budget": true
+      },
+      {
+        "category": "健身",
+        "planned": 193.03,
+        "actual": 199.0,
+        "difference": 5.97,
+        "on_budget": false
+      },
+      {
+        "category": "学习",
+        "planned": 252.2,
+        "actual": 320.0,
+        "difference": 67.8,
+        "on_budget": false
+      },
+      {
+        "category": "餐饮",
+        "planned": 245.41,
+        "actual": 351.0,
+        "difference": 105.59,
+        "on_budget": false
+      }
+    ],
+    "quest_completion": {
+      "completed": 0,
+      "total": 2
+    },
+    "goal_progress": {},
+    "effective": [
+      "预算和任务均基于实际账单计算"
+    ],
+    "needs_adjustment": [
+      "餐饮",
+      "学习",
+      "健身",
+      "交通"
+    ],
+    "next_strategy": [
+      "优先为 餐饮 保留明确额度,而不是一刀切禁止消费。",
+      "下阶段只保留 2 个可执行任务,降低任务负担。"
+    ],
+    "next_cycle_month": "2026-08",
+    "next_cycle_budget": {
+      "source": "monthly_reflection",
+      "categories": {
+        "交通": {
+          "bucket": "necessary",
+          "historical_median": 120.0,
+          "recommended": 126.0,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "住房": {
+          "bucket": "fixed",
+          "historical_median": 2200.0,
+          "recommended": 2200.0,
+          "rationale": "固定支出,原则上不做大幅削减"
+        },
+        "健身": {
+          "bucket": "necessary",
+          "historical_median": 199.0,
+          "recommended": 208.95,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "学习": {
+          "bucket": "necessary",
+          "historical_median": 260.0,
+          "recommended": 336.0,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "餐饮": {
+          "bucket": "necessary",
+          "historical_median": 253.0,
+          "recommended": 368.12,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        }
+      }
+    },
+    "next_cycle_quests": [
+      {
+        "quest_id": "carry_subscription_hunter",
+        "title": "延续:订阅大扫除",
+        "quest_type": "subscription_review",
+        "target": 1.0,
+        "unit": "项",
+        "exp_reward": 90,
+        "reason": "上周期未完成,Reflection 建议降低摩擦后继续"
+      },
+      {
+        "quest_id": "carry_learning_loot_log",
+        "title": "延续:学习小侦探",
+        "quest_type": "manual",
+        "target": 1.0,
+        "unit": "次",
+        "exp_reward": 70,
+        "reason": "上周期未完成,Reflection 建议降低摩擦后继续"
+      },
+      {
+        "quest_id": "reflection_餐饮",
+        "title": "餐饮缓冲预算挑战",
+        "quest_type": "category_limit",
+        "target": 368.12,
+        "unit": "餐饮",
+        "exp_reward": 100,
+        "reason": "根据本月最大预算偏差生成"
+      }
+    ],
+    "narrative": "按“计划-实际-调整”来看,本月结余4010元、储蓄率55.69%表现稳健,但餐饮超支105.59元、学习超支67.8元是主要偏差,需优先收紧这两项预算;交通和健身小幅超支(3.6元、5.97元)可微调控制。下一步建议将餐饮预算下调约100元、学习预算下调约70元,并为未完成的2项任务设置每周检查点,避免下周期再次超支。"
+  },
+  "agent_trace": [
+    {
+      "agent": "MoneyMirrorCoordinator",
+      "architecture": "Transaction → Pattern → Persona → Goal → Quest → Reflection",
+      "runtime": {
+        "available": true,
+        "enabled": true,
+        "reason": "已启用 Hello-Agents + OpenAI 兼容 LLM:deepseek-v4-flash",
+        "registry_name": "HelloAgents ToolRegistry (8 MoneyMirror tools)",
+        "paradigms": [
+          "ReActAgent",
+          "PlanSolveAgent",
+          "ReflectionAgent",
+          "Context Engineering"
+        ],
+        "registered_tools": [
+          "CSVImportTool",
+          "TransactionCategoryTool",
+          "StatisticsTool",
+          "AnomalyDetectionTool",
+          "BudgetCalculatorTool",
+          "GoalProjectionTool",
+          "SubscriptionDetectorTool",
+          "QuestProgressTool"
+        ],
+        "provider": "OpenAI-compatible",
+        "model": "deepseek-v4-flash",
+        "base_url": "https://api.deepseek.com"
+      }
+    },
+    {
+      "agent": "TransactionAgent",
+      "paradigm": "ReActAgent-style: inspect transaction → consult memory/rules → resolve only uncertainty",
+      "classification_sources": {
+        "income_rule": 2,
+        "keyword_rule": 15
+      }
+    },
+    {
+      "agent": "PatternAgent",
+      "paradigm": "PlanAndSolveAgent-style: plan metrics → call statistical tools → return evidence",
+      "tools": [
+        "StatisticsTool",
+        "AnomalyDetectionTool",
+        "SubscriptionDetectorTool"
+      ],
+      "evidence": {
+        "late_night_count": 0,
+        "anomaly_count": 0,
+        "subscription_count": 1
+      },
+      "planning_note": "由于深夜消费与异常均为 0,且仅检出 1 项订阅,下一步应优先核对该订阅的账单周期与使用价值,以判断是否需要保留或取消。"
+    },
+    {
+      "agent": "PersonaAgent",
+      "paradigm": "Feature vector → configurable scoring → evidence validation → LLM narrative",
+      "config_path": "/home/nyc/hello-agents/Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/config/personas.json",
+      "grounded_metrics": {
+        "savings_rate": 55.69,
+        "late_night_share": 0.0,
+        "late_night_count": 0,
+        "weekend_share": 15.45,
+        "weekend_count": 3,
+        "payday_share": 71.72,
+        "frequent_small_count": 0,
+        "frequent_small_share": 0.0,
+        "food_share": 11.0,
+        "flexible_spend_share": 0.0,
+        "subscription_share": 0.0,
+        "subscription_count": 1,
+        "learning_share": 10.03,
+        "learning_active_months": 2,
+        "impulse": 30.0
+      },
+      "feature_vector": {
+        "night": 0.0,
+        "weekend": 36.25,
+        "frequent_small": 0.0,
+        "flexible_spend": 0.0,
+        "food": 27.51,
+        "subscription": 11.67,
+        "learning": 62.66,
+        "learning_consistency": 50.0,
+        "savings": 100.0,
+        "planning": 75.0,
+        "planning_inverse": 25.0,
+        "impulse": 30.0
+      },
+      "candidates": [
+        {
+          "archetype": "steady_planner",
+          "name": "稳健规划玩家",
+          "score": 85.0,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "learning_investor",
+          "name": "学习投入玩家",
+          "score": 59.49,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "weekend_experience",
+          "name": "周末体验玩家",
+          "score": 23.56,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "subscription_collector",
+          "name": "数字订阅整理师",
+          "score": 14.34,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "frequent_small_spend",
+          "name": "高频小额行动派",
+          "score": 10.13,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "late_night_focus",
+          "name": "夜行消费探索者",
+          "score": 7.5,
+          "evidence_valid": false
+        }
+      ],
+      "llm_role": "仅生成年轻化解释,不决定人格原型、分数或证据"
+    },
+    {
+      "agent": "GoalAgent",
+      "paradigm": "PlanAndSolveAgent-style goal feasibility projection",
+      "tools": [
+        "GoalProjectionTool"
+      ],
+      "goal_count": 0,
+      "planning_note": "将已验证的目标投影差距,拆解为当月可执行的储蓄或支出调整动作,并明确下次校验的时点与调整后金额。"
+    },
+    {
+      "agent": "QuestAgent",
+      "paradigm": "规则发现真实信号 → PlanSolveAgent 动态编排 → Python 强校验与进度计算",
+      "tools": [
+        "StatisticsTool",
+        "BudgetCalculatorTool",
+        "SubscriptionDetectorTool",
+        "QuestProgressTool"
+      ],
+      "signal_catalog": [
+        {
+          "signal_id": "subscriptions",
+          "priority": "required",
+          "verified_observation": "发现 1 项疑似连续扣费。",
+          "locked_constraint": "检查 1 项疑似连续扣费,并仅保留仍会使用的服务。",
+          "completion_source": "用户 CLI 确认后由 SQLite Memory 记录"
+        },
+        {
+          "signal_id": "learning_followthrough",
+          "priority": "optional",
+          "verified_observation": "学习类支出为 ¥320.00,占本月支出 10.03%。",
+          "locked_constraint": "记录 1 次学习服务是否真正被使用,并标注下次使用时间。",
+          "completion_source": "用户 CLI 确认后由 SQLite Memory 记录"
+        }
+      ],
+      "llm_orchestration": {
+        "candidate_count": 2,
+        "accepted_signal_ids": [
+          "subscriptions",
+          "learning_followthrough"
+        ],
+        "validation": {
+          "attempts": 1,
+          "repaired": false,
+          "rejected": []
+        },
+        "numeric_authority": "Python only: target / progress / EXP / status are derived from locked blueprints and QuestProgressTool."
+      },
+      "quest_evidence": [
+        "需要用户在后续 CLI 引导中确认完成",
+        "等待用户更新进度"
+      ]
+    },
+    {
+      "agent": "ReflectionAgent",
+      "paradigm": "ReflectionAgent-style: plan → actual → deviation → adjustment",
+      "context_sources": [
+        "SQLiteMemory: previous snapshot",
+        "budget",
+        "quest outcomes",
+        "goal projection"
+      ]
+    }
+  ],
+  "guided_conversation": [
+    {
+      "role": "user",
+      "content": "a"
+    },
+    {
+      "role": "assistant",
+      "content": "📱 好选择!用 App 记账最省心,不用靠脑袋硬记。\n\n**今晚 5 分钟搞定:**\n- 🔽 打开你常用的记账 App,把「餐饮」单独设成一个分类\n- 🏷️ 给这个分类加个 **90 元周额度** 的备注或标签\n- ⏰ 设个每晚 9 点的提醒:顺手记一笔,3 秒就够\n\n试跑一周,咱们周六看结果。万一某天忘了记,也别补到崩溃,记个大概就行。\n\n👉 你手机里已经有常用的记账 App 了吗?还是需要我陪你选一个?"
+    },
+    {
+      "role": "user",
+      "content": "已经有了"
+    },
+    {
+      "role": "assistant",
+      "content": "📱 有 App 就好办啦!我这边看到你 7 月餐饮花了 **351 元**,比建议线超了 **105.59 元**,是本月最大的超支项——所以我们才要启动这个 90 元周信封实验。\n\n**今晚 3 步开工:**\n- 🏷️ 在 App 里给「餐饮」分类加个标签:**周额度 90**\n- ⏰ 设每晚 9 点的记账提醒\n- 🧾 从明天第一杯咖啡/第一顿饭开始记,每笔都算数\n\n不用追求精确到分,记个大概金额就过关。\n\n👉 你打算从明天早上开始记第一笔,还是今晚就把今天的餐饮也补登一下?"
+    },
+    {
+      "role": "user",
+      "content": "现开始学习地图"
+    },
+    {
+      "role": "assistant",
+      "content": "🗺️ 学习地图已解锁!我这边看到你 7 月在学习上花了 **320 元**,超出了建议线 **67.8 元**,是目前值得留意的项目。这正好对应我们的 **学习小侦探** 任务——只做一件事:确认那项学习服务是否真的被用起来。\n\n**建议步骤:**\n- ➕ 打开支付记录,找到那笔 320 元的学习消费\n- 🔍 回想最近一次打开它是什么时候\n- 📅 顺手约定一个下次使用的时间\n\n先从一个问题开始吧:**最近一次打开那个学习服务,大概是哪天?** 记不准的话,猜个大概就行~"
+    }
+  ]
+}

+ 112 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_02_money_mirror_report.md

@@ -0,0 +1,112 @@
+# 🪞 MoneyMirror 月度报告 · 2026年7月
+
+> 本月关键词:储蓄率 55.69% ✅ · 发薪日小管家 · 餐饮需要温柔收紧
+
+## 一、财务镜像
+
+7 月收入 **7,200 元**,支出 **3,190 元**,结余 **4,010 元**。储蓄率达到 **55.69%**——相当于每赚到 100 元,你就稳稳留下了约 55.69 元,抗风险能力非常在线。
+
+本月共 **9 笔**交易,活跃记账 **8 天**,日均支出 **398.75 元**。整体来说,这是一份"支出有重点、结余很健康"的账单。
+
+## 二、消费分类与趋势
+
+**分类排行:**
+
+| 分类 | 金额 |
+|---|---|
+| 🏠 住房 | 2,200 元 |
+| 🍜 餐饮 | 351 元 |
+| 📚 学习 | 320 元(占比 10.0%) |
+| 🏋️ 健身 | 199 元 |
+| 🚌 交通 | 120 元 |
+
+**周度趋势:**
+- 7/6 当周:**2,374 元**(本月支出高峰)
+- 7/13 当周:**639 元**
+- 7/20 当周:**177 元**
+
+支出呈明显的"前重后轻":月初集中安排必要开销,中下旬逐步收敛,节奏感不错。
+
+## 三、行为模式
+
+- 🌙 深夜消费:**0 笔、0 元、占比 0%**——几乎从不在深夜冲动下单
+- 🎉 周末消费:3 笔、合计 **493 元**、占比 **15.45%**——周末也保持克制
+- 💰 发薪日窗口:2 笔、合计 **2,288 元**、占比 **71.72%**——钱到账后先按计划安排,再安心存下
+- 🔁 高频小额消费:**0 笔**——没有零碎小额刷屏
+
+## 四、异常消费解释
+
+**暂无异常数据**。本月没有触发异常识别的消费,整体支出结构健康,属于"稳稳的幸运月"。
+
+## 五、消费人格
+
+你的主人格是 **稳健规划玩家**(匹配分 85.0,置信度 0.85):储蓄率 55.7%、发薪日后集中消费占比 71.7%、深夜消费为 0——简直是一位自带节奏感的「发薪日小管家」。
+
+副人格 **学习投入玩家**(匹配分 59.49):学习消费占比 10.0%,且已有 2 个月出现学习消费,说明你在持续为自己充电。继续保持这份"理性 + 成长"的组合。
+
+## 六、订阅提醒
+
+📌 **健身房月卡**:已连续 2 个月扣费(2026-06、2026-07),每次 **199 元**。目前没有被判定为低价值,但如果最近去的次数变少,建议顺手问自己一句:"下个月我还会真的用吗?"不用就考虑取消。
+
+## 七、目标进度
+
+- 自定义储蓄 / 愿望目标:**暂无数据**
+- Money Quest:**2 个进行中**(见第九节),进度均为「未完成」
+
+## 八、动态预算与执行
+
+本月建议预算总额 **3,007.04 元**,实际支出 **3,190 元**。分项对照:
+
+| 分类 | 建议 | 实际 | 差额 |
+|---|---|---|---|
+| 交通 | 116.40 | 120.00 | +3.60 |
+| 住房 | 2,200.00 | 2,200.00 | 0.00 ✅ |
+| 健身 | 193.03 | 199.00 | +5.97 |
+| 学习 | 252.20 | 320.00 | +67.80 |
+| 餐饮 | 245.41 | 351.00 | +105.59 |
+
+预算原则是:固定支出不削减,必要支出参考历史中位数、温和调整。住房完美踩线;**餐饮和学习是两大超支项**;交通、健身属于轻微越线,微调即可。
+
+## 九、Money Quest
+
+1. 🧹 **订阅大扫除**:检查 1 项疑似连续扣费(健身房月卡),确认是否仍在使用。经验 +90,进度 0/1。
+2. 🕵️ **学习小侦探**:确认那笔学习服务是否真的被用起来,并约定下次使用时间。经验 +70,进度 0/1。
+
+## 十、等级与成就
+
+当前 **Lv.1**,总经验 0(本周期获得 0),连续记账 **3 天**(历史最长也是 3 天)。
+
+成就盘点:
+- ✅ 储蓄率破 20%(55.69% 远超门槛)
+- ✅ 任务上线
+- ✅ 三日无消费连击
+- ❌ 零消费日初体验——找一天挑战 0 支出吧
+- ❌ 深夜雷达启动——暂时用不上,因为你的深夜消费本来就是 0
+
+## 十一、月度 Reflection(计划-实际-调整)
+
+**做得好的**:预算和任务均基于真实账单计算,结论可靠;结余 4,010 元 + 储蓄率 55.69% 说明本月基本面很稳。
+
+**需要调整的**:餐饮(+105.59)、学习(+67.80)、健身(+5.97)、交通(+3.60)。其中餐饮是最大偏差项。
+
+**自动生成的 8 月建议预算**:
+
+| 分类 | 8 月建议 | 调整逻辑 |
+|---|---|---|
+| 餐饮 | **368.12** | 根据实际偏差增加缓冲,不搞一刀切 |
+| 学习 | **336.00** | 根据实际偏差增加缓冲 |
+| 健身 | **208.95** | 增加缓冲 |
+| 交通 | **126.00** | 增加缓冲,避免对一次性消费一刀切 |
+| 住房 | **2,200.00** | 固定支出,不做削减 |
+
+这个逻辑不是简单否定超支,而是“看见真实开销 → 留出弹性 → 让你更容易坚持”。
+
+## 十二、下一周期行动清单
+
+1. 🍜 **优先为餐饮保留明确额度**:在记账 App 里给「餐饮」设一个 **90 元/周**的额度标签,每晚 9 点提醒记账,试跑一周再看结果。
+2. 🧹 完成 **订阅大扫除**:打开支付记录核对健身房月卡,确认是否取消;这是上周期遗留任务,降低摩擦后继续。
+3. 🕵️ 完成 **学习小侦探**:给那笔 320 元的学习消费定一个具体的下次使用时间,比如"周三晚上 20:00 打开它"。
+4. 📅 为未完成任务设**每周检查点**,避免下周期再次堆积。
+5. 🎯 控制任务数量:下阶段只保留 2 个延续任务 + 1 个新增「餐饮缓冲预算挑战」(目标 **368.12 元**,经验 +100),不给执行添负担。
+
+## 安全边界

+ 779 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_03_money_mirror_report.json

@@ -0,0 +1,779 @@
+{
+  "user_id": "demo_user",
+  "month": "2026-07",
+  "transactions": [
+    {
+      "transaction_id": "19bedefd5cb8ad0f",
+      "occurred_at": "2026-06-08T09:00",
+      "merchant": "公司工资",
+      "amount": 6800.0,
+      "kind": "income",
+      "category": "收入",
+      "note": "六月工资到账",
+      "source": "data/sample_03.csv",
+      "category_confidence": 1.0
+    },
+    {
+      "transaction_id": "b1e4872d1e856cb9",
+      "occurred_at": "2026-06-08T10:00",
+      "merchant": "房东-六月房租",
+      "amount": 2100.0,
+      "kind": "expense",
+      "category": "住房",
+      "note": "固定住房",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "be9ffb5238c0dee7",
+      "occurred_at": "2026-06-13T15:00",
+      "merchant": "万达影院",
+      "amount": 75.0,
+      "kind": "expense",
+      "category": "娱乐",
+      "note": "周末电影",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "d8c3d605f2532ea0",
+      "occurred_at": "2026-06-14T19:00",
+      "merchant": "城市餐厅",
+      "amount": 180.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "周末聚餐",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "168cd0064e1f3593",
+      "occurred_at": "2026-06-20T16:00",
+      "merchant": "线下书店",
+      "amount": 90.0,
+      "kind": "expense",
+      "category": "学习",
+      "note": "周末阅读",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "fe720a3536477ac2",
+      "occurred_at": "2026-06-22T08:20",
+      "merchant": "地铁月票",
+      "amount": 120.0,
+      "kind": "expense",
+      "category": "交通",
+      "note": "通勤",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "a56c69fc1c3c93fc",
+      "occurred_at": "2026-07-06T09:00",
+      "merchant": "公司工资",
+      "amount": 6800.0,
+      "kind": "income",
+      "category": "收入",
+      "note": "七月工资到账",
+      "source": "data/sample_03.csv",
+      "category_confidence": 1.0
+    },
+    {
+      "transaction_id": "014e570569ba1507",
+      "occurred_at": "2026-07-06T10:00",
+      "merchant": "房东-七月房租",
+      "amount": 2100.0,
+      "kind": "expense",
+      "category": "住房",
+      "note": "固定住房",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "c6552c4de6ab1d5b",
+      "occurred_at": "2026-07-11T14:30",
+      "merchant": "独立咖啡馆",
+      "amount": 78.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "周末咖啡",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "4af74193df1ba577",
+      "occurred_at": "2026-07-11T20:00",
+      "merchant": "音乐节门票",
+      "amount": 360.0,
+      "kind": "expense",
+      "category": "娱乐",
+      "note": "周末娱乐",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "89acaefd172ba271",
+      "occurred_at": "2026-07-12T13:00",
+      "merchant": "城市餐厅",
+      "amount": 260.0,
+      "kind": "expense",
+      "category": "餐饮",
+      "note": "周末聚餐",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "1bc9ae722f87f132",
+      "occurred_at": "2026-07-12T17:00",
+      "merchant": "文创商场",
+      "amount": 420.0,
+      "kind": "expense",
+      "category": "购物",
+      "note": "周末购物",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "c41bab59e9eb2bdc",
+      "occurred_at": "2026-07-18T15:00",
+      "merchant": "万达影院",
+      "amount": 85.0,
+      "kind": "expense",
+      "category": "娱乐",
+      "note": "周末电影",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "58eb3d824496c01b",
+      "occurred_at": "2026-07-19T19:00",
+      "merchant": "剧本杀",
+      "amount": 168.0,
+      "kind": "expense",
+      "category": "娱乐",
+      "note": "周末娱乐",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    },
+    {
+      "transaction_id": "9a624a29e83da8b9",
+      "occurred_at": "2026-07-21T08:20",
+      "merchant": "地铁月票",
+      "amount": 120.0,
+      "kind": "expense",
+      "category": "交通",
+      "note": "通勤",
+      "source": "data/sample_03.csv",
+      "category_confidence": 0.92
+    }
+  ],
+  "summary": {
+    "transaction_count": 9,
+    "income": 6800.0,
+    "expense": 3591.0,
+    "balance": 3209.0,
+    "savings_rate": 47.19,
+    "average_expense": 448.88,
+    "active_days": 6
+  },
+  "category_breakdown": {
+    "住房": 2100.0,
+    "娱乐": 613.0,
+    "购物": 420.0,
+    "餐饮": 338.0,
+    "交通": 120.0
+  },
+  "trends": {
+    "daily": {
+      "2026-07-06": 2100.0,
+      "2026-07-11": 438.0,
+      "2026-07-12": 680.0,
+      "2026-07-18": 85.0,
+      "2026-07-19": 168.0,
+      "2026-07-21": 120.0
+    },
+    "weekly": {
+      "2026-07-06": 3218.0,
+      "2026-07-13": 253.0,
+      "2026-07-20": 120.0
+    },
+    "monthly": {
+      "2026-07": 3591.0
+    }
+  },
+  "patterns": {
+    "late_night": {
+      "count": 0,
+      "amount": 0,
+      "share": 0.0
+    },
+    "weekend": {
+      "count": 6,
+      "amount": 1371.0,
+      "share": 38.18
+    },
+    "payday_window": {
+      "count": 1,
+      "amount": 2100.0,
+      "share": 58.48
+    },
+    "frequent_small": {
+      "count": 0,
+      "amount": 0,
+      "average": 0.0
+    },
+    "category_spikes": {
+      "住房": 2100.0,
+      "娱乐": 613.0,
+      "购物": 420.0,
+      "餐饮": 338.0,
+      "交通": 120.0
+    }
+  },
+  "anomalies": [],
+  "subscriptions": [],
+  "persona": {
+    "primary": "周末体验玩家",
+    "archetype": "weekend_experience",
+    "score": 79.11,
+    "confidence": 0.79,
+    "labels": [
+      "周末体验玩家",
+      "弹性体验玩家",
+      "周末社交发动机"
+    ],
+    "secondary": [
+      {
+        "archetype": "flexible_adventurer",
+        "name": "弹性体验玩家",
+        "score": 74.18,
+        "confidence": 0.74,
+        "evidence": [
+          "娱乐与购物占比 28.8%",
+          "周末消费占比 38.2%",
+          "周末交易笔数 6",
+          "冲动消费特征分 30.0%"
+        ]
+      },
+      {
+        "archetype": "weekend_social",
+        "name": "周末社交发动机",
+        "score": 70.74,
+        "confidence": 0.71,
+        "evidence": [
+          "周末消费占比 38.2%",
+          "周末交易笔数 6",
+          "餐饮消费占比 9.4%",
+          "娱乐与购物占比 28.8%"
+        ]
+      }
+    ],
+    "evidence": [
+      "周末消费占比 38.2%",
+      "周末交易笔数 6",
+      "娱乐与购物占比 28.8%"
+    ],
+    "feature_vector": {
+      "night": 0.0,
+      "weekend": 82.98,
+      "frequent_small": 0.0,
+      "flexible_spend": 71.92,
+      "food": 23.53,
+      "subscription": 0.0,
+      "learning": 6.25,
+      "learning_consistency": 25.0,
+      "savings": 47.19,
+      "planning": 48.59,
+      "planning_inverse": 51.41,
+      "impulse": 30.0,
+      "payday": 100.0,
+      "impulse_inverse": 70.0
+    },
+    "narrative": "你的画像很贴近“周末体验玩家”(也可以叫你“周末享受派”),匹配分79.1,是会把快乐集中安排在休息日的人呢~  \n从证据看,你周末消费占比38.2%、周末有6笔交易,娱乐与购物也占到28.8%,这样专注犒劳自己,真的很会生活呀!"
+  },
+  "budget": {
+    "month": "2026-07",
+    "categories": {
+      "交通": {
+        "bucket": "necessary",
+        "historical_median": 120.0,
+        "recommended": 116.4,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "住房": {
+        "bucket": "fixed",
+        "historical_median": 2100.0,
+        "recommended": 2100.0,
+        "rationale": "固定支出,原则上不做大幅削减"
+      },
+      "娱乐": {
+        "bucket": "optional",
+        "historical_median": 75.0,
+        "recommended": 63.75,
+        "rationale": "可选支出,保留真实体验额度并设置温和上限"
+      },
+      "学习": {
+        "bucket": "necessary",
+        "historical_median": 90.0,
+        "recommended": 87.3,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      },
+      "购物": {
+        "bucket": "optional",
+        "historical_median": 0.0,
+        "recommended": 0.0,
+        "rationale": "可选支出,保留真实体验额度并设置温和上限"
+      },
+      "餐饮": {
+        "bucket": "necessary",
+        "historical_median": 180.0,
+        "recommended": 174.6,
+        "rationale": "必要支出,参考历史中位数并保留缓冲"
+      }
+    },
+    "recommended_total": 2542.05,
+    "historical_months": [
+      "2026-06"
+    ],
+    "principle": "基于历史中位数,固定支出不削减,必要/弹性/可选支出分层温和调整"
+  },
+  "goals": [],
+  "quests": [
+    {
+      "quest_id": "娱乐_budget",
+      "title": "娱乐精打细算",
+      "description": "娱乐开销超出动态预算,但别担心,调整节奏就能找回平衡。轻松规划,让每一笔都更从容。\n🧩 小提示:看看本月娱乐记录,挑出可延后的消费,把快乐分散到未来。\n🎯 已核验目标:娱乐支出不高于已核验的动态预算 ¥63.75。",
+      "quest_type": "category_limit",
+      "target": 63.75,
+      "progress": 0.0,
+      "unit": "娱乐",
+      "exp_reward": 120,
+      "status": "active",
+      "evidence": "娱乐 已消费 ¥613.00,预算上限 ¥63.75"
+    },
+    {
+      "quest_id": "weekend_wallet_shield",
+      "title": "周末轻盈计划",
+      "description": "周末消费占比偏高,试着安排免费或低开销活动,享受轻松时光。\n🧩 小提示:规划本周末一次无消费散步或家中游戏,记录感受。\n🎯 已核验目标:周末支出不高于已核验的温和目标 ¥1165.35。",
+      "quest_type": "weekend_spend_limit",
+      "target": 1165.35,
+      "progress": 0.0,
+      "unit": "元",
+      "exp_reward": 110,
+      "status": "active",
+      "evidence": "周末已消费 ¥1371.00,温和上限 ¥1165.35"
+    }
+  ],
+  "achievements": [
+    {
+      "key": "savings_rate_20",
+      "title": "储蓄率破 20%",
+      "description": "储蓄率首次达到或超过 20%。",
+      "unlocked": true
+    },
+    {
+      "key": "zero_spend_start",
+      "title": "零消费日初体验",
+      "description": "完成至少一个零消费日。",
+      "unlocked": false
+    },
+    {
+      "key": "quest_ready",
+      "title": "任务上线",
+      "description": "已生成基于真实账单的 Money Quest。",
+      "unlocked": true
+    },
+    {
+      "key": "late_night_awareness",
+      "title": "深夜雷达启动",
+      "description": "已识别深夜消费行为并生成应对任务。",
+      "unlocked": false
+    },
+    {
+      "key": "zero_spend_streak_3",
+      "title": "三日无消费连击",
+      "description": "连续 3 天没有支出记录。",
+      "unlocked": true
+    }
+  ],
+  "gamification": {
+    "level": 1,
+    "total_exp": 0,
+    "exp_gained_this_cycle": 0,
+    "current_streak_days": 5,
+    "longest_streak_days": 5
+  },
+  "reflection": {
+    "month": "2026-07",
+    "previous_month": "2026-06",
+    "has_previous_snapshot": true,
+    "budget_deviations": [
+      {
+        "category": "交通",
+        "planned": 116.4,
+        "actual": 120.0,
+        "difference": 3.6,
+        "on_budget": false
+      },
+      {
+        "category": "住房",
+        "planned": 2100.0,
+        "actual": 2100.0,
+        "difference": 0.0,
+        "on_budget": true
+      },
+      {
+        "category": "娱乐",
+        "planned": 63.75,
+        "actual": 613.0,
+        "difference": 549.25,
+        "on_budget": false
+      },
+      {
+        "category": "学习",
+        "planned": 87.3,
+        "actual": 0.0,
+        "difference": -87.3,
+        "on_budget": true
+      },
+      {
+        "category": "餐饮",
+        "planned": 174.6,
+        "actual": 338.0,
+        "difference": 163.4,
+        "on_budget": false
+      }
+    ],
+    "quest_completion": {
+      "completed": 0,
+      "total": 2
+    },
+    "goal_progress": {},
+    "effective": [
+      "预算和任务均基于实际账单计算"
+    ],
+    "needs_adjustment": [
+      "娱乐",
+      "餐饮",
+      "交通"
+    ],
+    "next_strategy": [
+      "优先为 娱乐 保留明确额度,而不是一刀切禁止消费。",
+      "下阶段只保留 2 个可执行任务,降低任务负担。"
+    ],
+    "next_cycle_month": "2026-08",
+    "next_cycle_budget": {
+      "source": "monthly_reflection",
+      "categories": {
+        "交通": {
+          "bucket": "necessary",
+          "historical_median": 120.0,
+          "recommended": 126.0,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "住房": {
+          "bucket": "fixed",
+          "historical_median": 2100.0,
+          "recommended": 2100.0,
+          "rationale": "固定支出,原则上不做大幅削减"
+        },
+        "娱乐": {
+          "bucket": "optional",
+          "historical_median": 75.0,
+          "recommended": 95.62,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        },
+        "学习": {
+          "bucket": "necessary",
+          "historical_median": 90.0,
+          "recommended": 87.3,
+          "rationale": "必要支出,参考历史中位数并保留缓冲"
+        },
+        "购物": {
+          "bucket": "optional",
+          "historical_median": 0.0,
+          "recommended": 0.0,
+          "rationale": "可选支出,保留真实体验额度并设置温和上限"
+        },
+        "餐饮": {
+          "bucket": "necessary",
+          "historical_median": 180.0,
+          "recommended": 261.9,
+          "rationale": "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+        }
+      }
+    },
+    "next_cycle_quests": [
+      {
+        "quest_id": "carry_娱乐_budget",
+        "title": "延续:娱乐精打细算",
+        "quest_type": "category_limit",
+        "target": 63.75,
+        "unit": "娱乐",
+        "exp_reward": 120,
+        "reason": "上周期未完成,Reflection 建议降低摩擦后继续"
+      },
+      {
+        "quest_id": "carry_weekend_wallet_shield",
+        "title": "延续:周末轻盈计划",
+        "quest_type": "weekend_spend_limit",
+        "target": 1165.35,
+        "unit": "元",
+        "exp_reward": 110,
+        "reason": "上周期未完成,Reflection 建议降低摩擦后继续"
+      },
+      {
+        "quest_id": "reflection_娱乐",
+        "title": "娱乐缓冲预算挑战",
+        "quest_type": "category_limit",
+        "target": 95.62,
+        "unit": "娱乐",
+        "exp_reward": 100,
+        "reason": "根据本月最大预算偏差生成"
+      }
+    ],
+    "narrative": "下周期请优先收紧娱乐(超支549.25)和餐饮(超支163.4)两类支出,将娱乐消费控制回63.75的计划水平,避免不必要的聚餐和冲动购物;同时把学习预算87.3真正用起来,安排一项技能或课程任务。最后,本周必须补上2项待办任务,结余和储蓄率虽健康,但任务完成率0/2会拖累长期目标。"
+  },
+  "agent_trace": [
+    {
+      "agent": "MoneyMirrorCoordinator",
+      "architecture": "Transaction → Pattern → Persona → Goal → Quest → Reflection",
+      "runtime": {
+        "available": true,
+        "enabled": true,
+        "reason": "已启用 Hello-Agents + OpenAI 兼容 LLM:deepseek-v4-flash",
+        "registry_name": "HelloAgents ToolRegistry (8 MoneyMirror tools)",
+        "paradigms": [
+          "ReActAgent",
+          "PlanSolveAgent",
+          "ReflectionAgent",
+          "Context Engineering"
+        ],
+        "registered_tools": [
+          "CSVImportTool",
+          "TransactionCategoryTool",
+          "StatisticsTool",
+          "AnomalyDetectionTool",
+          "BudgetCalculatorTool",
+          "GoalProjectionTool",
+          "SubscriptionDetectorTool",
+          "QuestProgressTool"
+        ],
+        "provider": "OpenAI-compatible",
+        "model": "deepseek-v4-flash",
+        "base_url": "https://api.deepseek.com"
+      }
+    },
+    {
+      "agent": "TransactionAgent",
+      "paradigm": "ReActAgent-style: inspect transaction → consult memory/rules → resolve only uncertainty",
+      "classification_sources": {
+        "income_rule": 2,
+        "keyword_rule": 13
+      }
+    },
+    {
+      "agent": "PatternAgent",
+      "paradigm": "PlanAndSolveAgent-style: plan metrics → call statistical tools → return evidence",
+      "tools": [
+        "StatisticsTool",
+        "AnomalyDetectionTool",
+        "SubscriptionDetectorTool"
+      ],
+      "evidence": {
+        "late_night_count": 0,
+        "anomaly_count": 0,
+        "subscription_count": 0
+      },
+      "planning_note": "鉴于已验证的夜间消费、异常交易和订阅扣款计数均为零,下一步应将分析重心转向常规消费类别与时间分布,而非排查异常项。"
+    },
+    {
+      "agent": "PersonaAgent",
+      "paradigm": "Feature vector → configurable scoring → evidence validation → LLM narrative",
+      "config_path": "/home/nyc/hello-agents/Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/config/personas.json",
+      "grounded_metrics": {
+        "savings_rate": 47.19,
+        "late_night_share": 0.0,
+        "late_night_count": 0,
+        "weekend_share": 38.18,
+        "weekend_count": 6,
+        "payday_share": 58.48,
+        "frequent_small_count": 0,
+        "frequent_small_share": 0.0,
+        "food_share": 9.41,
+        "flexible_spend_share": 28.77,
+        "subscription_share": 0.0,
+        "subscription_count": 0,
+        "learning_share": 0.0,
+        "learning_active_months": 1,
+        "impulse": 30.0
+      },
+      "feature_vector": {
+        "night": 0.0,
+        "weekend": 82.98,
+        "frequent_small": 0.0,
+        "flexible_spend": 71.92,
+        "food": 23.53,
+        "subscription": 0.0,
+        "learning": 6.25,
+        "learning_consistency": 25.0,
+        "savings": 47.19,
+        "planning": 48.59,
+        "planning_inverse": 51.41,
+        "impulse": 30.0,
+        "payday": 100.0,
+        "impulse_inverse": 70.0
+      },
+      "candidates": [
+        {
+          "archetype": "weekend_experience",
+          "name": "周末体验玩家",
+          "score": 79.11,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "flexible_adventurer",
+          "name": "弹性体验玩家",
+          "score": 74.18,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "payday_rhythm",
+          "name": "发薪节奏管理者",
+          "score": 71.29,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "weekend_social",
+          "name": "周末社交发动机",
+          "score": 70.74,
+          "evidence_valid": true
+        },
+        {
+          "archetype": "mindful_minimalist",
+          "name": "清醒消费实践者",
+          "score": 53.52,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "steady_planner",
+          "name": "稳健规划玩家",
+          "score": 48.03,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "savings_sprinter",
+          "name": "储蓄冲刺玩家",
+          "score": 47.68,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "food_routine",
+          "name": "日常餐饮探索家",
+          "score": 25.39,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "learning_consistent",
+          "name": "学习成长玩家",
+          "score": 17.5,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "digital_lifestyle",
+          "name": "数字生活玩家",
+          "score": 16.45,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "learning_investor",
+          "name": "学习投入玩家",
+          "score": 10.94,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "subscription_collector",
+          "name": "数字订阅整理师",
+          "score": 10.28,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "frequent_small_spend",
+          "name": "高频小额行动派",
+          "score": 9.53,
+          "evidence_valid": false
+        },
+        {
+          "archetype": "late_night_focus",
+          "name": "夜行消费探索者",
+          "score": 7.5,
+          "evidence_valid": false
+        }
+      ],
+      "llm_role": "仅生成年轻化解释,不决定人格原型、分数或证据"
+    },
+    {
+      "agent": "GoalAgent",
+      "paradigm": "PlanAndSolveAgent-style goal feasibility projection",
+      "tools": [
+        "GoalProjectionTool"
+      ],
+      "goal_count": 0,
+      "planning_note": "将目标投影与实际余额的差额拆解为本周期可执行的储蓄或支出限额,并即刻设定下一次预算检查点,用工具持续校验进度。"
+    },
+    {
+      "agent": "QuestAgent",
+      "paradigm": "规则发现真实信号 → PlanSolveAgent 动态编排 → Python 强校验与进度计算",
+      "tools": [
+        "StatisticsTool",
+        "BudgetCalculatorTool",
+        "SubscriptionDetectorTool",
+        "QuestProgressTool"
+      ],
+      "signal_catalog": [
+        {
+          "signal_id": "flexible_budget",
+          "priority": "required",
+          "verified_observation": "娱乐本月已支出 ¥613.00;动态预算为 ¥63.75。",
+          "locked_constraint": "娱乐支出不高于已核验的动态预算 ¥63.75。",
+          "completion_source": "Python QuestProgressTool"
+        },
+        {
+          "signal_id": "weekend",
+          "priority": "optional",
+          "verified_observation": "周末消费 6 笔,占本月支出 38.18%。",
+          "locked_constraint": "周末支出不高于已核验的温和目标 ¥1165.35。",
+          "completion_source": "Python QuestProgressTool"
+        }
+      ],
+      "llm_orchestration": {
+        "candidate_count": 2,
+        "accepted_signal_ids": [
+          "flexible_budget",
+          "weekend"
+        ],
+        "validation": {
+          "attempts": 1,
+          "repaired": false,
+          "rejected": []
+        },
+        "numeric_authority": "Python only: target / progress / EXP / status are derived from locked blueprints and QuestProgressTool."
+      },
+      "quest_evidence": [
+        "娱乐 已消费 ¥613.00,预算上限 ¥63.75",
+        "周末已消费 ¥1371.00,温和上限 ¥1165.35"
+      ]
+    },
+    {
+      "agent": "ReflectionAgent",
+      "paradigm": "ReflectionAgent-style: plan → actual → deviation → adjustment",
+      "context_sources": [
+        "SQLiteMemory: previous snapshot",
+        "budget",
+        "quest outcomes",
+        "goal projection"
+      ]
+    }
+  ]
+}

+ 145 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/outputs/sample_03_money_mirror_report.md

@@ -0,0 +1,145 @@
+# MoneyMirrorAgent 月度报告 | 2026年7月
+
+## 📊 财务镜像
+
+本月你的收入为 **¥6,800**,总支出 **¥3,591**,结余 **¥3,209**。储蓄率达到了 **47.19%**——几乎一半的收入留了下来,这个基础很扎实。全月共 **9 笔**交易,平均每笔支出 **¥448.88**,共有 **6 天**产生消费。值得注意的是,本月没有发生深夜消费,零消费日的连续记录也达到了 **5 天**,整体节奏保持得相当清爽。
+
+---
+
+## 📂 消费分类与趋势
+
+本月支出按类别分布如下:
+
+| 类别 | 金额 | 占比 |
+|------|------|------|
+| 住房 | ¥2,100 | 58.5% |
+| 娱乐 | ¥613 | 17.1% |
+| 购物 | ¥420 | 11.7% |
+| 餐饮 | ¥338 | 9.4% |
+| 交通 | ¥120 | 3.3% |
+
+从时间维度看,月初的 **7/6 那周支出 ¥3,218**,占到了全月近九成;随后两周迅速回落(7/13 周 ¥253,7/20 周 ¥120)。这个形态说明:月初集中支付住房(¥2,100)叠加发薪日的犒劳型消费后,后半程的自我控制力其实很不错。
+
+---
+
+## 🧭 行为模式
+
+- **发薪日窗口效应明显**:发薪日附近支出 **¥2,100**,占全月 **58.48%**——主要是住房固定支出,属正常节奏。
+- **周末消费集中**:周末共有 **6 笔**交易,合计 **¥1,371**,占比 **38.18%**。娱乐和购物占全月 **28.8%**,集中在周末犒劳自己。
+- **无高频小额消费**:没有频繁点外卖或买小东西的迹象,消费决策偏向集中、有目的性。
+- **学习投入为 0**:尽管有 ¥87.3 的学习预算,本月实际未使用。
+
+---
+
+## ⚠️ 异常消费解释
+
+本月未检测到超出常规范围的异常消费。娱乐消费 ¥613 虽远高于预算建议值,但属于**可解释的模式性消费**——主要由周末体验型支出构成,并非突发性失控。暂时无需特别干预,但值得在下一周期留意额度。
+
+---
+
+## 🎭 消费人格
+
+本月最匹配的消费人格是 **「周末体验玩家」**,匹配度 **79.1 分**。
+
+你属于那种把快乐集中安排在休息日的人——周末消费占比 38.2%,6 笔周末交易里藏着不少娱乐和购物的身影,占比合计 28.8%。你的次级人格包括「弹性体验玩家」(74.2 分)和「周末社交发动机」(70.7 分),特征是说走就走、用消费买体验,但也会回应社交需求。
+
+这并不算缺点,关键是**给体验留出额度,同时不让它悄悄膨胀**。
+
+---
+
+## 🔔 订阅提醒
+
+本月暂无数据——未检测到任何订阅类支出,不用操心自动扣费的问题,继续保持就好。
+
+---
+
+## 🎯 目标进度
+
+暂无数据——目前还没有设置储蓄或消费目标。建议下个月可以设定一个具体的娱乐支出上限目标,让「想花的」和「能花的」更一致。
+
+---
+
+## 💡 动态预算
+
+系统基于历史中位数生成了 7 月动态预算,实际执行情况如下:
+
+| 类别 | 预算参考 | 实际支出 | 状态 |
+|------|---------|---------|------|
+| 住房 | ¥2,100 | ¥2,100 | ✅ 持平 |
+| 交通 | ¥116.4 | ¥120 | ⚠️ 微超 ¥3.6 |
+| 餐饮 | ¥174.6 | ¥338 | ❌ 超支 ¥163.4 |
+| 娱乐 | ¥63.75 | ¥613 | ❌ 超支 ¥549.25 |
+| 学习 | ¥87.3 | ¥0 | ✅ 未使用 |
+| 购物 | ¥0 | ¥420 | ⚠️ 未规划消费 |
+
+整体建议预算 ¥2,542.05,实际支出 ¥3,591,超预算约 ¥1,049。娱乐是最大的偏差来源,一次性消费特征明显,属于「集中犒劳」而非「细水长流」。
+
+---
+
+## 🎮 Money Quest
+
+本月已上线 **2 个任务**,目前完成度 **0/2**:
+
+1. **「娱乐精打细算」**:娱乐支出不高于 **¥63.75**,奖励经验 120。已消费 ¥613,尚需大幅收敛。
+2. **「周末轻盈计划」**:周末支出不高于 **¥1,165.35**,奖励经验 110。已消费 ¥1,371,超出约 ¥205.65。
+
+这两个任务的设计思路是「卡住上限、保留弹性」,不是完全不让你花,而是让每一笔享受都更有计划感。本月未完成没关系,下周期会延续,难度不变。
+
+---
+
+## 🏆 等级与成就
+
+- **当前等级**:Lv.1,总经验 0(本期暂未获得新经验)
+- **连续记录**:连续 5 天零消费,最长连续 5 天
+
+**已解锁成就**:
+- 🏅 **储蓄率破 20%**:本月储蓄率 47.19%,远超门槛
+- 🏅 **任务上线**:已基于真实账单生成 Money Quest
+- 🏅 **三日无消费连击**:连续 3 天无支出记录 ✅
+
+**未解锁成就**:
+- 🔒 零消费日初体验(需要至少一天完整零消费)
+- 🔒 深夜雷达启动(需识别到深夜消费行为并应对)
+
+---
+
+## 🌙 月度 Reflection
+
+**做得好的**:储蓄率 47.19% 非常健康;月末消费明显收缩;没有深夜消费;零消费日连续 5 天——你的「自控后劲」其实很强。
+
+**需要调整的**:
+- **娱乐**:超支 ¥549.25,最大偏差来源。建议保留「可玩额度」,而不是用最低值一刀切。
+- **餐饮**:超支 ¥163.4,可能源于周末社交聚餐。不需要完全杜绝,但可以提前设定「每周末最多一顿外食」。
+- **交通**:微超 ¥3.6,影响不大,但说明预算缓冲可以更贴合实际。
+
+**被忽视的**:学习预算 ¥87.3 完全没有使用。可能不是没兴趣,而是没安排具体任务——下个月把「学点什么」变成一个明确动作。
+
+**待办事项**:共 2 项任务,完成率为 0/2。虽然结余好看,但任务进度若长期停滞,会拖累成长体系。
+
+---
+
+## 🚀 下一周期行动清单(2026年8月)
+
+基于 7 月反思,8 月的预算与任务已做针对性调整:
+
+**新预算亮点**:
+- 娱乐预算上调至 **¥95.62**(基于实际偏差增加缓冲,不再用历史最低值压人)
+- 餐饮预算上调至 **¥261.9**(承认社交聚餐是真实需求,给足空间)
+- 交通预算微调至 **¥126**,住房维持 **¥2,100**,学习保持 **¥87.3**
+
+**延续任务**:
+1. 🔄 **延续:娱乐精打细算** —— 目标 ¥63.75(如果觉得太紧,可以按新预算 ¥95.62 来看待自己的进步空间)
+2. 🔄 **延续:周末轻盈计划** —— 目标 ¥1,165.35 周末总支出
+
+**新任务**:
+3. 🆕 **娱乐缓冲预算挑战** —— 目标 ≤ ¥95.62,奖励经验 100。结合 7 月的真实节奏,先用这个温和目标找回掌控感。
+
+**行动建议**:
+1. 把娱乐预算 ¥95.62 拆成一个「周末快乐金」,花之前看一眼余额;
+2. 给周末提前安排 1 项免费活动(公园散步、宅家游戏),降低消费冲动;
+3. 把学习预算用起来——报一个 ¥87.3 内的线上短课或买一本书;
+4. 每周日花 3 分钟记录当周消费,防止月初大手大脚、月末被动收缩。
+
+---
+
+下个月再见面时,希望你的娱乐支出离预算上限更近一点,任务完成率从 0/2 变成 2/2 甚至 3/3。你已经证明自己能存下钱,接下来只需要证明:**会花的人,也能花得漂亮。** 🌱

+ 9 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/requirements.txt

@@ -0,0 +1,9 @@
+hello-agents>=1.0.0
+python-dotenv>=1.0.0
+
+
+# Development / verification
+pytest>=8.0.0
+
+# Chapter 16 notebook entry point
+jupyter>=1.0.0

+ 5 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/__init__.py

@@ -0,0 +1,5 @@
+"""MoneyMirrorAgent package."""
+
+from .agents.coordinator import MoneyMirrorCoordinator
+
+__all__ = ["MoneyMirrorCoordinator"]

+ 5 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/__init__.py

@@ -0,0 +1,5 @@
+"""Agent roles and the MoneyMirror coordinator."""
+
+from .coordinator import MoneyMirrorCoordinator
+
+__all__ = ["MoneyMirrorCoordinator"]

+ 134 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/conversation_agent.py

@@ -0,0 +1,134 @@
+"""Stateful guided conversation before the final report is generated."""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Iterable
+
+from .runtime import HelloAgentsRuntime
+
+
+class ConversationAgent:
+    """Turn a static analysis into a step-by-step financial coaching session.
+
+    The conversation deliberately receives a *presentation payload*, not the
+    complete ``AnalysisReport``.  Transactions and agent traces are useful for
+    local debugging, but sending them on every turn makes the LLM context noisy
+    and can hide the verified facts that the coach must use.
+    """
+
+    paradigm = "Interactive Hello-Agents coaching: observe → suggest → ask → reflect"
+    MAX_HISTORY_ITEMS = 6
+    MAX_MESSAGE_CHARS = 600
+    MAX_ANOMALIES = 6
+    MAX_QUESTS = 6
+    MAX_SUBSCRIPTIONS = 8
+
+    def __init__(self, runtime: HelloAgentsRuntime) -> None:
+        self.runtime = runtime
+
+    @classmethod
+    def compact_report(cls, report: Any, conversation: Iterable[dict[str, str]] | None = None) -> dict[str, Any]:
+        """Create the small, verified fact packet used by conversational LLM calls.
+
+        Numeric values are copied from deterministic tools; this method only
+        removes high-volume detail and never calculates a replacement number.
+        In particular, raw transactions and ``agent_trace`` are intentionally
+        excluded from prompts to avoid context truncation and accidental model
+        hallucinations.
+        """
+        category_breakdown = dict(getattr(report, "category_breakdown", {}) or {})
+        # Keep all categories: the demo has only a small fixed category set and
+        # the user should be able to ask about any one of them.
+        trends = dict(getattr(report, "trends", {}) or {})
+        trend_summary: dict[str, Any] = {}
+        for key in ("weekly", "monthly"):
+            values = trends.get(key)
+            if isinstance(values, dict):
+                trend_summary[key] = values
+        patterns = dict(getattr(report, "patterns", {}) or {})
+        # These pattern objects are already compact deterministic summaries.
+        selected_patterns = {
+            key: patterns[key]
+            for key in ("late_night", "weekend", "payday_window", "frequent_small", "category_spikes")
+            if key in patterns
+        }
+        budget = getattr(report, "budget", {}) or {}
+        compact_budget = dict(budget)
+        if isinstance(budget, dict) and isinstance(budget.get("categories"), dict):
+            compact_budget["categories"] = budget["categories"]
+
+        def item_dict(item: Any) -> dict[str, Any]:
+            if hasattr(item, "to_dict"):
+                return item.to_dict()
+            return dict(item) if isinstance(item, dict) else {"value": str(item)}
+
+        compact: dict[str, Any] = {
+            "month": getattr(report, "month", ""),
+            "summary": getattr(report, "summary", {}) or {},
+            "category_breakdown": category_breakdown,
+            "trends": trend_summary,
+            "patterns": selected_patterns,
+            "anomalies": [item_dict(item) for item in list(getattr(report, "anomalies", []) or [])[: cls.MAX_ANOMALIES]],
+            "subscriptions": list(getattr(report, "subscriptions", []) or [])[: cls.MAX_SUBSCRIPTIONS],
+            "persona": getattr(report, "persona", {}) or {},
+            "budget": compact_budget,
+            "goals": list(getattr(report, "goals", []) or []),
+            "quests": [item_dict(item) for item in list(getattr(report, "quests", []) or [])[: cls.MAX_QUESTS]],
+            "achievements": [item_dict(item) for item in list(getattr(report, "achievements", []) or [])],
+            "gamification": getattr(report, "gamification", {}) or {},
+            "reflection": getattr(report, "reflection", {}) or {},
+        }
+        if conversation:
+            compact["guided_conversation"] = cls.compact_conversation(conversation)
+        return compact
+
+    @classmethod
+    def compact_conversation(cls, conversation: Iterable[dict[str, str]]) -> list[dict[str, str]]:
+        """Keep the most recent dialogue turns within a predictable size."""
+        items = list(conversation)[-cls.MAX_HISTORY_ITEMS :]
+        result: list[dict[str, str]] = []
+        for item in items:
+            content = str(item.get("content", "")).strip()
+            if len(content) > cls.MAX_MESSAGE_CHARS:
+                content = content[: cls.MAX_MESSAGE_CHARS].rstrip() + "…"
+            result.append({"role": str(item.get("role", "user")), "content": content})
+        return result
+
+    @classmethod
+    def payload(cls, report: Any, conversation: Iterable[dict[str, str]] | None = None) -> str:
+        """Serialize a compact packet with an explicit verified-facts marker."""
+        return json.dumps(
+            {"[Verified tool output]": cls.compact_report(report, conversation)},
+            ensure_ascii=False,
+            separators=(",", ":"),
+        )
+
+    def opening(self, report: Any) -> str:
+        compact = self.compact_report(report)
+        summary = compact.get("summary", {})
+        patterns = compact.get("patterns", {})
+        persona = compact.get("persona", {})
+        focus = (
+            f"当前分析月份={compact.get('month')};收入={summary.get('income')};"
+            f"支出={summary.get('expense')};结余={summary.get('balance')};"
+            f"储蓄率={summary.get('savings_rate')}%;消费人格={persona.get('primary')};"
+            f"深夜消费={patterns.get('late_night')};周末消费={patterns.get('weekend')}。"
+        )
+        return self.runtime.explain(
+            "为用户开启 MoneyMirror 的第一关引导,而不是生成报告。你必须使用下面这行已核验焦点中的至少一个真实数字,"
+            "不能说‘没有数据’或要求用户重新上传账单。请严格按顺序输出:"
+            "(1) 用一个有趣的镜像/RPG昵称,引用真实消费现象;"
+            "(2) 给一个今天就能完成、不会一刀切的微行动;"
+            "(3) 只问一个选择题式追问,让用户选择最想先改善的消费场景。"
+            "不要输出‘结论/依据/风险/下一步’的机械模板,不要生成最终 Markdown 报告,"
+            "控制在 100-160 字,使用少量 emoji。\n"
+            f"已核验焦点:{focus}",
+            mode="simple",
+            evidence=[self.payload(report)],
+        )
+
+    def respond(self, report: Any, question: str, history: Iterable[dict[str, str]]) -> str:
+        """Non-streaming counterpart used by CLI and tests."""
+        chunks = self.runtime.stream_user_guidance(question, self.payload(report), self.compact_conversation(history))
+        return "".join(chunks).strip()

+ 201 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/coordinator.py

@@ -0,0 +1,201 @@
+"""MoneyMirrorCoordinator orchestrates specialized agents around deterministic tools."""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from ..memory import SQLiteMemory
+from ..models import AnalysisReport, Goal
+from ..tools import BudgetCalculatorTool, CSVImportTool, StatisticsTool
+from ..tools.anomaly_detection import AnomalyDetectionTool
+from ..tools.goal_projection import GoalProjectionTool
+from ..tools.hello_agents_registry import build_registry_functions
+from ..tools.quest_progress import QuestProgressTool
+from ..tools.subscription_detector import SubscriptionDetectorTool
+from ..tools.transaction_category import TransactionCategoryTool
+from .conversation_agent import ConversationAgent
+from .goal_agent import GoalAgent
+from .pattern_agent import PatternAgent
+from .persona_agent import PersonaAgent
+from .quest_agent import QuestAgent
+from .reflection_agent import ReflectionAgent
+from .runtime import HelloAgentsRuntime
+from .transaction_agent import TransactionAgent
+
+
+class MoneyMirrorCoordinator:
+    """A multi-agent pipeline that keeps numeric work local and reproducible.
+
+    Flow: CSV import → TransactionAgent → PatternAgent → PersonaAgent → GoalAgent
+    → QuestAgent → ReflectionAgent. Each agent adds either planning, context, or
+    interpretation; all calculation-heavy decisions come from explicit tools.
+    """
+
+    def __init__(
+        self,
+        db_path: str | Path = "outputs/moneymirror.db",
+        user_id: str = "local_user",
+        runtime: HelloAgentsRuntime | None = None,
+    ) -> None:
+        self.memory = SQLiteMemory(db_path, user_id)
+        self.runtime = runtime or HelloAgentsRuntime()
+        self.csv_import = CSVImportTool()
+        self.statistics = StatisticsTool()
+        self.budget_tool = BudgetCalculatorTool()
+        self.transaction_agent = TransactionAgent(self.memory, self.runtime)
+        self.conversation_agent = ConversationAgent(self.runtime)
+        self.pattern_agent = PatternAgent(self.runtime)
+        self.persona_agent = PersonaAgent(self.runtime)
+        self.goal_agent = GoalAgent(self.memory, self.runtime)
+        self.quest_agent = QuestAgent(self.memory, self.runtime)
+        self.reflection_agent = ReflectionAgent(self.memory, self.runtime)
+        # Expose the deterministic tools to an optional Hello-Agents ReAct
+        # agent. The coordinator still invokes these tools directly, so an LLM
+        # cannot alter financial arithmetic or bypass Memory precedence.
+        self.runtime.register_tool_functions(build_registry_functions(
+            self.csv_import,
+            TransactionCategoryTool(),
+            self.statistics,
+            AnomalyDetectionTool(),
+            self.budget_tool,
+            GoalProjectionTool(),
+            SubscriptionDetectorTool(),
+            QuestProgressTool(),
+            self.memory.get_merchant_category,
+        ))
+
+    def close(self) -> None:
+        self.memory.close()
+
+    def correct_merchant_category(self, merchant: str, category: str) -> None:
+        """Persist a user correction so identical merchants use Memory next time."""
+        self.transaction_agent.correct_category(merchant, category)
+
+    def add_goal(self, goal: Goal) -> None:
+        self.memory.save_goal(goal)
+
+    def complete_quest(self, report: AnalysisReport, quest_id: str, note: str = "") -> dict:
+        """Persist a user-confirmed non-transactional Quest from the CLI."""
+        quest = next((item for item in report.quests if item.quest_id == quest_id), None)
+        if quest is None:
+            available = ", ".join(item.quest_id for item in report.quests)
+            raise ValueError(f"未找到 Quest:{quest_id}。可用 Quest ID:{available}")
+        result = self.quest_agent.complete_manual_quest(quest, report.month, note)
+        report.gamification["total_exp"] = result["total_exp"]
+        report.gamification["exp_gained_this_cycle"] = report.gamification.get("exp_gained_this_cycle", 0) + result["gained_exp"]
+        report.gamification["level"] = result["level"]
+        return result
+
+    def analyze_csv(self, csv_path: str | Path, month: str | None = None) -> AnalysisReport:
+        imported = self.csv_import.load(csv_path)
+        return self.analyze_transactions(imported, month, import_errors=self.csv_import.last_errors)
+
+    def analyze_transactions(
+        self,
+        imported: list,
+        month: str | None = None,
+        import_errors: list[str] | None = None,
+    ) -> AnalysisReport:
+        transactions, transaction_trace = self.transaction_agent.run(imported)
+        if import_errors:
+            transaction_trace["csv_import_warnings"] = list(import_errors)
+        months = self.statistics.month_keys(transactions)
+        if not months:
+            raise ValueError("没有可用于分析的交易记录")
+        active_month = month or months[-1]
+        if active_month not in months:
+            raise ValueError(f"账单中不存在月份 {active_month}")
+        summary = self.statistics.summarize(transactions, active_month)
+        categories = self.statistics.category_breakdown(transactions, active_month)
+        trends = self.statistics.trends(transactions, active_month)
+        patterns, anomalies, subscriptions, pattern_trace = self.pattern_agent.run(transactions, active_month)
+        budget = self.budget_tool.calculate(transactions, active_month)
+        self.memory.save_budget(active_month, budget)
+        persona, persona_trace = self.persona_agent.run(summary, categories, patterns, subscriptions, transactions)
+        goals, goal_trace = self.goal_agent.run(transactions, active_month)
+        quests, achievements, gamification, quest_trace = self.quest_agent.run(
+            transactions, active_month, summary, categories, patterns, subscriptions, budget, goals
+        )
+
+        # Create a historical baseline snapshot before reflecting on the latest month.
+        previous_month = self.reflection_agent._previous_month(active_month)
+        if previous_month in months and not self.memory.get_snapshot(previous_month):
+            previous_summary = self.statistics.summarize(transactions, previous_month)
+            previous_categories = self.statistics.category_breakdown(transactions, previous_month)
+            self.memory.save_snapshot(previous_month, {"summary": previous_summary, "category_breakdown": previous_categories, "source": "auto_baseline"})
+
+        reflection, reflection_trace = self.reflection_agent.run(active_month, summary, categories, budget, quests, goals)
+        report = AnalysisReport(
+            user_id=self.memory.user_id,
+            month=active_month,
+            transactions=transactions,
+            summary=summary,
+            category_breakdown=categories,
+            trends=trends,
+            patterns=patterns,
+            anomalies=anomalies,
+            subscriptions=subscriptions,
+            persona=persona,
+            budget=budget,
+            goals=goals,
+            quests=quests,
+            achievements=achievements,
+            gamification=gamification,
+            reflection=reflection,
+            agent_trace=[
+                {"agent": "MoneyMirrorCoordinator", "architecture": "Transaction → Pattern → Persona → Goal → Quest → Reflection", "runtime": self.runtime.status_dict()},
+                transaction_trace,
+                pattern_trace,
+                persona_trace,
+                goal_trace,
+                quest_trace,
+                reflection_trace,
+            ],
+        )
+        self.memory.save_snapshot(active_month, {"summary": summary, "category_breakdown": categories, "persona": persona, "budget": budget, "goal_projections": goals, "quest_completion": reflection["quest_completion"], "reflection": reflection})
+        return report
+
+    @staticmethod
+    def _output_stem(source_csv: str | Path | None) -> str:
+        """Build a readable, filesystem-safe report stem from the input CSV."""
+        if source_csv is None:
+            return "money_mirror_report"
+        stem = Path(source_csv).stem.strip()
+        safe_stem = re.sub(r"[^\w.-]+", "_", stem, flags=re.UNICODE).strip("._")
+        return f"{safe_stem or 'transactions'}_money_mirror_report"
+
+    def write_outputs(
+        self,
+        report: AnalysisReport,
+        output_dir: str | Path = "outputs",
+        conversation: list[dict[str, str]] | None = None,
+        source_csv: str | Path | None = None,
+    ) -> tuple[Path, Path]:
+        """Persist JSON facts and an LLM-authored Markdown report.
+
+        The Markdown file is intentionally generated only here, after the
+        user finishes the guided conversation. There is no local template
+        fallback: a report is never presented as LLM-generated unless the
+        configured Hello-Agents model actually returned it.
+        """
+        directory = Path(output_dir)
+        directory.mkdir(parents=True, exist_ok=True)
+        output_stem = self._output_stem(source_csv)
+        json_path = directory / f"{output_stem}.json"
+        markdown_path = directory / f"{output_stem}.md"
+        # Keep the persisted JSON complete for auditability, but send only a
+        # compact verified presentation packet to the LLM. Raw transactions and
+        # agent traces are not needed for narrative generation and can crowd out
+        # the facts the model must cite.
+        payload = report.to_dict()
+        if conversation:
+            payload["guided_conversation"] = self.conversation_agent.compact_conversation(conversation)
+            self.memory.set_preference("last_guided_conversation", payload["guided_conversation"])
+        json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+        markdown = self.runtime.generate_markdown(
+            self.conversation_agent.payload(report, conversation)
+        )
+        markdown_path.write_text(markdown, encoding="utf-8")
+        return json_path, markdown_path

+ 36 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/goal_agent.py

@@ -0,0 +1,36 @@
+"""GoalAgent: feasibility comes from GoalProjectionTool, not an LLM guess."""
+
+from __future__ import annotations
+
+import json
+from typing import Iterable
+
+from ..memory import SQLiteMemory
+from ..models import Goal, Transaction
+from ..tools.goal_projection import GoalProjectionTool
+from .runtime import HelloAgentsRuntime
+
+
+class GoalAgent:
+    paradigm = "PlanAndSolveAgent-style goal feasibility projection"
+
+    def __init__(self, memory: SQLiteMemory, runtime: HelloAgentsRuntime | None = None) -> None:
+        self.memory = memory
+        self.runtime = runtime
+        self.tool = GoalProjectionTool()
+
+    def run(self, transactions: Iterable[Transaction], month: str) -> tuple[list[dict], dict]:
+        goals = [Goal(**item) for item in self.memory.list_goals(active_only=True)]
+        results = [self.tool.project(goal, transactions, month) for goal in goals]
+        planning_note = (
+            "GoalProjectionTool 已根据现金流、历史结余和截止日期计算可行性。"
+            if results
+            else "当前没有已保存的财务目标;可通过 CLI 的 Memory 配置或后续输入创建目标,避免替真实用户臆造目标。"
+        )
+        if self.runtime is not None:
+            planning_note = self.runtime.explain(
+                "请用一句话说明如何把目标投影转成下一步行动;不得修改工具计算出的金额。",
+                mode="plan",
+                evidence=[json.dumps(results, ensure_ascii=False)],
+            )
+        return results, {"agent": "GoalAgent", "paradigm": self.paradigm, "tools": ["GoalProjectionTool"], "goal_count": len(results), "planning_note": planning_note}

+ 46 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/pattern_agent.py

@@ -0,0 +1,46 @@
+"""PatternAgent plans deterministic tool use for patterns and anomalies."""
+
+from __future__ import annotations
+
+import json
+from typing import Iterable
+
+from ..models import Transaction
+from ..tools import AnomalyDetectionTool, StatisticsTool, SubscriptionDetectorTool
+from .runtime import HelloAgentsRuntime
+
+
+class PatternAgent:
+    paradigm = "PlanAndSolveAgent-style: plan metrics → call statistical tools → return evidence"
+
+    def __init__(self, runtime: HelloAgentsRuntime | None = None) -> None:
+        self.runtime = runtime
+        self.statistics = StatisticsTool()
+        self.anomalies = AnomalyDetectionTool()
+        self.subscriptions = SubscriptionDetectorTool()
+
+    def run(self, transactions: Iterable[Transaction], month: str) -> tuple[dict, list, list[dict], dict]:
+        items = list(transactions)
+        patterns = self.statistics.patterns(items, month)
+        anomalies = self.anomalies.detect(items, month)
+        subscriptions = self.subscriptions.detect(items)
+        evidence = {
+            "late_night_count": patterns["late_night"]["count"],
+            "anomaly_count": len(anomalies),
+            "subscription_count": len(subscriptions),
+        }
+        planning_note = "Python 工具已按深夜、周末、工资到账窗口、异常和连续扣费顺序完成统计。"
+        if self.runtime is not None:
+            planning_note = self.runtime.explain(
+                "请用一句话说明下一步应如何解读这组已验证的消费模式证据,不要重新计算金额。",
+                mode="plan",
+                evidence=[json.dumps(evidence, ensure_ascii=False)],
+            )
+        trace = {
+            "agent": "PatternAgent",
+            "paradigm": self.paradigm,
+            "tools": ["StatisticsTool", "AnomalyDetectionTool", "SubscriptionDetectorTool"],
+            "evidence": evidence,
+            "planning_note": planning_note,
+        }
+        return patterns, anomalies, subscriptions, trace

+ 377 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/persona_agent.py

@@ -0,0 +1,377 @@
+"""Evidence-based, configurable consumer-persona scoring for MoneyMirror."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Iterable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from ..models import Transaction
+from ..tools.statistics import StatisticsTool
+from .runtime import HelloAgentsRuntime
+
+
+@dataclass(frozen=True, slots=True)
+class PersonaArchetype:
+    """A stable, configurable persona structure; wording is not a Python rule."""
+
+    archetype_id: str
+    name: str
+    minimum_score: float
+    required_features: dict[str, float]
+    weights: dict[str, float]
+    evidence_metrics: tuple[str, ...]
+
+
+class PersonaAgent:
+    """Score verified behavior features, validate evidence, then ask the LLM for prose.
+
+    Python determines the reproducible archetype and its score. The LLM only
+    turns the verified result into young, supportive language; it cannot
+    change the score, invent a metric, or turn generic food spending into a
+    coffee-specific claim.
+    """
+
+    paradigm = "Feature vector → configurable scoring → evidence validation → LLM narrative"
+    DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "personas.json"
+
+    # These names are the stable contract between deterministic extraction and
+    # external persona configuration. They are deliberately behavior metrics,
+    # not persona labels: adding or renaming an archetype remains a JSON-only
+    # change, while a misspelled feature is rejected instead of silently
+    # scoring as zero.
+    FEATURE_KEYS = frozenset(
+        {
+            "night",
+            "weekend",
+            "frequent_small",
+            "flexible_spend",
+            "food",
+            "subscription",
+            "learning",
+            "learning_consistency",
+            "savings",
+            "planning",
+            "planning_inverse",
+            "impulse",
+            "payday",
+            "impulse_inverse",
+        }
+    )
+    METRIC_KEYS = frozenset(
+        {
+            "savings_rate",
+            "late_night_share",
+            "late_night_count",
+            "weekend_share",
+            "weekend_count",
+            "payday_share",
+            "frequent_small_count",
+            "frequent_small_share",
+            "food_share",
+            "flexible_spend_share",
+            "subscription_share",
+            "subscription_count",
+            "learning_share",
+            "learning_active_months",
+            "impulse",
+        }
+    )
+
+    def __init__(self, runtime: HelloAgentsRuntime, config_path: str | Path | None = None) -> None:
+        self.runtime = runtime
+        self.statistics = StatisticsTool()
+        self.config_path = Path(config_path) if config_path else self.DEFAULT_CONFIG_PATH
+        self.archetypes, self.fallback = self._load_config(self.config_path)
+        self._validate_config_references()
+
+    @staticmethod
+    def _clamp_score(value: float) -> float:
+        return round(max(0.0, min(100.0, value)), 2)
+
+    @classmethod
+    def _scaled(cls, value: float, full_score_at: float) -> float:
+        if full_score_at <= 0:
+            return 0.0
+        return cls._clamp_score(value / full_score_at * 100)
+
+    @staticmethod
+    def _load_config(path: Path) -> tuple[tuple[PersonaArchetype, ...], dict[str, Any]]:
+        try:
+            raw = json.loads(path.read_text(encoding="utf-8"))
+        except (OSError, json.JSONDecodeError) as exc:
+            raise ValueError(f"无法读取人格配置 {path}: {exc}") from exc
+        items = raw.get("archetypes")
+        fallback = raw.get("fallback")
+        if not isinstance(items, list) or not items or not isinstance(fallback, dict):
+            raise ValueError("人格配置必须包含非空 archetypes 和 fallback")
+
+        archetypes: list[PersonaArchetype] = []
+        seen_ids: set[str] = set()
+        for item in items:
+            if not isinstance(item, dict):
+                raise ValueError("人格配置中的 archetype 必须是对象")
+            archetype_id = str(item.get("id", "")).strip()
+            name = str(item.get("name", "")).strip()
+            weights = item.get("weights")
+            required = item.get("required_features", {})
+            evidence = item.get("evidence_metrics", [])
+            if not archetype_id or not name or archetype_id in seen_ids:
+                raise ValueError("人格配置需要唯一且非空的 id 与 name")
+            if not isinstance(weights, dict) or not weights or not isinstance(required, dict) or not isinstance(evidence, list):
+                raise ValueError(f"人格配置 {archetype_id} 的字段格式无效")
+            numeric_weights = {str(key): float(value) for key, value in weights.items()}
+            if any(value < 0 for value in numeric_weights.values()) or sum(numeric_weights.values()) <= 0:
+                raise ValueError(f"人格配置 {archetype_id} 的 weights 必须为正权重")
+            archetypes.append(
+                PersonaArchetype(
+                    archetype_id=archetype_id,
+                    name=name,
+                    minimum_score=float(item.get("minimum_score", 0)),
+                    required_features={str(key): float(value) for key, value in required.items()},
+                    weights=numeric_weights,
+                    evidence_metrics=tuple(str(key) for key in evidence),
+                )
+            )
+            seen_ids.add(archetype_id)
+        if not str(fallback.get("id", "")).strip() or not str(fallback.get("name", "")).strip():
+            raise ValueError("人格配置 fallback 需要 id 与 name")
+        return tuple(archetypes), fallback
+
+    def _validate_config_references(self) -> None:
+        """Fail fast when JSON references a metric the extractor cannot produce."""
+        for archetype in self.archetypes:
+            feature_keys = set(archetype.weights) | set(archetype.required_features)
+            unknown_features = sorted(feature_keys - self.FEATURE_KEYS)
+            if unknown_features:
+                raise ValueError(
+                    f"人格配置 {archetype.archetype_id} 引用了未知特征: {', '.join(unknown_features)}"
+                )
+            unknown_metrics = sorted(set(archetype.evidence_metrics) - self.METRIC_KEYS)
+            if unknown_metrics:
+                raise ValueError(
+                    f"人格配置 {archetype.archetype_id} 引用了未知证据指标: {', '.join(unknown_metrics)}"
+                )
+            if not 0 <= archetype.minimum_score <= 100:
+                raise ValueError(f"人格配置 {archetype.archetype_id} 的 minimum_score 必须在 0 到 100 之间")
+            invalid_thresholds = [
+                name for name, threshold in archetype.required_features.items() if not 0 <= threshold <= 100
+            ]
+            if invalid_thresholds:
+                raise ValueError(
+                    f"人格配置 {archetype.archetype_id} 的 required_features 阈值必须在 0 到 100 之间: "
+                    f"{', '.join(sorted(invalid_thresholds))}"
+                )
+        fallback_unknown_metrics = sorted(set(self.fallback.get("evidence_metrics", [])) - self.METRIC_KEYS)
+        if fallback_unknown_metrics:
+            raise ValueError(f"人格配置 fallback 引用了未知证据指标: {', '.join(fallback_unknown_metrics)}")
+
+    def _learning_active_months(self, transactions: Iterable[Transaction] | None) -> int:
+        if transactions is None:
+            return 0
+        totals = self.statistics.monthly_category_totals(transactions)
+        return sum(1 for categories in totals.values() if categories.get("学习", 0.0) > 0)
+
+    def _extract_features(
+        self,
+        summary: dict[str, Any],
+        categories: dict[str, float],
+        patterns: dict[str, Any],
+        subscriptions: list[dict[str, Any]],
+        transactions: Iterable[Transaction] | None,
+    ) -> tuple[dict[str, float], dict[str, float | int]]:
+        expense = max(float(summary.get("expense", 0.0)), 1.0)
+        savings_rate = float(summary.get("savings_rate", 0.0))
+        late = patterns.get("late_night", {})
+        weekend = patterns.get("weekend", {})
+        payday = patterns.get("payday_window", {})
+        frequent_small = patterns.get("frequent_small", {})
+
+        late_share = float(late.get("share", 0.0))
+        weekend_share = float(weekend.get("share", 0.0))
+        payday_share = float(payday.get("share", 0.0))
+        small_count = int(frequent_small.get("count", 0))
+        small_share = float(frequent_small.get("amount", 0.0)) / expense * 100
+        food_share = float(categories.get("餐饮", 0.0)) / expense * 100
+        flexible_spend_share = (float(categories.get("娱乐", 0.0)) + float(categories.get("购物", 0.0))) / expense * 100
+        subscription_share = float(categories.get("订阅", 0.0)) / expense * 100
+        learning_share = float(categories.get("学习", 0.0)) / expense * 100
+        learning_active_months = self._learning_active_months(transactions)
+
+        night = 0.72 * self._scaled(late_share, 25) + 0.28 * self._scaled(int(late.get("count", 0)), 5)
+        weekend_score = 0.72 * self._scaled(weekend_share, 50) + 0.28 * self._scaled(int(weekend.get("count", 0)), 6)
+        frequent_small_score = 0.7 * self._scaled(small_count, 10) + 0.3 * self._scaled(small_share, 25)
+        payday_score = self._scaled(payday_share, 45)
+        impulse = 0.45 * frequent_small_score + 0.3 * payday_score + 0.25 * self._scaled(late_share, 30)
+        learning = 0.75 * self._scaled(learning_share, 15) + 0.25 * self._scaled(learning_active_months, 4)
+        subscription = 0.65 * self._scaled(subscription_share, 12) + 0.35 * self._scaled(len(subscriptions), 3)
+        savings = self._clamp_score(savings_rate)
+        planning = 0.5 * savings + 0.25 * (100 - self._scaled(payday_share, 45)) + 0.15 * (100 - frequent_small_score) + 0.1 * (100 - self._scaled(late_share, 30))
+
+        feature_vector = {
+            "night": self._clamp_score(night),
+            "weekend": self._clamp_score(weekend_score),
+            "frequent_small": self._clamp_score(frequent_small_score),
+            "flexible_spend": self._scaled(flexible_spend_share, 40),
+            "food": self._scaled(food_share, 40),
+            "subscription": self._clamp_score(subscription),
+            "learning": self._clamp_score(learning),
+            "learning_consistency": self._scaled(learning_active_months, 4),
+            "savings": self._clamp_score(savings),
+            "planning": self._clamp_score(planning),
+            "planning_inverse": self._clamp_score(100 - planning),
+            "impulse": self._clamp_score(impulse),
+            "payday": self._clamp_score(payday_score),
+            "impulse_inverse": self._clamp_score(100 - impulse),
+        }
+        metrics: dict[str, float | int] = {
+            "savings_rate": round(savings_rate, 2),
+            "late_night_share": round(late_share, 2),
+            "late_night_count": int(late.get("count", 0)),
+            "weekend_share": round(weekend_share, 2),
+            "weekend_count": int(weekend.get("count", 0)),
+            "payday_share": round(payday_share, 2),
+            "frequent_small_count": small_count,
+            "frequent_small_share": round(small_share, 2),
+            "food_share": round(food_share, 2),
+            "flexible_spend_share": round(flexible_spend_share, 2),
+            "subscription_share": round(subscription_share, 2),
+            "subscription_count": len(subscriptions),
+            "learning_share": round(learning_share, 2),
+            "learning_active_months": learning_active_months,
+            "impulse": round(feature_vector["impulse"], 2),
+        }
+        return feature_vector, metrics
+
+    @staticmethod
+    def _score(archetype: PersonaArchetype, features: dict[str, float]) -> float:
+        total_weight = sum(archetype.weights.values())
+        return round(
+            sum(features.get(feature, 0.0) * weight for feature, weight in archetype.weights.items()) / total_weight,
+            2,
+        )
+
+    @staticmethod
+    def _has_valid_evidence(archetype: PersonaArchetype, features: dict[str, float], score: float) -> bool:
+        return score >= archetype.minimum_score and all(
+            features.get(feature, 0.0) >= threshold for feature, threshold in archetype.required_features.items()
+        )
+
+    @staticmethod
+    def _format_metric(metric: str, value: float | int) -> str:
+        labels = {
+            "savings_rate": "储蓄率",
+            "late_night_share": "深夜消费占比",
+            "late_night_count": "深夜交易笔数",
+            "weekend_share": "周末消费占比",
+            "weekend_count": "周末交易笔数",
+            "payday_share": "工资到账后消费占比",
+            "frequent_small_count": "高频小额交易笔数",
+            "frequent_small_share": "高频小额消费占比",
+            "food_share": "餐饮消费占比",
+            "flexible_spend_share": "娱乐与购物占比",
+            "subscription_share": "订阅消费占比",
+            "subscription_count": "疑似订阅项数",
+            "learning_share": "学习消费占比",
+            "learning_active_months": "有学习消费的月份数",
+            "impulse": "冲动消费特征分",
+        }
+        suffix = "" if metric.endswith("count") or metric.endswith("months") else "%"
+        rendered = str(value) if suffix == "" else f"{float(value):.1f}{suffix}"
+        return f"{labels.get(metric, metric)} {rendered}"
+
+    def _evidence_for(self, metric_keys: Iterable[str], metrics: dict[str, float | int]) -> list[str]:
+        return [self._format_metric(key, metrics[key]) for key in metric_keys if key in metrics]
+
+    def run(
+        self,
+        summary: dict,
+        categories: dict[str, float],
+        patterns: dict,
+        subscriptions: list[dict],
+        transactions: Iterable[Transaction] | None = None,
+    ) -> tuple[dict, dict]:
+        feature_vector, metrics = self._extract_features(summary, categories, patterns, subscriptions, transactions)
+        scored = [
+            {
+                "archetype": archetype,
+                "score": self._score(archetype, feature_vector),
+            }
+            for archetype in self.archetypes
+        ]
+        valid = [item for item in scored if self._has_valid_evidence(item["archetype"], feature_vector, item["score"])]
+        valid.sort(key=lambda item: (-item["score"], item["archetype"].archetype_id))
+
+        if valid:
+            primary_item = valid[0]
+            primary = primary_item["archetype"]
+            primary_score = primary_item["score"]
+            secondary_items = valid[1:3]
+        else:
+            primary = PersonaArchetype(
+                archetype_id=str(self.fallback["id"]),
+                name=str(self.fallback["name"]),
+                minimum_score=0.0,
+                required_features={},
+                weights={},
+                evidence_metrics=tuple(str(item) for item in self.fallback.get("evidence_metrics", [])),
+            )
+            primary_score = 0.0
+            secondary_items = []
+
+        evidence = self._evidence_for(primary.evidence_metrics, metrics)
+        if not evidence:
+            evidence = ["当前账单的高风险消费信号不集中,消费结构保持相对均衡"]
+        secondary = [
+            {
+                "archetype": item["archetype"].archetype_id,
+                "name": item["archetype"].name,
+                "score": item["score"],
+                "confidence": round(item["score"] / 100, 2),
+                "evidence": self._evidence_for(item["archetype"].evidence_metrics, metrics),
+            }
+            for item in secondary_items
+        ]
+        narrative = self.runtime.explain(
+            "基于经过程序验证的人格原型写两句年轻化、温和的消费说明。"
+            f"稳定原型={primary.archetype_id};展示名={primary.name};匹配分={primary_score:.1f};"
+            f"证据={';'.join(evidence)}。不要改写原型归属、不要编造金额或比例、不要将餐饮泛化成咖啡,"
+            "可以给一个不含数字的趣味别称,但必须围绕稳定原型表达。",
+            mode="simple",
+            evidence=[
+                "人格特征向量=" + json.dumps(feature_vector, ensure_ascii=False),
+                "已验证人格证据=" + ";".join(evidence),
+            ],
+        )
+        candidates = [
+            {
+                "archetype": item["archetype"].archetype_id,
+                "name": item["archetype"].name,
+                "score": item["score"],
+                "evidence_valid": self._has_valid_evidence(item["archetype"], feature_vector, item["score"]),
+            }
+            for item in sorted(scored, key=lambda item: (-item["score"], item["archetype"].archetype_id))
+        ]
+        persona = {
+            "primary": primary.name,
+            "archetype": primary.archetype_id,
+            "score": primary_score,
+            "confidence": round(primary_score / 100, 2),
+            "labels": [primary.name, *(item["name"] for item in secondary)],
+            "secondary": secondary,
+            "evidence": evidence,
+            "feature_vector": feature_vector,
+            "narrative": narrative,
+        }
+        trace = {
+            "agent": "PersonaAgent",
+            "paradigm": self.paradigm,
+            "config_path": str(self.config_path),
+            "grounded_metrics": metrics,
+            "feature_vector": feature_vector,
+            "candidates": candidates,
+            "llm_role": "仅生成年轻化解释,不决定人格原型、分数或证据",
+        }
+        return persona, trace

+ 415 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/quest_agent.py

@@ -0,0 +1,415 @@
+"""LLM-orchestrated, evidence-locked Money Quest generation.
+
+The QuestAgent intentionally separates three concerns:
+
+1. Python rules discover auditable behavioural signals from the imported bill.
+2. The configured LLM selects and narrates an RPG-style Quest plan as JSON.
+3. Python validates every LLM field and derives all targets, progress, EXP and
+   completion states from the original tool output.
+
+That boundary keeps the interaction playful without allowing a model to invent
+money amounts, completion evidence, or unrelated content.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+from ..memory import SQLiteMemory
+from ..models import Achievement, Quest, Transaction
+from ..tools.quest_progress import QuestProgressTool
+from .runtime import HelloAgentsRuntime, LLMCallError
+
+
+@dataclass(frozen=True, slots=True)
+class _QuestBlueprint:
+    """A Python-owned Quest contract exposed to the LLM as an eligible signal."""
+
+    signal_id: str
+    quest_id: str
+    quest_type: str
+    target: float
+    unit: str
+    exp_reward: int
+    constraint: str
+    evidence: str
+    priority: str = "optional"
+
+    def public_signal(self) -> dict[str, Any]:
+        """Return only selection context, never authority over numeric outcomes."""
+        return {
+            "signal_id": self.signal_id,
+            "priority": self.priority,
+            "verified_observation": self.evidence,
+            "locked_constraint": self.constraint,
+            "completion_source": "Python QuestProgressTool" if self.quest_type not in {"subscription_review", "manual"} else "用户 CLI 确认后由 SQLite Memory 记录",
+        }
+
+
+@dataclass(frozen=True, slots=True)
+class _QuestCandidate:
+    """The small, non-numeric portion the LLM is allowed to author."""
+
+    signal_id: str
+    title: str
+    narrative: str
+    action_hint: str
+
+
+class QuestAgent:
+    """Turn deterministic behaviour signals into validated LLM-designed Quests."""
+
+    paradigm = "规则发现真实信号 → PlanSolveAgent 动态编排 → Python 强校验与进度计算"
+    _MAX_QUESTS = 5
+
+    def __init__(self, memory: SQLiteMemory, runtime: HelloAgentsRuntime | None = None) -> None:
+        self.memory = memory
+        self.runtime = runtime
+        self.progress_tool = QuestProgressTool()
+
+    def run(
+        self,
+        transactions: Iterable[Transaction],
+        month: str,
+        summary: dict,
+        categories: dict[str, float],
+        patterns: dict,
+        subscriptions: list[dict],
+        budget: dict,
+        goals: list[dict] | None = None,
+    ) -> tuple[list[Quest], list[Achievement], dict, dict]:
+        """Create Quest objects through the LLM selection + Python validation path.
+
+        ``goals`` is intentionally passed as an already projected deterministic
+        result; the LLM can prioritize a goal signal but cannot change its
+        feasibility or monthly amount.
+        """
+        transactions = list(transactions)
+        blueprints = self._discover_signals(summary, categories, patterns, subscriptions, budget, goals or [])
+        candidates, validation = self._orchestrate_candidates(blueprints, month)
+        quests = [self._materialize(candidate, blueprints[candidate.signal_id]) for candidate in candidates]
+        quests = [self.progress_tool.update(quest, transactions, month) for quest in quests]
+        self._restore_manual_completions(quests, month)
+        for quest in quests:
+            self.memory.save_quest(quest)
+
+        gamification = self._gamification(quests, transactions, month)
+        achievements = self._achievements(summary, patterns, quests, gamification)
+        for achievement in achievements:
+            if achievement.unlocked:
+                self.memory.save_achievement(achievement.to_dict())
+
+        trace = {
+            "agent": "QuestAgent",
+            "paradigm": self.paradigm,
+            "tools": ["StatisticsTool", "BudgetCalculatorTool", "SubscriptionDetectorTool", "QuestProgressTool"],
+            "signal_catalog": [blueprint.public_signal() for blueprint in blueprints.values()],
+            "llm_orchestration": {
+                "candidate_count": len(candidates),
+                "accepted_signal_ids": [candidate.signal_id for candidate in candidates],
+                "validation": validation,
+                "numeric_authority": "Python only: target / progress / EXP / status are derived from locked blueprints and QuestProgressTool.",
+            },
+            "quest_evidence": [quest.evidence for quest in quests],
+        }
+        return quests, achievements, gamification, trace
+
+    def _discover_signals(
+        self,
+        summary: dict,
+        categories: dict[str, float],
+        patterns: dict,
+        subscriptions: list[dict],
+        budget: dict,
+        goals: list[dict],
+    ) -> dict[str, _QuestBlueprint]:
+        """Discover only reproducible signals; no model is involved here."""
+        signals: dict[str, _QuestBlueprint] = {}
+        late_night = patterns.get("late_night", {})
+        frequent_small = patterns.get("frequent_small", {})
+        weekend = patterns.get("weekend", {})
+        payday = patterns.get("payday_window", {})
+
+        if int(late_night.get("count", 0)) >= 2:
+            signals["late_night"] = _QuestBlueprint(
+                "late_night", "late_night_guard", "late_night_limit", 1.0, "笔", 80,
+                "22:00 后支出最多 1 笔。",
+                f"深夜消费 {int(late_night['count'])} 笔,合计 ¥{float(late_night.get('amount', 0)):.2f}。",
+                "required",
+            )
+        if int(frequent_small.get("count", 0)) >= 6:
+            signals["frequent_small"] = _QuestBlueprint(
+                "frequent_small", "zero_spend_scout", "zero_spend_days", 2.0, "天", 100,
+                "在当前分析区间内完成 2 个无支出日。",
+                f"发现 {int(frequent_small['count'])} 笔不高于 ¥50 的高频小额支出,合计 ¥{float(frequent_small.get('amount', 0)):.2f}。",
+                "required",
+            )
+
+        flexible_categories = [category for category in ("娱乐", "购物") if float(categories.get(category, 0)) > 0]
+        if flexible_categories:
+            category = max(flexible_categories, key=lambda item: float(categories[item]))
+            recommended = float(budget.get("categories", {}).get(category, {}).get("recommended", categories[category]))
+            signals["flexible_budget"] = _QuestBlueprint(
+                "flexible_budget", f"{category}_budget", "category_limit", recommended, category, 120,
+                f"{category}支出不高于已核验的动态预算 ¥{recommended:.2f}。",
+                f"{category}本月已支出 ¥{float(categories[category]):.2f};动态预算为 ¥{recommended:.2f}。",
+                "required",
+            )
+        if subscriptions:
+            review_count = float(min(3, len(subscriptions)))
+            signals["subscriptions"] = _QuestBlueprint(
+                "subscriptions", "subscription_hunter", "subscription_review", review_count, "项", 90,
+                f"检查 {int(review_count)} 项疑似连续扣费,并仅保留仍会使用的服务。",
+                f"发现 {len(subscriptions)} 项疑似连续扣费。",
+                "required",
+            )
+        if int(weekend.get("count", 0)) >= 3 and float(weekend.get("share", 0)) >= 30:
+            target = round(max(1.0, float(weekend.get("amount", 0)) * 0.85), 2)
+            signals["weekend"] = _QuestBlueprint(
+                "weekend", "weekend_wallet_shield", "weekend_spend_limit", target, "元", 110,
+                f"周末支出不高于已核验的温和目标 ¥{target:.2f}。",
+                f"周末消费 {int(weekend['count'])} 笔,占本月支出 {float(weekend['share']):.2f}%。",
+            )
+        if int(payday.get("count", 0)) >= 3 and float(payday.get("share", 0)) >= 25:
+            target = round(max(1.0, float(payday.get("amount", 0)) * 0.85), 2)
+            signals["payday"] = _QuestBlueprint(
+                "payday", "payday_cooldown", "payday_window_limit", target, "元", 110,
+                f"工资到账后 3 天内支出不高于已核验的温和目标 ¥{target:.2f}。",
+                f"工资到账后 3 天内发生 {int(payday['count'])} 笔支出,占本月支出 {float(payday['share']):.2f}%。",
+            )
+        learning = float(categories.get("学习", 0))
+        expense = max(float(summary.get("expense", 0)), 1.0)
+        if learning >= 100 and learning / expense >= 0.08:
+            signals["learning_followthrough"] = _QuestBlueprint(
+                "learning_followthrough", "learning_loot_log", "manual", 1.0, "次", 70,
+                "记录 1 次学习服务是否真正被使用,并标注下次使用时间。",
+                f"学习类支出为 ¥{learning:.2f},占本月支出 {learning / expense * 100:.2f}%。",
+            )
+        actionable_goals = [goal for goal in goals if goal.get("feasible") and float(goal.get("required_monthly_amount", 0)) > 0]
+        if actionable_goals and float(summary.get("balance", 0)) > 0:
+            goal = min(actionable_goals, key=lambda item: float(item.get("required_monthly_amount", 0)))
+            signals["goal_transfer"] = _QuestBlueprint(
+                "goal_transfer", "goal_supply_line", "manual", 1.0, "次", 75,
+                "确认 1 次本月结余如何服务于已设置的财务目标。",
+                f"目标“{goal.get('title', '储蓄目标')}”每月仍需约 ¥{float(goal['required_monthly_amount']):.2f};本月结余 ¥{float(summary.get('balance', 0)):.2f}。",
+            )
+        if not signals:
+            signals["balance"] = _QuestBlueprint(
+                "balance", "balance_builder", "manual", 1.0, "次", 60,
+                "记录 1 次消费决策,并确认它是否服务于你的目标。",
+                "尚未发现需要优先处理的高强度消费信号,适合建立一条自己的决策记录。",
+                "required",
+            )
+        return signals
+
+    def _orchestrate_candidates(
+        self,
+        blueprints: dict[str, _QuestBlueprint],
+        month: str,
+    ) -> tuple[list[_QuestCandidate], dict[str, Any]]:
+        if self.runtime is None or not self.runtime.status.enabled:
+            raise LLMCallError("Quest 动态编排必须使用已配置的 LLM,当前运行时不可用。")
+        catalog = [blueprint.public_signal() for blueprint in blueprints.values()]
+        prompt = (
+            "你是 MoneyMirrorAgent 的 Quest 编排师。根据 Python 已核验的行为信号,为年轻用户选择并命名个性化 RPG Quest。"
+            "这是严格的 JSON 协议:你只负责选择信号、标题、氛围叙述和一条可执行提示;不得创造金额、次数、日期、完成状态、EXP 或新的 signal_id。"
+            "必须包含全部 priority=required 的信号;optional 信号可按相关性最多选 2 个;总数不超过 5。"
+            "title 为 4-18 个中文字符、无数字;narrative 与 action_hint 各 8-90 字、无数字/金额/百分比,语气友好、轻松、不羞辱。"
+            "只输出一个 JSON 对象,禁止 Markdown 代码块和任何解释,格式严格如下:\n"
+            '{"quests":[{"signal_id":"...","title":"...","narrative":"...","action_hint":"..."}]}\n'
+            f"分析月份:{month}\nSIGNAL_CATALOG_JSON:\n{json.dumps(catalog, ensure_ascii=False, separators=(',', ':'))}"
+        )
+        first = self.runtime.generate_quest_candidates(prompt)
+        candidates, problems = self._parse_and_validate_candidates(first, blueprints)
+        if candidates:
+            return candidates, {"attempts": 1, "repaired": False, "rejected": problems}
+
+        repair_prompt = (
+            "你的上一条 Quest JSON 未通过 Python 强校验。请立刻仅输出一个非空 JSON 对象,不要解释、不要 Markdown。"
+            "字段必须严格为 {\"quests\":[{\"signal_id\":\"...\",\"title\":\"...\",\"narrative\":\"...\",\"action_hint\":\"...\"}]}。"
+            "只可使用目录中的 signal_id,必须包含全部 priority=required 信号;不得出现金额、数字、日期、EXP、进度或完成状态。"
+            f"校验问题:{';'.join(problems[:6])}\n"
+            f"允许信号:{', '.join(blueprints)}。必须包含:{', '.join(item.signal_id for item in blueprints.values() if item.priority == 'required')}。\n"
+            f"SIGNAL_CATALOG_JSON:\n{json.dumps(catalog, ensure_ascii=False, separators=(',', ':'))}"
+        )
+        repaired = self.runtime.generate_quest_candidates(repair_prompt)
+        candidates, repair_problems = self._parse_and_validate_candidates(repaired, blueprints)
+        if not candidates:
+            details = ";".join(repair_problems[:8]) or "LLM 未返回可验证的 Quest JSON"
+            raise LLMCallError(f"Quest 动态编排输出未通过 Python 强校验:{details}")
+        return candidates, {"attempts": 2, "repaired": True, "rejected": problems, "repair_rejected": repair_problems}
+
+    def _parse_and_validate_candidates(
+        self,
+        raw: str,
+        blueprints: dict[str, _QuestBlueprint],
+    ) -> tuple[list[_QuestCandidate], list[str]]:
+        problems: list[str] = []
+        try:
+            payload = self._extract_json(raw)
+        except ValueError as exc:
+            return [], [str(exc)]
+        rows = payload.get("quests") if isinstance(payload, dict) else None
+        if not isinstance(rows, list) or not rows:
+            return [], ["根对象必须含有非空 quests 数组"]
+        if len(rows) > self._MAX_QUESTS:
+            return [], [f"Quest 数量超过上限 {self._MAX_QUESTS}"]
+
+        candidates: list[_QuestCandidate] = []
+        seen: set[str] = set()
+        for index, row in enumerate(rows):
+            if not isinstance(row, dict):
+                problems.append(f"quests[{index}] 不是对象")
+                continue
+            required_keys = {"signal_id", "title", "narrative", "action_hint"}
+            if set(row) != required_keys:
+                problems.append(f"quests[{index}] 字段必须严格为 {sorted(required_keys)}")
+                continue
+            signal_id = str(row["signal_id"]).strip()
+            if signal_id not in blueprints:
+                problems.append(f"不允许的 signal_id:{signal_id}")
+                continue
+            if signal_id in seen:
+                problems.append(f"signal_id 重复:{signal_id}")
+                continue
+            title = self._clean_copy(row["title"])
+            narrative = self._clean_copy(row["narrative"])
+            action_hint = self._clean_copy(row["action_hint"])
+            if not (4 <= len(title) <= 18):
+                problems.append(f"{signal_id} 的 title 长度不在 4-18")
+                continue
+            if not (8 <= len(narrative) <= 90 and 8 <= len(action_hint) <= 90):
+                problems.append(f"{signal_id} 的 narrative/action_hint 长度不在 8-90")
+                continue
+            unsafe = self._unsafe_copy(title) or self._unsafe_copy(narrative) or self._unsafe_copy(action_hint)
+            if unsafe:
+                problems.append(f"{signal_id} 文案包含不允许内容:{unsafe}")
+                continue
+            seen.add(signal_id)
+            candidates.append(_QuestCandidate(signal_id, title, narrative, action_hint))
+
+        required = {item.signal_id for item in blueprints.values() if item.priority == "required"}
+        missing = sorted(required - seen)
+        if missing:
+            problems.append(f"缺少 required 信号:{', '.join(missing)}")
+        if problems or not candidates:
+            return [], problems
+        return candidates, []
+
+    @staticmethod
+    def _extract_json(raw: str) -> dict[str, Any]:
+        text = str(raw).strip()
+        if text.startswith("```"):
+            text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE).strip()
+        try:
+            parsed = json.loads(text)
+        except json.JSONDecodeError:
+            start = text.find("{")
+            end = text.rfind("}")
+            if start < 0 or end <= start:
+                raise ValueError("LLM 没有返回可解析的 Quest JSON") from None
+            try:
+                parsed = json.loads(text[start : end + 1])
+            except json.JSONDecodeError as exc:
+                raise ValueError(f"LLM Quest JSON 格式错误:{exc.msg}") from exc
+        if not isinstance(parsed, dict):
+            raise ValueError("LLM Quest JSON 根节点必须是对象")
+        return parsed
+
+    @staticmethod
+    def _clean_copy(value: Any) -> str:
+        return re.sub(r"\s+", " ", str(value).strip())
+
+    def _unsafe_copy(self, text: str) -> str | None:
+        # Amounts, percentages, and Arabic-number commitments are prohibited
+        # in model copy. Natural Chinese wording such as “每一笔” is allowed as
+        # flavour only: it cannot alter the separately appended Python-owned
+        # target, progress, EXP, status, or evidence.
+        if any(character.isdigit() for character in text) or any(token in text for token in ("¥", "元", "%")):
+            return "金额、百分比或阿拉伯数字只能由 Python 写入锁定约束"
+        return None
+
+    @staticmethod
+    def _materialize(candidate: _QuestCandidate, blueprint: _QuestBlueprint) -> Quest:
+        description = (
+            f"{candidate.narrative}\n"
+            f"🧩 小提示:{candidate.action_hint}\n"
+            f"🎯 已核验目标:{blueprint.constraint}"
+        )
+        return Quest(
+            blueprint.quest_id,
+            candidate.title,
+            description,
+            blueprint.quest_type,
+            blueprint.target,
+            0,
+            blueprint.unit,
+            blueprint.exp_reward,
+        )
+
+    def _restore_manual_completions(self, quests: list[Quest], month: str) -> None:
+        """Restore only CLI-confirmable outcomes from month-scoped Memory."""
+        manually_completed = set(self.memory.get_preference(f"manual_completed_quests:{month}", []))
+        for quest in quests:
+            if quest.quest_id in manually_completed and quest.quest_type in {"subscription_review", "manual"}:
+                quest.progress = quest.target
+                quest.status = "completed"
+                quest.evidence = f"用户已在 CLI 中确认完成({month})"
+
+    def complete_manual_quest(self, quest: Quest, month: str, note: str = "") -> dict:
+        """Record a user-confirmed Quest only where transactions cannot prove it."""
+        if quest.quest_type not in {"subscription_review", "manual"}:
+            raise ValueError(f"{quest.title} 的进度由账单自动计算,不能手动完成。")
+        quest.progress = quest.target
+        quest.status = "completed"
+        detail = note.strip() or "用户已在 CLI 中确认完成"
+        quest.evidence = f"{detail}({month})"
+        self.memory.save_quest(quest)
+        manual_key = f"manual_completed_quests:{month}"
+        manual_completed = set(self.memory.get_preference(manual_key, []))
+        manual_completed.add(quest.quest_id)
+        self.memory.set_preference(manual_key, sorted(manual_completed))
+
+        completed_ids = set(self.memory.get_preference("completed_quest_ids", []))
+        gained_exp = 0
+        if quest.quest_id not in completed_ids:
+            completed_ids.add(quest.quest_id)
+            gained_exp = quest.exp_reward
+            self.memory.set_preference("completed_quest_ids", sorted(completed_ids))
+            self.memory.set_preference("total_exp", int(self.memory.get_preference("total_exp", 0)) + gained_exp)
+        return {
+            "gained_exp": gained_exp,
+            "total_exp": int(self.memory.get_preference("total_exp", 0)),
+            "level": 1 + int(self.memory.get_preference("total_exp", 0)) // 200,
+        }
+
+    def _gamification(self, quests: list[Quest], transactions: list[Transaction], month: str) -> dict:
+        completed_ids = set(self.memory.get_preference("completed_quest_ids", []))
+        new_completed = [quest for quest in quests if quest.status == "completed" and quest.quest_id not in completed_ids]
+        completed_ids.update(quest.quest_id for quest in new_completed)
+        total_exp = int(self.memory.get_preference("total_exp", 0)) + sum(quest.exp_reward for quest in new_completed)
+        current_streak = self.progress_tool.max_zero_spend_streak(transactions, month)
+        longest_streak = max(int(self.memory.get_preference("longest_streak_days", 0)), current_streak)
+        self.memory.set_preference("completed_quest_ids", sorted(completed_ids))
+        self.memory.set_preference("total_exp", total_exp)
+        self.memory.set_preference("longest_streak_days", longest_streak)
+        return {
+            "level": 1 + total_exp // 200,
+            "total_exp": total_exp,
+            "exp_gained_this_cycle": sum(quest.exp_reward for quest in new_completed),
+            "current_streak_days": current_streak,
+            "longest_streak_days": longest_streak,
+        }
+
+    @staticmethod
+    def _achievements(summary: dict, patterns: dict, quests: list[Quest], gamification: dict) -> list[Achievement]:
+        return [
+            Achievement("savings_rate_20", "储蓄率破 20%", "储蓄率首次达到或超过 20%。", summary.get("savings_rate", 0) >= 20),
+            Achievement("zero_spend_start", "零消费日初体验", "完成至少一个零消费日。", any(item.quest_type == "zero_spend_days" and item.progress >= 1 for item in quests)),
+            Achievement("quest_ready", "任务上线", "已生成基于真实账单的 Money Quest。", bool(quests)),
+            Achievement("late_night_awareness", "深夜雷达启动", "已识别深夜消费行为并生成应对任务。", patterns["late_night"]["count"] > 0),
+            Achievement("zero_spend_streak_3", "三日无消费连击", "连续 3 天没有支出记录。", gamification.get("longest_streak_days", 0) >= 3),
+        ]

+ 131 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/reflection_agent.py

@@ -0,0 +1,131 @@
+"""ReflectionAgent compares persisted plans with actual outcomes."""
+
+from __future__ import annotations
+
+from ..memory import SQLiteMemory
+from .runtime import HelloAgentsRuntime
+
+
+class ReflectionAgent:
+    paradigm = "ReflectionAgent-style: plan → actual → deviation → adjustment"
+
+    def __init__(self, memory: SQLiteMemory, runtime: HelloAgentsRuntime) -> None:
+        self.memory = memory
+        self.runtime = runtime
+
+    def run(self, month: str, summary: dict, categories: dict[str, float], budget: dict, quests: list, goals: list[dict]) -> tuple[dict, dict]:
+        previous_month = self._previous_month(month)
+        previous_snapshot = self.memory.get_snapshot(previous_month)
+        budget_lines = budget.get("categories", {})
+        deviations = []
+        for category, planned in budget_lines.items():
+            actual = categories.get(category, 0.0)
+            limit = planned.get("recommended", 0.0)
+            if limit:
+                deviations.append({"category": category, "planned": limit, "actual": actual, "difference": round(actual - limit, 2), "on_budget": actual <= limit})
+        completed = sum(1 for quest in quests if getattr(quest, "status", "active") == "completed")
+        goal_state = goals[0] if goals else {}
+        strategy = []
+        overspent = sorted((item for item in deviations if item["difference"] > 0), key=lambda item: item["difference"], reverse=True)
+        if overspent:
+            strategy.append(f"优先为 {overspent[0]['category']} 保留明确额度,而不是一刀切禁止消费。")
+        if summary.get("savings_rate", 0) < 20:
+            strategy.append("把结余转入目标账户的动作安排在收入到账后 24 小时内。")
+        if completed == 0 and quests:
+            strategy.append("下阶段只保留 2 个可执行任务,降低任务负担。")
+        if not strategy:
+            strategy.append("当前计划与实际较匹配,维持预算框架并逐步提高目标储蓄。")
+
+        next_month = self._next_month(month)
+        next_budget = self._next_cycle_budget(budget_lines, deviations)
+        next_cycle_quests = self._next_cycle_quests(quests, overspent, next_budget)
+        prior_text = "没有上月快照,本次作为基线月。" if not previous_snapshot else f"已读取 {previous_month} 的历史快照用于比较。"
+        narrative = self.runtime.explain(
+            f"按 Reflection 模式解释:月度结余={summary.get('balance')},储蓄率={summary.get('savings_rate')},任务完成={completed}/{len(quests)},预算偏差={deviations},目标={goal_state}。只能基于这些数据给 2-3 句下一周期建议。",
+            mode="reflection",
+            evidence=[
+                f"储蓄率={summary.get('savings_rate', 0):.2f}%",
+                f"任务完成={completed}/{len(quests)}",
+                f"预算偏差={deviations}",
+            ],
+            memory=[prior_text],
+        )
+        reflection = {
+            "month": month,
+            "previous_month": previous_month,
+            "has_previous_snapshot": bool(previous_snapshot),
+            "budget_deviations": deviations,
+            "quest_completion": {"completed": completed, "total": len(quests)},
+            "goal_progress": goal_state,
+            "effective": ["预算和任务均基于实际账单计算"],
+            "needs_adjustment": [item["category"] for item in overspent],
+            "next_strategy": strategy,
+            "next_cycle_month": next_month,
+            "next_cycle_budget": next_budget,
+            "next_cycle_quests": next_cycle_quests,
+            "narrative": narrative,
+        }
+        # Reflection is not just prose: persist the next-cycle artifacts so
+        # the following run/dashboard can inspect and act on them.
+        self.memory.save_budget(next_month, next_budget)
+        self.memory.save_reflection(month, reflection)
+        return reflection, {"agent": "ReflectionAgent", "paradigm": self.paradigm, "context_sources": ["SQLiteMemory: previous snapshot", "budget", "quest outcomes", "goal projection"]}
+
+    @staticmethod
+    def _previous_month(month: str) -> str:
+        year, value = map(int, month.split("-"))
+        return f"{year - 1}-12" if value == 1 else f"{year}-{value - 1:02d}"
+
+    @staticmethod
+    def _next_month(month: str) -> str:
+        year, value = map(int, month.split("-"))
+        return f"{year + 1}-01" if value == 12 else f"{year}-{value + 1:02d}"
+
+    @staticmethod
+    def _next_cycle_budget(budget_lines: dict, deviations: list[dict]) -> dict:
+        """Turn deviations into a gentle next-cycle budget adjustment.
+
+        A large one-off purchase must not cause a punitive cut. For an
+        overspent category, the next recommendation becomes a soft ceiling
+        between the old plan and 150% of it, capped by 105% of actual spend.
+        """
+
+        overspent = {item["category"]: item for item in deviations if item["difference"] > 0}
+        categories = {}
+        for category, planned in budget_lines.items():
+            line = dict(planned)
+            if category in overspent:
+                actual = overspent[category]["actual"]
+                baseline = planned.get("recommended", 0.0)
+                line["recommended"] = round(max(baseline, min(actual * 1.05, baseline * 1.5)), 2)
+                line["rationale"] = "Reflection 根据实际偏差增加缓冲,避免对一次性消费一刀切"
+            categories[category] = line
+        return {"source": "monthly_reflection", "categories": categories}
+
+    @staticmethod
+    def _next_cycle_quests(quests: list, overspent: list[dict], next_budget: dict) -> list[dict]:
+        plans = []
+        for quest in quests:
+            if getattr(quest, "status", "active") != "completed":
+                plans.append({
+                    "quest_id": f"carry_{quest.quest_id}",
+                    "title": f"延续:{quest.title}",
+                    "quest_type": quest.quest_type,
+                    "target": quest.target,
+                    "unit": quest.unit,
+                    "exp_reward": quest.exp_reward,
+                    "reason": "上周期未完成,Reflection 建议降低摩擦后继续",
+                })
+        if overspent:
+            category = overspent[0]["category"]
+            recommended = next_budget.get("categories", {}).get(category, {}).get("recommended", 0.0)
+            plans.append({
+                "quest_id": f"reflection_{category}",
+                "title": f"{category}缓冲预算挑战",
+                "quest_type": "category_limit",
+                "target": recommended,
+                "unit": category,
+                "exp_reward": 100,
+                "reason": "根据本月最大预算偏差生成",
+            })
+        return plans or [{"quest_id": "reflection_checkin", "title": "月度镜像回顾", "quest_type": "reflection_checkin", "target": 1, "unit": "次", "exp_reward": 60, "reason": "保持有效计划"}]

+ 481 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/runtime.py

@@ -0,0 +1,481 @@
+"""Strict Hello-Agents runtime for MoneyMirrorAgent.
+
+The project deliberately has no deterministic/offline substitute for language
+work.  Python tools remain the source of truth for money, statistics,
+anomalies, budgets, goals and quest progress; Hello-Agents is required for
+uncertain classification, planning, explanations, Reflection, user dialogue,
+and the final Markdown report.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import os
+from contextlib import redirect_stdout
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Iterable, Literal
+
+from dotenv import load_dotenv
+
+
+class RuntimeConfigurationError(RuntimeError):
+    """Raised when the required Hello-Agents/LLM configuration is missing."""
+
+
+class LLMCallError(RuntimeError):
+    """Raised when a required LLM call fails or returns no content."""
+
+
+@dataclass(slots=True)
+class RuntimeStatus:
+    available: bool
+    enabled: bool
+    reason: str
+    registry_name: str = "HelloAgents ToolRegistry"
+    paradigms: tuple[str, ...] = ()
+    registered_tools: tuple[str, ...] = ()
+    provider: str = "OpenAI-compatible"
+    model: str = ""
+    base_url: str = ""
+
+
+class HelloAgentsRuntime:
+    """Use the official Hello-Agents agents with a mandatory LLM backend.
+
+    ``PlanSolveAgent`` is the class name exported by hello-agents 1.x for the
+    Plan-and-Solve pattern described in the course material.  The runtime
+    keeps the agent objects in one place so every specialist can share the
+    same configured provider and Context Engineering policy.
+    """
+
+    def __init__(self) -> None:
+        self.registry: Any | None = None
+        self.agent: Any | None = None
+        self.react_agent: Any | None = None
+        self.plan_agent: Any | None = None
+        self.reflection_agent: Any | None = None
+        self.context_builder: Any | None = None
+        self.llm: Any | None = None
+        self.status = RuntimeStatus(False, False, "Hello-Agents 尚未初始化")
+        self._initialize()
+
+    def _initialize(self) -> None:
+        # This makes ``MoneyMirrorCoordinator`` usable from tests, notebooks,
+        # CLI, notebooks and tests can initialize from any working directory.
+        project_root = Path(__file__).resolve().parents[2]
+        load_dotenv(project_root / ".env", override=False)
+
+        required = {
+            "LLM_API_KEY": os.getenv("LLM_API_KEY", "").strip(),
+            "LLM_BASE_URL": os.getenv("LLM_BASE_URL", "").strip(),
+            "LLM_MODEL_ID": os.getenv("LLM_MODEL_ID", "").strip(),
+        }
+        missing = [name for name, value in required.items() if not value]
+        if missing:
+            self.status = RuntimeStatus(
+                False,
+                False,
+                "缺少必需的 LLM 配置:" + ", ".join(missing) + "。请复制 .env.example 为 .env 并填写。",
+                model=required["LLM_MODEL_ID"],
+                base_url=required["LLM_BASE_URL"],
+            )
+            raise RuntimeConfigurationError(self.status.reason)
+
+        try:
+            from hello_agents import (  # type: ignore
+                Config,
+                HelloAgentsLLM,
+                PlanSolveAgent,
+                ReActAgent,
+                ReflectionAgent as HelloReflectionAgent,
+                SimpleAgent,
+            )
+            from hello_agents.tools import ToolRegistry  # type: ignore
+        except Exception as exc:  # pragma: no cover - dependency installation issue
+            raise RuntimeConfigurationError(f"无法导入 hello-agents:{exc}") from exc
+
+        try:
+            self.registry = ToolRegistry()
+            try:
+                from hello_agents.context import ContextBuilder, ContextConfig  # type: ignore
+
+                # DeepSeek-V4-Flash has a large context window. Keep an explicit,
+                # configurable client-side budget so Hello-Agents does not discard
+                # verified evidence before it reaches the model. The report packet
+                # is still compacted at the caller for predictable latency/cost.
+                self.context_builder = ContextBuilder(
+                    ContextConfig(
+                        max_tokens=int(os.getenv("LLM_CONTEXT_MAX_TOKENS", "100000")),
+                        # Verified evidence must not be filtered just because a
+                        # Chinese user query shares few whitespace-separated
+                        # tokens with compact JSON.
+                        min_relevance=0.0,
+                    )
+                )
+            except Exception:
+                self.context_builder = None
+
+            self.llm = HelloAgentsLLM(
+                model=required["LLM_MODEL_ID"],
+                api_key=required["LLM_API_KEY"],
+                base_url=required["LLM_BASE_URL"],
+                temperature=float(os.getenv("LLM_TEMPERATURE", "0.2")),
+                max_tokens=int(os.getenv("LLM_MAX_TOKENS", "16384")),
+                timeout=int(os.getenv("LLM_TIMEOUT", "90")),
+            )
+            config = Config(
+                debug=False,
+                max_history_length=8,
+                trace_enabled=False,
+                session_enabled=False,
+                skills_enabled=False,
+                auto_save_enabled=False,
+                stream_enabled=False,
+            )
+            system_prompt = (
+                "你是 MoneyMirrorAgent 的语言推理智能体。所有金额、统计、异常检测、预算、"
+                "目标投影和 Quest 进度已经由 Python 工具计算。只能基于已验证证据解释、规划"
+                "和生成文案;不许编造数值。"
+            )
+            with redirect_stdout(io.StringIO()):
+                self.agent = SimpleAgent(
+                    name="MoneyMirror 解释智能体",
+                    llm=self.llm,
+                    system_prompt=system_prompt,
+                    config=config,
+                    tool_registry=self.registry,
+                    enable_tool_calling=False,
+                )
+                self.react_agent = ReActAgent(
+                    name="MoneyMirror Transaction ReActAgent",
+                    llm=self.llm,
+                    tool_registry=self.registry,
+                    system_prompt=system_prompt,
+                    config=config,
+                    max_steps=3,
+                )
+                self.plan_agent = PlanSolveAgent(
+                    name="MoneyMirror PlanAndSolveAgent",
+                    llm=self.llm,
+                    system_prompt=system_prompt,
+                    config=config,
+                    tool_registry=self.registry,
+                    enable_tool_calling=False,
+                    max_tool_iterations=1,
+                )
+                self.reflection_agent = HelloReflectionAgent(
+                    name="MoneyMirror ReflectionAgent",
+                    llm=self.llm,
+                    system_prompt=system_prompt,
+                    config=config,
+                    tool_registry=self.registry,
+                    enable_tool_calling=False,
+                    max_iterations=2,
+                )
+        except Exception as exc:
+            self.status = RuntimeStatus(
+                False,
+                False,
+                f"Hello-Agents/LLM 初始化失败:{exc}",
+                model=required["LLM_MODEL_ID"],
+                base_url=required["LLM_BASE_URL"],
+            )
+            raise RuntimeConfigurationError(self.status.reason) from exc
+
+        self.status = RuntimeStatus(
+            True,
+            True,
+            f"已启用 Hello-Agents + OpenAI 兼容 LLM:{required['LLM_MODEL_ID']}",
+            paradigms=("ReActAgent", "PlanSolveAgent", "ReflectionAgent", "Context Engineering"),
+            provider="OpenAI-compatible",
+            model=required["LLM_MODEL_ID"],
+            base_url=required["LLM_BASE_URL"],
+        )
+
+    def register_tool_functions(self, functions: dict[str, tuple[Any, str]]) -> None:
+        """Expose deterministic tools to Hello-Agents ToolRegistry."""
+        if self.registry is None:  # pragma: no cover - guarded by init
+            raise RuntimeConfigurationError("ToolRegistry 尚未初始化")
+        registered: list[str] = []
+        with redirect_stdout(io.StringIO()):
+            for name, (function, description) in functions.items():
+                self.registry.register_function(function, name=name, description=description)
+                registered.append(name)
+        self.status.registered_tools = tuple(registered)
+        self.status.registry_name = f"HelloAgents ToolRegistry ({len(registered)} MoneyMirror tools)"
+
+    def build_context(self, task: str, evidence: Iterable[str] = (), memory: Iterable[str] = ()) -> str:
+        """Build a GSSC-style context packet with verified facts separated."""
+        evidence_packets = [f"[Verified tool output]\n{item}" for item in evidence]
+        memory_packets = [f"[Long-term Memory]\n{item}" for item in memory]
+        if self.context_builder is not None:
+            try:
+                from hello_agents.context import ContextPacket  # type: ignore
+
+                packets = [
+                    ContextPacket(content=item, metadata={"type": "tool_result"}) for item in evidence_packets
+                ] + [ContextPacket(content=item, metadata={"type": "related_memory"}) for item in memory_packets]
+                built = self.context_builder.build(
+                    user_query=task,
+                    system_instructions=(
+                        "只使用 Verified tool output 作为数字事实;Long-term Memory 只能作为历史上下文,"
+                        "不要把它当作当前月计算结果。"
+                    ),
+                    additional_packets=packets,
+                )
+                # ContextBuilder 1.x appends a generic numbered answer template
+                # (结论/依据/风险/下一步). That template is useful for generic
+                # tasks but conflicts with MoneyMirror's playful one-question
+                # coaching protocol. Keep GSSC evidence selection while removing
+                # only that generic output instruction.
+                return built.split("\n\n[Output]\n", 1)[0]
+            except Exception:
+                # ContextBuilder API can vary between hello-agents patch releases;
+                # the explicit packet format below preserves the same semantics.
+                pass
+        parts = ["[Task]", task]
+        if evidence_packets:
+            parts.extend(["[Evidence]", *evidence_packets])
+        if memory_packets:
+            parts.extend(["[Context]", *memory_packets])
+        return "\n".join(parts)
+
+    def explain(
+        self,
+        prompt: str,
+        mode: Literal["simple", "react", "plan", "reflection"] = "simple",
+        evidence: Iterable[str] = (),
+        memory: Iterable[str] = (),
+    ) -> str:
+        """Run a required language-agent call and return its non-empty text."""
+        # ``deepseek-v4-flash`` accepts normal chat completions but rejects the
+        # function-tool choice issued internally by Hello-Agents' current
+        # ReAct/PlanSolve/Reflection implementations in thinking mode.  The
+        # specialist *roles* still use those paradigms and the ToolRegistry,
+        # while their language response is executed by the official
+        # SimpleAgent with a mode-specific instruction. This keeps the real
+        # provider path stable instead of silently falling back to templates.
+        if self.agent is None:
+            raise LLMCallError("Hello-Agents SimpleAgent 未初始化")
+        role_instruction = {
+            "simple": "以清晰、温和的解释者身份回答。",
+            "react": "按 ReAct 的观察-判断-结论节奏回答,但不要输出思维链或虚构工具调用。",
+            "plan": "按 Plan-and-Solve 的先规划后行动节奏回答,直接给出简洁结果。",
+            "reflection": "按 Reflection 的计划-实际-调整节奏回答,给出下一步行动。",
+        }.get(mode)
+        if role_instruction is None:
+            raise LLMCallError(f"未知 Agent 模式:{mode}")
+        context = self.build_context(role_instruction + "\n" + prompt, evidence, memory)
+        last_error: Exception | None = None
+        for attempt in range(2):
+            try:
+                # hello-agents emits progress logs to stdout. Keep CLI/UI
+                # output focused on the actual answer while retaining the
+                # official SimpleAgent execution path.
+                # Each specialist call is a fresh bounded turn. Otherwise
+                # SimpleAgent accumulates persona/quest/reflection prompts and
+                # eventually sends stale contexts back to the provider.
+                try:
+                    self.agent.clear_history()
+                except Exception:
+                    pass
+                with redirect_stdout(io.StringIO()):
+                    text = self.agent.run(context)
+                text = str(text).strip()
+                if text:
+                    return text
+                last_error = LLMCallError(f"{mode} Agent 返回空内容")
+            except Exception as exc:
+                last_error = exc
+            if attempt == 0:
+                # A transient empty response is retried once. No local prose
+                # or deterministic fallback is introduced.
+                continue
+        try:
+            return self._direct_llm_answer(context, mode)
+        except LLMCallError as direct_error:
+            raise LLMCallError(
+                f"{mode} Agent 调用失败,且直接 LLM 重试失败:{direct_error}"
+            ) from (last_error or direct_error)
+
+    def _direct_llm_answer(self, context: str, mode: str) -> str:
+        """Provider-compatible direct completion used when an Agent wrapper
+        returns an empty response (some thinking-model/tool combinations do).
+
+        This is still the configured HelloAgentsLLM client and never a local
+        fallback. It keeps the user-facing pipeline reliable across provider
+        patch versions while the official Agent objects remain initialized and
+        registered for the architecture/traces.
+        """
+        if self.llm is None:
+            raise LLMCallError("LLM 尚未初始化")
+        system = (
+            "你是 MoneyMirrorAgent 的语言智能体。请直接输出最终中文答案,不要输出思维链、"
+            "工具调用、空响应或过程日志。数字只能来自 Verified tool output。"
+            f"当前角色模式:{mode}。"
+        )
+        try:
+            response = self.llm.invoke(
+                [{"role": "system", "content": system}, {"role": "user", "content": context}],
+                temperature=0.2,
+            )
+            text = str(getattr(response, "content", response)).strip()
+        except Exception as exc:
+            raise LLMCallError(f"直接 LLM 调用失败:{exc}") from exc
+        if not text:
+            raise LLMCallError("直接 LLM 返回空内容")
+        return text
+
+    def generate_quest_candidates(self, prompt: str) -> str:
+        """Request strict QuestCandidate JSON from the configured LLM.
+
+        Agent wrappers are excellent for natural-language roles, but their
+        PlanSolve instruction can add prose around a schema. Quest selection is
+        a machine-validated boundary, so it uses the same required
+        ``HelloAgentsLLM`` client in JSON-object mode. This is not an offline
+        fallback: provider failure or invalid output remains an explicit error
+        handled by :class:`QuestAgent` and repaired through a second LLM turn.
+        """
+        if self.llm is None:
+            raise LLMCallError("LLM 尚未初始化")
+        messages = [
+            {
+                "role": "system",
+                "content": (
+                    "你是 MoneyMirrorAgent 的 Quest JSON 编排器。只返回一个合法 JSON 对象,"
+                    "不得使用 Markdown、解释、思维链或工具调用。严格遵守用户消息中的字段和安全约束;"
+                    "不得编造金额、次数、日期、进度或 EXP。"
+                ),
+            },
+            {"role": "user", "content": prompt},
+        ]
+        last_error: Exception | None = None
+        # Prefer provider JSON mode. Some OpenAI-compatible endpoints ignore or
+        # reject the option, so retry once as a normal completion with the same
+        # strict system instruction; Python still validates the returned text.
+        for kwargs in ({"response_format": {"type": "json_object"}}, {}):
+            try:
+                response = self.llm.invoke(messages, temperature=0.0, **kwargs)
+                text = str(getattr(response, "content", response)).strip()
+                if text and self._contains_nonempty_quest_array(text):
+                    return text
+                last_error = LLMCallError("Quest JSON LLM 返回空 Quest 数组或非 JSON 内容,准备重试")
+            except Exception as exc:
+                last_error = exc
+        raise LLMCallError(f"Quest JSON LLM 调用失败:{last_error}")
+
+    @staticmethod
+    def _contains_nonempty_quest_array(text: str) -> bool:
+        """Cheap transport-level guard before QuestAgent applies full schema checks."""
+        try:
+            value = json.loads(text)
+        except json.JSONDecodeError:
+            return False
+        return isinstance(value, dict) and isinstance(value.get("quests"), list) and bool(value["quests"])
+
+    def stream_user_guidance(
+        self,
+        question: str,
+        report_payload: str,
+        history: Iterable[dict[str, str]] = (),
+    ) -> Iterable[str]:
+        """Stream a guided, playful answer to a user's current input.
+
+        Hello-Agents' high-level ``Agent.run`` API returns a complete string.
+        For the interactive UI we use the same configured ``HelloAgentsLLM``
+        through its official streaming interface, preserving the Agent's
+        Context Engineering policy while allowing users to see tokens as they
+        arrive instead of waiting for a long final response.
+        """
+        if self.llm is None:
+            raise LLMCallError("LLM 尚未初始化")
+        # Bound transcript independently of the UI/CLI caller. This prevents a
+        # long chat from competing with the verified report facts in the model
+        # context, even if another integration passes unbounded history.
+        recent_history = list(history)[-6:]
+        transcript_lines: list[str] = []
+        for item in recent_history:
+            content = str(item.get("content", "")).strip()
+            if len(content) > 600:
+                content = content[:600].rstrip() + "…"
+            transcript_lines.append(f"{item.get('role', 'user')}: {content}")
+        transcript = "\n".join(transcript_lines) or "(这是本轮对话的第一条消息)"
+        context = self.build_context(
+            "结合真实消费数据,进行一步一步的 Money Quest 财务教练对话。",
+            evidence=[report_payload],
+            memory=[transcript],
+        )
+        system = (
+            "你是 MoneyMirrorAgent 的互动财务教练。你要像游戏 NPC 一样友好、有趣、会引导,"
+            "但不能戏弄用户或制造焦虑。每次只推进一个小问题:先复述你观察到的消费现象,"
+            "再给一个具体且可执行的小建议,最后提出一个简短追问,帮助用户选择目标或下一步。"
+            "所有金额和比例只能引用输入的 Verified tool output;不得编造数字。"
+            "回答控制在 120-220 字,使用少量 emoji 和 Markdown 列表,让用户能马上行动。"
+        )
+        messages = [
+            {"role": "system", "content": system},
+            {"role": "user", "content": f"{context}\n\n用户刚刚说:{question}"},
+        ]
+        try:
+            yielded = False
+            for chunk in self.llm.stream_invoke(messages, temperature=0.4):
+                if chunk:
+                    yielded = True
+                    yield str(chunk)
+            if not yielded:
+                raise LLMCallError("互动 LLM 返回空内容")
+        except LLMCallError:
+            raise
+        except Exception as exc:
+            raise LLMCallError(f"互动 LLM 流式调用失败:{exc}") from exc
+
+    def classify_uncertain(self, merchant: str, note: str, allowed_categories: list[str]) -> str | None:
+        prompt = (
+            f"商户:{merchant}\n备注:{note}\n只能从以下类别中选择一个并且只输出类别名:"
+            f"{', '.join(allowed_categories)}。证据不足时输出其他。"
+        )
+        answer = self.explain(prompt, mode="react")
+        normalized = answer.strip().splitlines()[0].strip("`。:: ")
+        return normalized if normalized in allowed_categories else None
+
+    def generate_markdown(self, report_payload: str) -> str:
+        """Generate the persisted Markdown report through the LLM."""
+        prompt = (
+            "请把下面的 MoneyMirrorAgent 已验证分析数据写成一份完整的中文 Markdown 月度报告。\n"
+            "必须包含:财务镜像、消费分类与趋势、行为模式、异常消费解释、消费人格、订阅提醒、"
+            "目标进度、动态预算、Money Quest、等级与成就、月度 Reflection 和下一周期行动清单。\n"
+            "要求:只引用输入中的数字;如果某项为空就明确写‘暂无数据’;语气年轻、温和、可执行;"
+            "不要输出代码围栏;直接输出 Markdown 正文。报告建议 1200-2200 字,"
+            "确保保留所有重要的小节,但不要复述原始 JSON。\n\n"
+            f"{report_payload}"
+        )
+        text = self.explain(prompt, mode="reflection")
+        if text.startswith("```"):
+            text = text.strip().removeprefix("```markdown").removesuffix("```").strip()
+        if not text.startswith("#"):
+            text = "# MoneyMirrorAgent 月度报告\n\n" + text
+        return text.rstrip() + "\n"
+
+    def answer_user(self, question: str, report_payload: str) -> str:
+        """Answer a user question using the current verified report context."""
+        return self.explain(
+            "回答用户问题。只能使用已验证账单数据和 Memory。\n"
+            f"用户问题:{question}",
+            mode="simple",
+            evidence=[report_payload],
+        )
+
+    def status_dict(self) -> dict[str, Any]:
+        return {
+            "available": self.status.available,
+            "enabled": self.status.enabled,
+            "reason": self.status.reason,
+            "registry_name": self.status.registry_name,
+            "paradigms": list(self.status.paradigms),
+            "registered_tools": list(self.status.registered_tools),
+            "provider": self.status.provider,
+            "model": self.status.model,
+            "base_url": self.status.base_url,
+        }

+ 41 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/agents/transaction_agent.py

@@ -0,0 +1,41 @@
+"""TransactionAgent: rule/memory-first categorization, LLM only for uncertainty."""
+
+from __future__ import annotations
+
+from typing import Iterable
+
+from ..memory import SQLiteMemory
+from ..models import Transaction
+from ..tools.transaction_category import CATEGORIES, TransactionCategoryTool
+from .runtime import HelloAgentsRuntime
+
+
+class TransactionAgent:
+    paradigm = "ReActAgent-style: inspect transaction → consult memory/rules → resolve only uncertainty"
+
+    def __init__(self, memory: SQLiteMemory, runtime: HelloAgentsRuntime, tool: TransactionCategoryTool | None = None) -> None:
+        self.memory = memory
+        self.runtime = runtime
+        self.tool = tool or TransactionCategoryTool()
+
+    def run(self, transactions: Iterable[Transaction]) -> tuple[list[Transaction], dict]:
+        resolved: list[Transaction] = []
+        source_counts: dict[str, int] = {}
+        for item in transactions:
+            result = self.tool.classify(item, self.memory.get_merchant_category)
+            if result.confidence < 0.5 and item.kind == "expense":
+                llm_category = self.runtime.classify_uncertain(item.merchant, item.note, list(CATEGORIES))
+                if llm_category:
+                    result.category = llm_category
+                    result.confidence = 0.6
+                    result.source = "hello_agents_llm"
+            item.category = result.category
+            item.category_confidence = result.confidence
+            source_counts[result.source] = source_counts.get(result.source, 0) + 1
+            resolved.append(item)
+        return resolved, {"agent": "TransactionAgent", "paradigm": self.paradigm, "classification_sources": source_counts}
+
+    def correct_category(self, merchant: str, category: str) -> None:
+        if category not in CATEGORIES:
+            raise ValueError(f"不支持的分类: {category}")
+        self.memory.set_merchant_category(merchant, category)

+ 122 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/config/personas.json

@@ -0,0 +1,122 @@
+{
+  "version": 2,
+  "archetypes": [
+    {
+      "id": "late_night_focus",
+      "name": "夜行消费探索者",
+      "minimum_score": 52,
+      "required_features": {"night": 38},
+      "weights": {"night": 0.75, "impulse": 0.25},
+      "evidence_metrics": ["late_night_share", "late_night_count", "impulse"]
+    },
+    {
+      "id": "weekend_experience",
+      "name": "周末体验玩家",
+      "minimum_score": 52,
+      "required_features": {"weekend": 38},
+      "weights": {"weekend": 0.65, "flexible_spend": 0.35},
+      "evidence_metrics": ["weekend_share", "weekend_count", "flexible_spend_share"]
+    },
+    {
+      "id": "frequent_small_spend",
+      "name": "高频小额行动派",
+      "minimum_score": 55,
+      "required_features": {"frequent_small": 45},
+      "weights": {"frequent_small": 0.65, "impulse": 0.2, "food": 0.15},
+      "evidence_metrics": ["frequent_small_count", "frequent_small_share", "food_share"]
+    },
+    {
+      "id": "subscription_collector",
+      "name": "数字订阅整理师",
+      "minimum_score": 50,
+      "required_features": {"subscription": 35},
+      "weights": {"subscription": 0.8, "planning_inverse": 0.2},
+      "evidence_metrics": ["subscription_share", "subscription_count"]
+    },
+    {
+      "id": "learning_investor",
+      "name": "学习投入玩家",
+      "minimum_score": 55,
+      "required_features": {"learning": 45, "learning_consistency": 50},
+      "weights": {"learning": 0.75, "learning_consistency": 0.25},
+      "evidence_metrics": ["learning_share", "learning_active_months"]
+    },
+    {
+      "id": "steady_planner",
+      "name": "稳健规划玩家",
+      "minimum_score": 60,
+      "required_features": {"planning": 55, "savings": 45},
+      "weights": {"planning": 0.6, "savings": 0.4},
+      "evidence_metrics": ["savings_rate", "payday_share", "late_night_share"]
+    },
+    {
+      "id": "payday_rhythm",
+      "name": "发薪节奏管理者",
+      "minimum_score": 58,
+      "required_features": {"payday": 55, "impulse": 35},
+      "weights": {"payday": 0.55, "impulse": 0.3, "planning": 0.15},
+      "evidence_metrics": ["payday_share", "savings_rate", "impulse"]
+    },
+    {
+      "id": "weekend_social",
+      "name": "周末社交发动机",
+      "minimum_score": 58,
+      "required_features": {"weekend": 42, "flexible_spend": 30},
+      "weights": {"weekend": 0.55, "flexible_spend": 0.3, "food": 0.15},
+      "evidence_metrics": ["weekend_share", "weekend_count", "food_share", "flexible_spend_share"]
+    },
+    {
+      "id": "food_routine",
+      "name": "日常餐饮探索家",
+      "minimum_score": 62,
+      "required_features": {"food": 55, "frequent_small": 42},
+      "weights": {"food": 0.55, "frequent_small": 0.3, "weekend": 0.15},
+      "evidence_metrics": ["food_share", "frequent_small_count", "frequent_small_share"]
+    },
+    {
+      "id": "savings_sprinter",
+      "name": "储蓄冲刺玩家",
+      "minimum_score": 66,
+      "required_features": {"savings": 65, "planning": 52},
+      "weights": {"savings": 0.65, "planning": 0.35},
+      "evidence_metrics": ["savings_rate", "payday_share", "flexible_spend_share"]
+    },
+    {
+      "id": "digital_lifestyle",
+      "name": "数字生活玩家",
+      "minimum_score": 58,
+      "required_features": {"subscription": 45, "planning_inverse": 30},
+      "weights": {"subscription": 0.68, "planning_inverse": 0.32},
+      "evidence_metrics": ["subscription_share", "subscription_count", "savings_rate"]
+    },
+    {
+      "id": "learning_consistent",
+      "name": "学习成长玩家",
+      "minimum_score": 60,
+      "required_features": {"learning": 48, "learning_consistency": 65},
+      "weights": {"learning_consistency": 0.6, "learning": 0.4},
+      "evidence_metrics": ["learning_share", "learning_active_months", "savings_rate"]
+    },
+    {
+      "id": "flexible_adventurer",
+      "name": "弹性体验玩家",
+      "minimum_score": 60,
+      "required_features": {"flexible_spend": 52},
+      "weights": {"flexible_spend": 0.62, "weekend": 0.23, "impulse_inverse": 0.15},
+      "evidence_metrics": ["flexible_spend_share", "weekend_share", "weekend_count", "impulse"]
+    },
+    {
+      "id": "mindful_minimalist",
+      "name": "清醒消费实践者",
+      "minimum_score": 64,
+      "required_features": {"planning": 62, "impulse_inverse": 58},
+      "weights": {"planning": 0.45, "savings": 0.3, "impulse_inverse": 0.25},
+      "evidence_metrics": ["savings_rate", "impulse", "late_night_share", "frequent_small_share"]
+    }
+  ],
+  "fallback": {
+    "id": "balanced_explorer",
+    "name": "均衡节奏探索者",
+    "evidence_metrics": ["savings_rate", "late_night_share", "weekend_share"]
+  }
+}

+ 5 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/memory/__init__.py

@@ -0,0 +1,5 @@
+"""Durable SQLite Memory for MoneyMirrorAgent."""
+
+from .sqlite_memory import SQLiteMemory
+
+__all__ = ["SQLiteMemory"]

+ 161 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/memory/sqlite_memory.py

@@ -0,0 +1,161 @@
+"""Small, explicit SQLite-backed long-term memory.
+
+The database is local by design: personal financial data should not leave the
+user's machine just to power a dashboard demo.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterable
+
+from ..models import Goal, Quest
+
+
+class SQLiteMemory:
+    def __init__(self, path: str | Path = "outputs/moneymirror.db", user_id: str = "local_user") -> None:
+        self.path = str(path)
+        self.user_id = user_id
+        if self.path != ":memory:":
+            Path(self.path).parent.mkdir(parents=True, exist_ok=True)
+        self.connection = sqlite3.connect(self.path)
+        self.connection.row_factory = sqlite3.Row
+        self._initialize()
+
+    def close(self) -> None:
+        self.connection.close()
+
+    def _initialize(self) -> None:
+        self.connection.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS merchant_categories (
+                user_id TEXT NOT NULL, merchant TEXT NOT NULL, category TEXT NOT NULL,
+                updated_at TEXT NOT NULL, PRIMARY KEY (user_id, merchant)
+            );
+            CREATE TABLE IF NOT EXISTS goals (
+                user_id TEXT NOT NULL, goal_id TEXT NOT NULL, payload TEXT NOT NULL,
+                updated_at TEXT NOT NULL, PRIMARY KEY (user_id, goal_id)
+            );
+            CREATE TABLE IF NOT EXISTS budgets (
+                user_id TEXT NOT NULL, month TEXT NOT NULL, payload TEXT NOT NULL,
+                updated_at TEXT NOT NULL, PRIMARY KEY (user_id, month)
+            );
+            CREATE TABLE IF NOT EXISTS quests (
+                user_id TEXT NOT NULL, quest_id TEXT NOT NULL, payload TEXT NOT NULL,
+                updated_at TEXT NOT NULL, PRIMARY KEY (user_id, quest_id)
+            );
+            CREATE TABLE IF NOT EXISTS achievements (
+                user_id TEXT NOT NULL, achievement_key TEXT NOT NULL, payload TEXT NOT NULL,
+                unlocked_at TEXT NOT NULL, PRIMARY KEY (user_id, achievement_key)
+            );
+            CREATE TABLE IF NOT EXISTS snapshots (
+                user_id TEXT NOT NULL, month TEXT NOT NULL, payload TEXT NOT NULL,
+                created_at TEXT NOT NULL, PRIMARY KEY (user_id, month)
+            );
+            CREATE TABLE IF NOT EXISTS reflections (
+                user_id TEXT NOT NULL, month TEXT NOT NULL, payload TEXT NOT NULL,
+                created_at TEXT NOT NULL, PRIMARY KEY (user_id, month)
+            );
+            CREATE TABLE IF NOT EXISTS preferences (
+                user_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
+                updated_at TEXT NOT NULL, PRIMARY KEY (user_id, key)
+            );
+            """
+        )
+        self.connection.commit()
+
+    @staticmethod
+    def _now() -> str:
+        return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+    def set_merchant_category(self, merchant: str, category: str) -> None:
+        self.connection.execute(
+            "INSERT INTO merchant_categories VALUES (?, ?, ?, ?) ON CONFLICT(user_id, merchant) DO UPDATE SET category=excluded.category, updated_at=excluded.updated_at",
+            (self.user_id, merchant.strip(), category, self._now()),
+        )
+        self.connection.commit()
+
+    def get_merchant_category(self, merchant: str) -> str | None:
+        row = self.connection.execute("SELECT category FROM merchant_categories WHERE user_id=? AND merchant=?", (self.user_id, merchant.strip())).fetchone()
+        return row["category"] if row else None
+
+    def merchant_categories(self) -> dict[str, str]:
+        rows = self.connection.execute("SELECT merchant, category FROM merchant_categories WHERE user_id=?", (self.user_id,)).fetchall()
+        return {row["merchant"]: row["category"] for row in rows}
+
+    def save_goal(self, goal: Goal | dict[str, Any]) -> None:
+        payload = goal.to_dict() if isinstance(goal, Goal) else goal
+        self._upsert_payload("goals", "goal_id", payload["goal_id"], payload)
+
+    def list_goals(self, active_only: bool = False) -> list[dict[str, Any]]:
+        rows = self.connection.execute("SELECT payload FROM goals WHERE user_id=? ORDER BY updated_at", (self.user_id,)).fetchall()
+        values = [json.loads(row["payload"]) for row in rows]
+        return [value for value in values if value.get("active", True)] if active_only else values
+
+    def save_budget(self, month: str, budget: dict[str, Any]) -> None:
+        self._upsert_payload("budgets", "month", month, budget)
+
+    def get_budget(self, month: str) -> dict[str, Any] | None:
+        return self._get_payload("budgets", "month", month)
+
+    def save_quest(self, quest: Quest | dict[str, Any]) -> None:
+        payload = quest.to_dict() if isinstance(quest, Quest) else quest
+        self._upsert_payload("quests", "quest_id", payload["quest_id"], payload)
+
+    def list_quests(self, active_only: bool = False) -> list[dict[str, Any]]:
+        rows = self.connection.execute("SELECT payload FROM quests WHERE user_id=? ORDER BY updated_at", (self.user_id,)).fetchall()
+        values = [json.loads(row["payload"]) for row in rows]
+        return [value for value in values if value.get("status") == "active"] if active_only else values
+
+    def save_achievement(self, achievement: dict[str, Any]) -> None:
+        self._upsert_payload("achievements", "achievement_key", achievement["key"], achievement, timestamp_column="unlocked_at")
+
+    def list_achievements(self) -> list[dict[str, Any]]:
+        rows = self.connection.execute("SELECT payload FROM achievements WHERE user_id=? ORDER BY unlocked_at", (self.user_id,)).fetchall()
+        return [json.loads(row["payload"]) for row in rows]
+
+    def save_snapshot(self, month: str, snapshot: dict[str, Any]) -> None:
+        self._upsert_payload("snapshots", "month", month, snapshot, timestamp_column="created_at")
+
+    def get_snapshot(self, month: str) -> dict[str, Any] | None:
+        return self._get_payload("snapshots", "month", month)
+
+    def list_snapshots(self) -> list[dict[str, Any]]:
+        rows = self.connection.execute("SELECT month, payload FROM snapshots WHERE user_id=? ORDER BY month", (self.user_id,)).fetchall()
+        return [{"month": row["month"], **json.loads(row["payload"])} for row in rows]
+
+    def save_reflection(self, month: str, reflection: dict[str, Any]) -> None:
+        self._upsert_payload("reflections", "month", month, reflection, timestamp_column="created_at")
+
+    def get_reflection(self, month: str) -> dict[str, Any] | None:
+        return self._get_payload("reflections", "month", month)
+
+    def list_reflections(self) -> list[dict[str, Any]]:
+        rows = self.connection.execute("SELECT month, payload FROM reflections WHERE user_id=? ORDER BY month", (self.user_id,)).fetchall()
+        return [{"month": row["month"], **json.loads(row["payload"])} for row in rows]
+
+    def set_preference(self, key: str, value: Any) -> None:
+        self.connection.execute(
+            "INSERT INTO preferences VALUES (?, ?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
+            (self.user_id, key, json.dumps(value, ensure_ascii=False), self._now()),
+        )
+        self.connection.commit()
+
+    def get_preference(self, key: str, default: Any = None) -> Any:
+        row = self.connection.execute("SELECT value FROM preferences WHERE user_id=? AND key=?", (self.user_id, key)).fetchone()
+        return json.loads(row["value"]) if row else default
+
+    def _upsert_payload(self, table: str, key_column: str, key: str, payload: dict[str, Any], timestamp_column: str = "updated_at") -> None:
+        columns = f"user_id, {key_column}, payload, {timestamp_column}"
+        self.connection.execute(
+            f"INSERT INTO {table} ({columns}) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, {key_column}) DO UPDATE SET payload=excluded.payload, {timestamp_column}=excluded.{timestamp_column}",
+            (self.user_id, key, json.dumps(payload, ensure_ascii=False), self._now()),
+        )
+        self.connection.commit()
+
+    def _get_payload(self, table: str, key_column: str, key: str) -> dict[str, Any] | None:
+        row = self.connection.execute(f"SELECT payload FROM {table} WHERE user_id=? AND {key_column}=?", (self.user_id, key)).fetchone()
+        return json.loads(row["payload"]) if row else None

+ 136 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/models.py

@@ -0,0 +1,136 @@
+"""Typed domain models used by deterministic tools and agent orchestration."""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from datetime import date
+from typing import Any, Literal
+
+TransactionKind = Literal["income", "expense"]
+
+
+@dataclass(slots=True)
+class Transaction:
+    transaction_id: str
+    occurred_at: str
+    merchant: str
+    amount: float
+    kind: TransactionKind
+    category: str = "Uncategorized"
+    note: str = ""
+    source: str = "csv"
+    category_confidence: float = 0.0
+
+    @property
+    def date(self) -> date:
+        return date.fromisoformat(self.occurred_at[:10])
+
+    @property
+    def hour(self) -> int:
+        if "T" not in self.occurred_at:
+            return 12
+        return int(self.occurred_at.split("T", 1)[1][:2])
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass(slots=True)
+class Anomaly:
+    transaction_id: str
+    merchant: str
+    category: str
+    amount: float
+    occurred_at: str
+    method: str
+    score: float
+    reason: str
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass(slots=True)
+class Goal:
+    goal_id: str
+    title: str
+    goal_type: Literal["savings", "travel", "category_limit"]
+    target_amount: float
+    current_amount: float
+    deadline: str
+    category: str | None = None
+    monthly_limit: float | None = None
+    active: bool = True
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass(slots=True)
+class Quest:
+    quest_id: str
+    title: str
+    description: str
+    quest_type: str
+    target: float
+    progress: float
+    unit: str
+    exp_reward: int
+    status: Literal["active", "completed"] = "active"
+    evidence: str = ""
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass(slots=True)
+class Achievement:
+    key: str
+    title: str
+    description: str
+    unlocked: bool
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass(slots=True)
+class AnalysisReport:
+    user_id: str
+    month: str
+    transactions: list[Transaction]
+    summary: dict[str, Any]
+    category_breakdown: dict[str, float]
+    trends: dict[str, dict[str, float]]
+    patterns: dict[str, Any]
+    anomalies: list[Anomaly]
+    subscriptions: list[dict[str, Any]]
+    persona: dict[str, Any]
+    budget: dict[str, Any]
+    goals: list[dict[str, Any]]
+    quests: list[Quest]
+    achievements: list[Achievement]
+    gamification: dict[str, Any]
+    reflection: dict[str, Any]
+    agent_trace: list[dict[str, Any]] = field(default_factory=list)
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "user_id": self.user_id,
+            "month": self.month,
+            "transactions": [item.to_dict() for item in self.transactions],
+            "summary": self.summary,
+            "category_breakdown": self.category_breakdown,
+            "trends": self.trends,
+            "patterns": self.patterns,
+            "anomalies": [item.to_dict() for item in self.anomalies],
+            "subscriptions": self.subscriptions,
+            "persona": self.persona,
+            "budget": self.budget,
+            "goals": self.goals,
+            "quests": [item.to_dict() for item in self.quests],
+            "achievements": [item.to_dict() for item in self.achievements],
+            "gamification": self.gamification,
+            "reflection": self.reflection,
+            "agent_trace": self.agent_trace,
+        }

+ 21 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/__init__.py

@@ -0,0 +1,21 @@
+"""Deterministic tools used by MoneyMirrorAgent."""
+
+from .anomaly_detection import AnomalyDetectionTool
+from .budget_calculator import BudgetCalculatorTool
+from .csv_import import CSVImportTool
+from .goal_projection import GoalProjectionTool
+from .quest_progress import QuestProgressTool
+from .statistics import StatisticsTool
+from .subscription_detector import SubscriptionDetectorTool
+from .transaction_category import TransactionCategoryTool
+
+__all__ = [
+    "AnomalyDetectionTool",
+    "BudgetCalculatorTool",
+    "CSVImportTool",
+    "GoalProjectionTool",
+    "QuestProgressTool",
+    "StatisticsTool",
+    "SubscriptionDetectorTool",
+    "TransactionCategoryTool",
+]

+ 61 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/anomaly_detection.py

@@ -0,0 +1,61 @@
+"""Transparent IQR, z-score, and median-ratio anomaly detection."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from statistics import mean, median, pstdev
+from typing import Iterable
+
+from ..models import Anomaly, Transaction
+
+
+class AnomalyDetectionTool:
+    def detect(self, transactions: Iterable[Transaction], month: str | None = None) -> list[Anomaly]:
+        items = [x for x in transactions if x.kind == "expense" and (not month or x.date.strftime("%Y-%m") == month)]
+        by_category: dict[str, list[float]] = defaultdict(list)
+        for item in items:
+            by_category[item.category].append(item.amount)
+        anomalies: list[Anomaly] = []
+        for item in items:
+            values = by_category[item.category]
+            if len(values) < 4:
+                # For sparse categories, use a conservative median multiplier.
+                # A 2.5x ratio still requires a meaningful amount gap, while
+                # making three-point categories (for example, shopping) useful.
+                baseline = median(values) if values else 0
+                if baseline and item.amount >= max(2.5 * baseline, baseline + 200):
+                    anomalies.append(Anomaly(item.transaction_id, item.merchant, item.category, item.amount, item.occurred_at, "sparse-median", round(item.amount / baseline, 2), f"金额约为该类别中位数的 {item.amount / baseline:.1f} 倍"))
+                continue
+            ordered = sorted(values)
+            category_median = median(values)
+            # IQR and z-score are intentionally complemented by a large
+            # median-ratio guard. In small, skewed categories a single large
+            # purchase can pull Q3 and the mean upward, hiding the very event
+            # a user expects the agent to explain (for example a ¥1,288
+            # electronics purchase among ordinary shopping transactions).
+            median_upper = max(3 * category_median, category_median + 500)
+            if item.amount >= median_upper:
+                ratio = item.amount / category_median if category_median else 0.0
+                anomalies.append(Anomaly(item.transaction_id, item.merchant, item.category, item.amount, item.occurred_at, "median-ratio", round(ratio, 2), f"金额约为该类别中位数的 {ratio:.1f} 倍"))
+                continue
+            q1 = self._percentile(ordered, 0.25)
+            q3 = self._percentile(ordered, 0.75)
+            iqr = q3 - q1
+            upper = q3 + 1.5 * iqr
+            category_mean = mean(values)
+            deviation = pstdev(values)
+            z = (item.amount - category_mean) / deviation if deviation else 0.0
+            if item.amount > upper:
+                anomalies.append(Anomaly(item.transaction_id, item.merchant, item.category, item.amount, item.occurred_at, "IQR", round(z, 2), f"高于 {item.category} 类别 IQR 上界 {upper:.2f}"))
+            elif abs(z) >= 2.5:
+                anomalies.append(Anomaly(item.transaction_id, item.merchant, item.category, item.amount, item.occurred_at, "Z-score", round(z, 2), f"相对该类别均值偏离 {z:.1f} 个标准差"))
+        return sorted(anomalies, key=lambda item: item.amount, reverse=True)
+
+    @staticmethod
+    def _percentile(values: list[float], fraction: float) -> float:
+        if len(values) == 1:
+            return values[0]
+        position = (len(values) - 1) * fraction
+        lower = int(position)
+        upper = min(lower + 1, len(values) - 1)
+        return values[lower] + (values[upper] - values[lower]) * (position - lower)

+ 52 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/budget_calculator.py

@@ -0,0 +1,52 @@
+"""Behavior-aware next-month budget calculation."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from statistics import median
+from typing import Iterable
+
+from ..models import Transaction
+
+
+class BudgetCalculatorTool:
+    fixed_categories = {"住房", "订阅"}
+    necessary_categories = {"餐饮", "交通", "学习", "健身", "医疗"}
+    # ``flexible`` is adjustable but still part of daily life; ``optional``
+    # represents discretionary experiences and shopping that can be paused.
+    flexible_categories = {"其他"}
+    optional_categories = {"娱乐", "购物"}
+
+    def calculate(self, transactions: Iterable[Transaction], month: str | None = None) -> dict:
+        items = [x for x in transactions if x.kind == "expense"]
+        by_month_category: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
+        for item in items:
+            by_month_category[item.date.strftime("%Y-%m")][item.category] += item.amount
+        months = sorted(by_month_category)
+        history = months[:-1] if month and month in months else months
+        if not history:
+            history = months
+        categories = sorted({cat for values in by_month_category.values() for cat in values})
+        lines: dict[str, dict] = {}
+        for category in categories:
+            values = [by_month_category[m].get(category, 0.0) for m in history]
+            baseline = median(values) if values else 0.0
+            if category in self.fixed_categories:
+                recommended = baseline
+                bucket = "fixed"
+                rationale = "固定支出,原则上不做大幅削减"
+            elif category in self.necessary_categories:
+                recommended = max(baseline * 0.97, baseline - 80) if baseline else 0.0
+                bucket = "necessary"
+                rationale = "必要支出,参考历史中位数并保留缓冲"
+            elif category in self.optional_categories:
+                recommended = max(baseline * 0.85, baseline - 120) if baseline else 0.0
+                bucket = "optional"
+                rationale = "可选支出,保留真实体验额度并设置温和上限"
+            else:
+                recommended = max(baseline * 0.9, baseline - 80) if baseline else 0.0
+                bucket = "flexible"
+                rationale = "弹性支出,结合历史行为小幅调整"
+            lines[category] = {"bucket": bucket, "historical_median": round(baseline, 2), "recommended": round(recommended, 2), "rationale": rationale}
+        total = round(sum(item["recommended"] for item in lines.values()), 2)
+        return {"month": month, "categories": lines, "recommended_total": total, "historical_months": history, "principle": "基于历史中位数,固定支出不削减,必要/弹性/可选支出分层温和调整"}

+ 160 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/csv_import.py

@@ -0,0 +1,160 @@
+"""CSV bill import and normalization."""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import io
+from datetime import datetime
+from pathlib import Path
+from typing import BinaryIO, Iterable, TextIO
+
+from ..models import Transaction
+
+
+class CSVImportTool:
+    """Normalize common Chinese/English bank-export columns into transactions.
+
+    Supported amount conventions:
+    - positive amount + a direction/type column;
+    - negative amount means expense and positive means income when direction is absent;
+    - separate income/expense columns (the non-empty one wins).
+    """
+
+    aliases = {
+        "date": ("date", "日期", "交易日期", "时间", "交易时间", "occurred_at"),
+        "merchant": ("merchant", "商户", "商户名称", "交易对方", "description", "摘要", "备注"),
+        "amount": ("amount", "金额", "交易金额", "price", "消费金额"),
+        "direction": ("direction", "收支", "类型", "交易类型", "kind", "income_expense"),
+        "income": ("income", "收入", "入账"),
+        "expense": ("expense", "支出", "消费", "付款"),
+        "category": ("category", "类别", "分类", "消费类别"),
+        "note": ("note", "备注", "说明", "memo"),
+    }
+
+    date_formats = (
+        "%Y-%m-%d %H:%M:%S",
+        "%Y-%m-%d %H:%M",
+        "%Y/%m/%d %H:%M:%S",
+        "%Y/%m/%d %H:%M",
+        "%Y-%m-%d",
+        "%Y/%m/%d",
+        "%Y.%m.%d",
+        "%Y年%m月%d日",
+    )
+
+    def __init__(self) -> None:
+        # Exposed for the coordinator/UI so partially malformed exports are
+        # visible instead of being silently mistaken for complete imports.
+        self.last_errors: list[str] = []
+
+    def load(self, source: str | Path | TextIO | BinaryIO) -> list[Transaction]:
+        self.last_errors = []
+        if hasattr(source, "read"):
+            raw = source.read()
+            source_name = getattr(source, "name", "uploaded.csv")
+            text = self._decode(raw)
+        else:
+            path = Path(source)
+            raw = path.read_bytes()
+            source_name = str(path)
+            text = self._decode(raw)
+        if not text.strip():
+            return []
+        reader = csv.DictReader(io.StringIO(text))
+        if not reader.fieldnames:
+            raise ValueError("CSV 缺少表头")
+        mapping = self._resolve_columns(reader.fieldnames)
+        if not mapping.get("date") or not mapping.get("merchant"):
+            raise ValueError("CSV 至少需要日期和商户列")
+        transactions: list[Transaction] = []
+        errors: list[str] = []
+        for row_number, row in enumerate(reader, start=2):
+            try:
+                transactions.append(self._row_to_transaction(row, mapping, source_name, row_number))
+            except ValueError as exc:
+                errors.append(f"第 {row_number} 行: {exc}")
+        self.last_errors = errors
+        if errors and not transactions:
+            raise ValueError("CSV 没有可导入的交易: " + "; ".join(errors[:3]))
+        return transactions
+
+    @staticmethod
+    def _decode(raw: str | bytes | bytearray) -> str:
+        if isinstance(raw, str):
+            return raw
+        for encoding in ("utf-8-sig", "utf-8", "gb18030"):
+            try:
+                return bytes(raw).decode(encoding)
+            except UnicodeDecodeError:
+                continue
+        raise ValueError("CSV 编码无法识别,请另存为 UTF-8 或 GB18030")
+
+    def _resolve_columns(self, fields: Iterable[str]) -> dict[str, str | None]:
+        normalized = {self._norm(field): field for field in fields if field}
+        result: dict[str, str | None] = {}
+        for target, candidates in self.aliases.items():
+            result[target] = next((normalized[self._norm(c)] for c in candidates if self._norm(c) in normalized), None)
+        return result
+
+    @staticmethod
+    def _norm(value: str) -> str:
+        return "".join(str(value).strip().lower().replace("_", "").replace("-", "").split())
+
+    def _row_to_transaction(self, row: dict[str, str], mapping: dict[str, str | None], source: str, row_number: int) -> Transaction:
+        raw_date = (row.get(mapping["date"] or "") or "").strip()
+        occurred_at = self._parse_date(raw_date)
+        merchant = (row.get(mapping["merchant"] or "") or "").strip() or "未知商户"
+        amount, kind = self._parse_amount_and_kind(row, mapping)
+        category = (row.get(mapping["category"] or "") or "").strip() or "Uncategorized"
+        note = (row.get(mapping["note"] or "") or "").strip()
+        digest = hashlib.sha1(f"{source}:{row_number}:{occurred_at}:{merchant}:{amount}:{kind}".encode()).hexdigest()[:16]
+        return Transaction(digest, occurred_at, merchant, round(abs(amount), 2), kind, category, note, source)
+
+    def _parse_date(self, raw: str) -> str:
+        if not raw:
+            raise ValueError("日期为空")
+        candidate = raw.replace("T", " ").strip()
+        try:
+            parsed = datetime.fromisoformat(candidate)
+            return parsed.isoformat(timespec="minutes")
+        except ValueError:
+            pass
+        for fmt in self.date_formats:
+            try:
+                return datetime.strptime(candidate, fmt).isoformat(timespec="minutes")
+            except ValueError:
+                continue
+        raise ValueError(f"无法解析日期 {raw!r}")
+
+    def _parse_amount_and_kind(self, row: dict[str, str], mapping: dict[str, str | None]) -> tuple[float, str]:
+        income_raw = self._number(row.get(mapping["income"] or ""))
+        expense_raw = self._number(row.get(mapping["expense"] or ""))
+        if income_raw is not None and abs(income_raw) > 0:
+            return abs(income_raw), "income"
+        if expense_raw is not None and abs(expense_raw) > 0:
+            return abs(expense_raw), "expense"
+        raw = (row.get(mapping["amount"] or "") or "").strip()
+        if not raw:
+            raise ValueError("金额为空")
+        amount = self._number(raw)
+        if amount is None:
+            raise ValueError(f"无法解析金额 {raw!r}")
+        direction = (row.get(mapping["direction"] or "") or "").strip().lower()
+        if any(token in direction for token in ("收入", "入账", "转入", "退款", "退货", "income", "credit", "deposit", "refund", "工资")):
+            kind = "income"
+        elif any(token in direction for token in ("支出", "消费", "expense", "debit", "payment", "付款")):
+            kind = "expense"
+        else:
+            kind = "income" if amount > 0 else "expense"
+        return abs(amount), kind
+
+    @staticmethod
+    def _number(raw: str | None) -> float | None:
+        if raw is None or not str(raw).strip():
+            return None
+        cleaned = str(raw).strip().replace(",", "").replace("¥", "").replace("¥", "")
+        try:
+            return float(cleaned)
+        except ValueError:
+            return None

+ 58 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/goal_projection.py

@@ -0,0 +1,58 @@
+"""Savings and category-limit feasibility calculations."""
+
+from __future__ import annotations
+
+from datetime import date
+from math import ceil
+from typing import Iterable
+
+from ..models import Goal, Transaction
+
+
+class GoalProjectionTool:
+    def project(self, goal: Goal, transactions: Iterable[Transaction], current_month: str | None = None) -> dict:
+        items = list(transactions)
+        if current_month:
+            current = date.fromisoformat(f"{current_month}-01")
+        else:
+            current = max((item.date for item in items), default=date.today()).replace(day=1)
+        deadline = date.fromisoformat(goal.deadline[:10]).replace(day=1)
+        months_left = max(1, (deadline.year - current.year) * 12 + deadline.month - current.month + 1)
+        income_by_month: dict[str, float] = {}
+        expenses_by_month: dict[str, float] = {}
+        category_total = 0.0
+        for item in items:
+            key = item.date.strftime("%Y-%m")
+            if item.kind == "income":
+                income_by_month[key] = income_by_month.get(key, 0.0) + item.amount
+            else:
+                expenses_by_month[key] = expenses_by_month.get(key, 0.0) + item.amount
+                if goal.category and item.category == goal.category and key == current.strftime("%Y-%m"):
+                    category_total += item.amount
+        months = sorted(set(income_by_month) | set(expenses_by_month))
+        surpluses = [income_by_month.get(key, 0.0) - expenses_by_month.get(key, 0.0) for key in months if income_by_month.get(key, 0.0)]
+        average_surplus = sum(surpluses) / len(surpluses) if surpluses else 0.0
+        if goal.goal_type == "category_limit" and goal.category:
+            required = max(0.0, (goal.monthly_limit or goal.target_amount) - category_total)
+            feasible = category_total <= (goal.monthly_limit or goal.target_amount)
+            projected = category_total
+        else:
+            remaining = max(0.0, goal.target_amount - goal.current_amount)
+            required = remaining / months_left
+            feasible = average_surplus >= required
+            projected = goal.current_amount + max(0.0, average_surplus) * months_left
+        return {
+            "goal_id": goal.goal_id,
+            "title": goal.title,
+            "goal_type": goal.goal_type,
+            "target_amount": round(goal.target_amount, 2),
+            "current_amount": round(goal.current_amount, 2),
+            "deadline": goal.deadline,
+            "months_left": months_left,
+            "required_monthly_amount": round(required, 2),
+            "average_monthly_surplus": round(average_surplus, 2),
+            "projected_amount": round(projected, 2),
+            "progress_percent": round(min(100.0, goal.current_amount / goal.target_amount * 100) if goal.target_amount else 0.0, 2),
+            "feasible": feasible,
+            "advice": "当前现金流支持该目标" if feasible else "目标偏紧,建议延长期限或降低阶段性目标",
+        }

+ 126 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/hello_agents_registry.py

@@ -0,0 +1,126 @@
+"""JSON adapters that expose MoneyMirror tools through Hello-Agents ToolRegistry.
+
+The coordinator still calls the Python tools directly for the normal pipeline.
+These small adapters make the same deterministic capabilities available to a
+Hello-Agents ReAct agent without moving financial arithmetic into an LLM.
+"""
+
+from __future__ import annotations
+
+import json
+from io import StringIO
+from typing import Any, Callable
+
+from ..models import Goal, Quest, Transaction
+from .anomaly_detection import AnomalyDetectionTool
+from .budget_calculator import BudgetCalculatorTool
+from .csv_import import CSVImportTool
+from .goal_projection import GoalProjectionTool
+from .quest_progress import QuestProgressTool
+from .statistics import StatisticsTool
+from .subscription_detector import SubscriptionDetectorTool
+from .transaction_category import TransactionCategoryTool
+
+
+def _payload(raw: str | dict[str, Any]) -> dict[str, Any]:
+    if isinstance(raw, dict):
+        return raw
+    try:
+        value = json.loads(raw)
+    except (TypeError, json.JSONDecodeError):
+        return {"input": raw}
+    return value if isinstance(value, dict) else {"input": value}
+
+
+def _transactions(payload: dict[str, Any]) -> list[Transaction]:
+    fields = {
+        "transaction_id",
+        "occurred_at",
+        "merchant",
+        "amount",
+        "kind",
+        "category",
+        "note",
+        "source",
+        "category_confidence",
+    }
+    return [Transaction(**{key: item[key] for key in fields if key in item}) for item in payload.get("transactions", [])]
+
+
+def _json(value: Any) -> str:
+    if hasattr(value, "to_dict"):
+        value = value.to_dict()
+    elif isinstance(value, list):
+        value = [item.to_dict() if hasattr(item, "to_dict") else item for item in value]
+    return json.dumps(value, ensure_ascii=False, default=str)
+
+
+def build_registry_functions(
+    csv_import: CSVImportTool,
+    category: TransactionCategoryTool,
+    statistics: StatisticsTool,
+    anomalies: AnomalyDetectionTool,
+    budget: BudgetCalculatorTool,
+    projection: GoalProjectionTool,
+    subscriptions: SubscriptionDetectorTool,
+    quest_progress: QuestProgressTool,
+    memory_lookup: Callable[[str], str | None],
+) -> dict[str, tuple[Callable[[str | dict[str, Any]], str], str]]:
+    """Build function tools with stable JSON-in/JSON-out contracts."""
+
+    def import_csv(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        transactions = csv_import.load(StringIO(str(data.get("csv_text", ""))))
+        return _json([item.to_dict() for item in transactions])
+
+    def classify_transaction(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        transaction = Transaction(
+            transaction_id=str(data.get("transaction_id", "registry")),
+            occurred_at=str(data.get("occurred_at", "2000-01-01T12:00")),
+            merchant=str(data.get("merchant", "未知商户")),
+            amount=float(data.get("amount", 0)),
+            kind=data.get("kind", "expense"),
+            note=str(data.get("note", "")),
+        )
+        result = category.classify(transaction, memory_lookup)
+        return _json({
+            "category": result.category,
+            "confidence": result.confidence,
+            "source": result.source,
+        })
+
+    def summarize(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        return _json(statistics.summarize(_transactions(data), data.get("month")))
+
+    def detect_anomalies(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        return _json(anomalies.detect(_transactions(data), data.get("month")))
+
+    def calculate_budget(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        return _json(budget.calculate(_transactions(data), str(data.get("month"))))
+
+    def project_goal(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        return _json(projection.project(Goal(**data["goal"]), _transactions(data), data.get("month")))
+
+    def detect_subscriptions(raw: str | dict[str, Any]) -> str:
+        return _json(subscriptions.detect(_transactions(_payload(raw))))
+
+    def update_quest(raw: str | dict[str, Any]) -> str:
+        data = _payload(raw)
+        quest = quest_progress.update(Quest(**data["quest"]), _transactions(data), data.get("month"))
+        return _json(quest)
+
+    return {
+        "CSVImportTool": (import_csv, "从 csv_text 导入并规范化账单交易"),
+        "TransactionCategoryTool": (classify_transaction, "按 Memory 和规则为一笔交易分类"),
+        "StatisticsTool": (summarize, "计算收入、支出、结余和消费统计"),
+        "AnomalyDetectionTool": (detect_anomalies, "使用确定性统计方法检测异常消费"),
+        "BudgetCalculatorTool": (calculate_budget, "根据历史消费计算动态预算"),
+        "GoalProjectionTool": (project_goal, "投影财务目标可行性和所需月度额度"),
+        "SubscriptionDetectorTool": (detect_subscriptions, "识别疑似周期性订阅扣费"),
+        "QuestProgressTool": (update_quest, "根据真实交易更新 Money Quest 进度"),
+    }

+ 73 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/quest_progress.py

@@ -0,0 +1,73 @@
+"""Quest progress calculations: deterministic evidence, no invented completion."""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+from typing import Iterable
+
+from ..models import Quest, Transaction
+
+
+class QuestProgressTool:
+    def update(self, quest: Quest, transactions: Iterable[Transaction], month: str | None = None) -> Quest:
+        items = [x for x in transactions if x.kind == "expense" and (not month or x.date.strftime("%Y-%m") == month)]
+        if quest.quest_type == "zero_spend_days":
+            all_days = {x.date for x in items}
+            if all_days:
+                start, end = min(all_days), max(all_days)
+                days = 0
+                cursor = start
+                while cursor <= end:
+                    if cursor not in all_days:
+                        days += 1
+                    cursor += timedelta(days=1)
+                quest.progress = min(quest.target, float(days))
+                quest.evidence = f"分析区间内发现 {days} 个无支出日"
+        elif quest.quest_type == "late_night_limit":
+            late = [x for x in items if x.hour >= 22 or x.hour < 6]
+            quest.progress = max(0.0, quest.target - len(late))
+            quest.evidence = f"深夜消费 {len(late)} 笔,目标最多 {quest.target:.0f} 笔"
+        elif quest.quest_type == "category_limit":
+            spent = sum(x.amount for x in items if x.category == quest.unit)
+            quest.progress = max(0.0, quest.target - spent)
+            quest.evidence = f"{quest.unit} 已消费 ¥{spent:.2f},预算上限 ¥{quest.target:.2f}"
+        elif quest.quest_type == "weekend_spend_limit":
+            spent = sum(x.amount for x in items if x.date.weekday() >= 5)
+            quest.progress = max(0.0, quest.target - spent)
+            quest.evidence = f"周末已消费 ¥{spent:.2f},温和上限 ¥{quest.target:.2f}"
+        elif quest.quest_type == "payday_window_limit":
+            income_dates = {x.date for x in transactions if x.kind == "income" and (not month or x.date.strftime("%Y-%m") == month)}
+            spent = sum(
+                x.amount
+                for x in items
+                if any(0 <= (x.date - income_date).days <= 3 for income_date in income_dates)
+            )
+            quest.progress = max(0.0, quest.target - spent)
+            quest.evidence = f"工资到账后 3 天内已消费 ¥{spent:.2f},温和上限 ¥{quest.target:.2f}"
+        elif quest.quest_type == "subscription_review":
+            quest.progress = min(quest.target, 0.0)
+            quest.evidence = "需要用户在后续 CLI 引导中确认完成"
+        else:
+            quest.evidence = "等待用户更新进度"
+        if quest.quest_type in {"zero_spend_days", "late_night_limit", "category_limit", "weekend_spend_limit", "payday_window_limit"} and quest.progress >= quest.target:
+            quest.status = "completed"
+        return quest
+    def max_zero_spend_streak(self, transactions: Iterable[Transaction], month: str | None = None) -> int:
+        """Return the longest consecutive no-expense-day streak in a period."""
+
+        items = [x for x in transactions if x.kind == "expense" and (not month or x.date.strftime("%Y-%m") == month)]
+        if not items:
+            return 0
+        spent_days = {x.date for x in items}
+        start, end = min(spent_days), max(spent_days)
+        longest = current = 0
+        cursor = start
+        while cursor <= end:
+            if cursor in spent_days:
+                current = 0
+            else:
+                current += 1
+                longest = max(longest, current)
+            cursor += timedelta(days=1)
+        return longest
+

+ 89 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/statistics.py

@@ -0,0 +1,89 @@
+"""Deterministic cash-flow, category, trend, and behavior statistics."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import date, timedelta
+from statistics import mean, median
+from typing import Iterable
+
+from ..models import Transaction
+
+
+class StatisticsTool:
+    def summarize(self, transactions: Iterable[Transaction], month: str | None = None) -> dict:
+        items = [item for item in transactions if not month or item.date.strftime("%Y-%m") == month]
+        income = round(sum(item.amount for item in items if item.kind == "income"), 2)
+        expense = round(sum(item.amount for item in items if item.kind == "expense"), 2)
+        balance = round(income - expense, 2)
+        return {
+            "transaction_count": len(items),
+            "income": income,
+            "expense": expense,
+            "balance": balance,
+            "savings_rate": round(balance / income * 100, 2) if income else 0.0,
+            "average_expense": round(expense / max(1, sum(1 for x in items if x.kind == "expense")), 2),
+            "active_days": len({item.date.isoformat() for item in items if item.kind == "expense"}),
+        }
+
+    def category_breakdown(self, transactions: Iterable[Transaction], month: str | None = None) -> dict[str, float]:
+        totals: dict[str, float] = defaultdict(float)
+        for item in transactions:
+            if item.kind == "expense" and (not month or item.date.strftime("%Y-%m") == month):
+                totals[item.category] += item.amount
+        return dict(sorted(((key, round(value, 2)) for key, value in totals.items()), key=lambda pair: pair[1], reverse=True))
+
+    def trends(self, transactions: Iterable[Transaction], month: str | None = None) -> dict[str, dict[str, float]]:
+        daily: dict[str, float] = defaultdict(float)
+        weekly: dict[str, float] = defaultdict(float)
+        monthly: dict[str, float] = defaultdict(float)
+        for item in transactions:
+            if item.kind != "expense" or (month and item.date.strftime("%Y-%m") != month):
+                continue
+            daily[item.date.isoformat()] += item.amount
+            monday = item.date - timedelta(days=item.date.weekday())
+            weekly[monday.isoformat()] += item.amount
+            monthly[item.date.strftime("%Y-%m")] += item.amount
+        return {
+            "daily": dict(sorted((key, round(value, 2)) for key, value in daily.items())),
+            "weekly": dict(sorted((key, round(value, 2)) for key, value in weekly.items())),
+            "monthly": dict(sorted((key, round(value, 2)) for key, value in monthly.items())),
+        }
+
+    def patterns(self, transactions: Iterable[Transaction], month: str | None = None) -> dict:
+        items = [item for item in transactions if item.kind == "expense" and (not month or item.date.strftime("%Y-%m") == month)]
+        if not items:
+            return {"late_night": {"count": 0, "amount": 0.0}, "weekend": {"count": 0, "amount": 0.0}, "payday_window": {"count": 0, "amount": 0.0}, "frequent_small": {"count": 0, "amount": 0.0}, "category_spikes": {}}
+        late = [x for x in items if x.hour >= 22 or x.hour < 6]
+        weekend = [x for x in items if x.date.weekday() >= 5]
+        income_dates = {x.date for x in transactions if x.kind == "income"}
+        payday = [x for x in items if any(0 <= (x.date - income_date).days <= 3 for income_date in income_dates)]
+        small = [x for x in items if x.amount <= 50]
+        category_amounts: dict[str, float] = defaultdict(float)
+        for x in items:
+            category_amounts[x.category] += x.amount
+        return {
+            "late_night": {"count": len(late), "amount": round(sum(x.amount for x in late), 2), "share": round(sum(x.amount for x in late) / sum(x.amount for x in items) * 100, 2)},
+            "weekend": {"count": len(weekend), "amount": round(sum(x.amount for x in weekend), 2), "share": round(sum(x.amount for x in weekend) / sum(x.amount for x in items) * 100, 2)},
+            "payday_window": {"count": len(payday), "amount": round(sum(x.amount for x in payday), 2), "share": round(sum(x.amount for x in payday) / sum(x.amount for x in items) * 100, 2)},
+            "frequent_small": {"count": len(small), "amount": round(sum(x.amount for x in small), 2), "average": round(mean(x.amount for x in small), 2) if small else 0.0},
+            "category_spikes": {key: round(value, 2) for key, value in sorted(category_amounts.items(), key=lambda pair: pair[1], reverse=True)},
+        }
+
+    def month_keys(self, transactions: Iterable[Transaction]) -> list[str]:
+        return sorted({item.date.strftime("%Y-%m") for item in transactions})
+
+    def monthly_category_totals(self, transactions: Iterable[Transaction]) -> dict[str, dict[str, float]]:
+        result: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
+        for item in transactions:
+            if item.kind == "expense":
+                result[item.date.strftime("%Y-%m")][item.category] += item.amount
+        return {month: {cat: round(value, 2) for cat, value in cats.items()} for month, cats in sorted(result.items())}
+
+    @staticmethod
+    def historical_average(values: list[float]) -> float:
+        return round(mean(values), 2) if values else 0.0
+
+    @staticmethod
+    def historical_median(values: list[float]) -> float:
+        return round(median(values), 2) if values else 0.0

+ 68 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/subscription_detector.py

@@ -0,0 +1,68 @@
+"""Recurring-charge and subscription detector.
+
+Periodicity alone is not enough: people can visit the same canteen, cinema or
+station on a similar day every month. A candidate therefore needs both a stable
+cross-month amount and semantic evidence that it is a membership/auto-renewal
+charge. The tool intentionally returns *suspected* subscriptions only; it never
+cancels or modifies a service.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from statistics import median
+from typing import Iterable
+
+from ..models import Transaction
+
+
+class SubscriptionDetectorTool:
+    """Detect likely recurring memberships without mistaking routine spending for one."""
+
+    _RECURRING_MARKERS = ("自动续费", "会员", "订阅", "月卡", "年卡", "续费", "连续扣费")
+
+    @classmethod
+    def _has_recurring_evidence(cls, items: list[Transaction]) -> bool:
+        """Return semantic evidence supplied by classification or transaction text."""
+        if any(item.category == "订阅" for item in items):
+            return True
+        return any(
+            marker in f"{item.merchant} {item.note}".lower()
+            for item in items
+            for marker in cls._RECURRING_MARKERS
+        )
+
+    def detect(self, transactions: Iterable[Transaction]) -> list[dict]:
+        groups: dict[str, list[Transaction]] = defaultdict(list)
+        for item in transactions:
+            if item.kind == "expense":
+                groups[item.merchant].append(item)
+
+        result: list[dict] = []
+        for merchant, items in groups.items():
+            # Rent and other housing costs are deliberately budgeted as fixed living
+            # expenses. Although they are periodic, presenting rent as a disposable
+            # "subscription" would be misleading and makes the Subscription Hunter
+            # less trustworthy.
+            if any(item.category == "住房" for item in items):
+                continue
+            months = sorted({item.date.strftime("%Y-%m") for item in items})
+            if len(months) < 2 or not self._has_recurring_evidence(items):
+                continue
+
+            amounts = [item.amount for item in items]
+            stable_amount = max(amounts) - min(amounts) <= max(10.0, median(amounts) * 0.15)
+            if stable_amount:
+                latest = items[-1]
+                result.append(
+                    {
+                        "merchant": merchant,
+                        "months": months,
+                        "occurrences": len(items),
+                        "typical_amount": round(median(amounts), 2),
+                        "category": latest.category,
+                        "low_value_flag": median(amounts) < 100,
+                        "message": "连续多月的会员或续费扣款,建议检查是否仍有使用价值",
+                    }
+                )
+        return sorted(result, key=lambda item: item["typical_amount"], reverse=True)

+ 53 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/src/tools/transaction_category.py

@@ -0,0 +1,53 @@
+"""Rule-first transaction categorization with durable merchant corrections."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Callable, Iterable
+
+from ..models import Transaction
+
+CATEGORIES = ("餐饮", "交通", "娱乐", "购物", "住房", "学习", "健身", "订阅", "医疗", "其他")
+
+
+@dataclass(slots=True)
+class CategoryResult:
+    category: str
+    confidence: float
+    source: str
+
+
+class TransactionCategoryTool:
+    rules: dict[str, tuple[str, ...]] = {
+        "住房": ("房租", "租金", "物业", "水电", "燃气", "电费", "宽带"),
+        "订阅": ("netflix", "spotify", "爱奇艺", "腾讯视频", "优酷", "b站大会员", "网易云音乐", "qq音乐", "喜马拉雅", "会员", "订阅", "icloud", "云盘"),
+        "餐饮": ("外卖", "美团", "饿了么", "餐厅", "饭店", "火锅", "烧烤", "奶茶", "咖啡", "星巴克", "便利店", "早餐", "午餐", "晚餐"),
+        "交通": ("地铁", "公交", "滴滴", "打车", "高铁", "火车", "加油", "停车", "共享单车", "单车"),
+        "娱乐": ("电影", "影院", "演唱会", "游戏", "steam", "剧本杀", "桌游", "livehouse", "音乐节", "门票"),
+        "购物": ("淘宝", "天猫", "京东", "拼多多", "商场", "优衣库", "服饰", "鞋", "数码", "商城", "超市", "礼物"),
+        "学习": ("课程", "书店", "图书", "学习", "考试", "培训", "语言", "知识", "论文"),
+        "健身": ("健身", "瑜伽", "游泳", "跑步", "keep", "运动", "球馆"),
+        "医疗": ("医院", "药房", "药店", "体检", "医疗"),
+    }
+
+    def classify(self, transaction: Transaction, memory_lookup: Callable[[str], str | None] | None = None) -> CategoryResult:
+        if transaction.kind == "income":
+            return CategoryResult("收入", 1.0, "income_rule")
+        if memory_lookup:
+            remembered = memory_lookup(transaction.merchant)
+            if remembered in CATEGORIES:
+                return CategoryResult(remembered, 1.0, "memory")
+        haystack = f"{transaction.merchant} {transaction.note}".lower()
+        for category, keywords in self.rules.items():
+            if any(keyword.lower() in haystack for keyword in keywords):
+                return CategoryResult(category, 0.92, "keyword_rule")
+        return CategoryResult("其他", 0.25, "fallback")
+
+    def apply(self, transactions: Iterable[Transaction], memory_lookup: Callable[[str], str | None] | None = None) -> list[Transaction]:
+        result: list[Transaction] = []
+        for transaction in transactions:
+            classified = self.classify(transaction, memory_lookup)
+            transaction.category = classified.category
+            transaction.category_confidence = classified.confidence
+            result.append(transaction)
+        return result

+ 0 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/__init__.py


+ 88 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/fakes.py

@@ -0,0 +1,88 @@
+"""LLM test double: production has no offline route; tests avoid network calls."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from typing import Iterable
+
+
+@dataclass
+class _Status:
+    reason: str = "test Hello-Agents runtime"
+    enabled: bool = True
+
+
+class FakeRuntime:
+    """Contract-level stand-in for HelloAgentsRuntime used only by unit tests."""
+
+    def __init__(self) -> None:
+        self.status = _Status()
+        self.registered: tuple[str, ...] = ()
+
+    def register_tool_functions(self, functions) -> None:
+        self.registered = tuple(functions)
+
+    def status_dict(self) -> dict:
+        return {
+            "available": True,
+            "enabled": True,
+            "reason": self.status.reason,
+            "registry_name": "Fake ToolRegistry",
+            "paradigms": ["ReActAgent", "PlanSolveAgent", "ReflectionAgent"],
+            "registered_tools": list(self.registered),
+        }
+
+    def classify_uncertain(self, merchant: str, note: str, allowed_categories: list[str]) -> str:
+        return "其他" if "其他" in allowed_categories else allowed_categories[0]
+
+    @staticmethod
+    def _quest_json(prompt: str) -> str:
+        """Mirror only the strict JSON protocol, not production Quest logic."""
+        marker = "SIGNAL_CATALOG_JSON:\n"
+        catalog: list[dict] = []
+        if marker in prompt:
+            raw = prompt.split(marker, 1)[1]
+            decoder = json.JSONDecoder()
+            try:
+                catalog, _ = decoder.raw_decode(raw.lstrip())
+            except json.JSONDecodeError:
+                catalog = []
+        names = {
+            "late_night": ("夜航冷静结界", "给深夜冲动留一段缓冲,不必用意志硬扛。", "先辨认触发场景,再为自己准备替代选项。"),
+            "frequent_small": ("零钱能量巡逻", "把细碎消费当作线索,找回自己选择的节奏。", "出门前先想好今天最想守住的体验。"),
+            "flexible_budget": ("弹性钱包护盾", "体验额度依然保留,只是让目标也拥有位置。", "付款前停一停,确认这次消费是否真的值得。"),
+            "subscriptions": ("订阅遗迹寻宝", "把持续扣费翻出来,留下真正陪伴你的服务。", "从最近使用感受开始,逐项做一个保留决定。"),
+            "weekend": ("周末钱包护盾", "周末可以尽兴,也可以留下一点可控的边界。", "安排活动前先选定最想投入的一件事。"),
+            "payday": ("发薪冷静回合", "到账后的兴奋值得被看见,也值得多一点缓冲。", "先把想买的东西记下,稍后再决定是否结算。"),
+            "learning_followthrough": ("学习战利品回访", "让学习消费继续产生陪伴感,而不只是一次付款。", "选一个最容易开始的学习入口,写下下次打开它的时机。"),
+            "goal_transfer": ("目标补给路线", "让本月结余有一个温柔去处,持续靠近你的愿望。", "先确认最想推进的目标,再写下这次结余的安排。"),
+            "balance": ("镜像决策日志", "消费没有标准答案,记录会帮你看见自己的偏好。", "挑一笔最近消费,写下它带来的真实感受。"),
+        }
+        quests = []
+        for signal in catalog:
+            signal_id = signal.get("signal_id")
+            if signal_id in names:
+                title, narrative, action_hint = names[signal_id]
+                quests.append({"signal_id": signal_id, "title": title, "narrative": narrative, "action_hint": action_hint})
+        return json.dumps({"quests": quests[:5]}, ensure_ascii=False)
+
+    def explain(self, prompt: str, mode: str = "simple", evidence: Iterable[str] = (), memory: Iterable[str] = ()) -> str:
+        if "SIGNAL_CATALOG_JSON:" in prompt or "Quest JSON" in prompt:
+            return self._quest_json(prompt)
+        if mode == "reflection":
+            return "已根据验证的账单、预算和目标证据完成本轮反思;下一步选择一个低摩擦任务即可。"
+        if mode == "plan":
+            return "先阅读已验证的消费证据,再挑选一个能在本周完成的小行动。"
+        return "这是一段基于已验证消费证据生成的 AI 引导文案。"
+
+    def generate_quest_candidates(self, prompt: str) -> str:
+        return self._quest_json(prompt)
+
+    def generate_markdown(self, report_payload: str) -> str:
+        return "# MoneyMirrorAgent 月度报告\n\n这是由测试 LLM 生成的 Markdown 报告。\n"
+
+    def stream_user_guidance(self, question: str, report_payload: str, history):
+        yield "🪞 先从一个小行动开始:"
+        yield "本周记录一次触发消费的场景,然后告诉我你的发现。"

+ 53 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_conversation_agent.py

@@ -0,0 +1,53 @@
+"""Tests for the compact, data-grounded LLM conversation payload."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.agents.conversation_agent import ConversationAgent
+from src.agents.coordinator import MoneyMirrorCoordinator
+
+from .fakes import FakeRuntime
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_conversation_payload_keeps_verified_facts_without_raw_audit_data() -> None:
+    coordinator = MoneyMirrorCoordinator(":memory:", runtime=FakeRuntime())
+    try:
+        report = coordinator.analyze_csv(ROOT / "data" / "sample_01.csv")
+        payload = ConversationAgent.payload(
+            report,
+            [
+                {"role": "assistant", "content": "开场"},
+                {"role": "user", "content": "我想控制深夜外卖"},
+                {"role": "assistant", "content": "请从一个小任务开始"},
+            ],
+        )
+        parsed = json.loads(payload)
+        facts = parsed["[Verified tool output]"]
+        assert facts["summary"]["expense"] == 6574
+        assert facts["patterns"]["late_night"]["count"] >= 3
+        assert facts["persona"]["primary"]
+        assert facts["quests"]
+        assert facts["guided_conversation"][-1]["content"] == "请从一个小任务开始"
+        # Raw accounting/audit detail remains in the persisted JSON only, not
+        # in every LLM turn where it can crowd out the facts above.
+        assert "transactions" not in facts
+        assert "agent_trace" not in facts
+    finally:
+        coordinator.close()
+
+
+def test_compact_conversation_bounds_history_and_message_length() -> None:
+    history = [
+        {"role": "user", "content": f"turn-{index}"}
+        for index in range(8)
+    ]
+    history[-1]["content"] = "x" * 700
+    compact = ConversationAgent.compact_conversation(history)
+    assert len(compact) == ConversationAgent.MAX_HISTORY_ITEMS
+    assert compact[0]["content"] == "turn-2"
+    assert compact[-1]["content"].endswith("…")
+    assert len(compact[-1]["content"]) == ConversationAgent.MAX_MESSAGE_CHARS + 1

+ 45 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_coordinator.py

@@ -0,0 +1,45 @@
+from pathlib import Path
+
+from src.agents.coordinator import MoneyMirrorCoordinator
+from src.models import Goal
+
+from .fakes import FakeRuntime
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_complete_demo_pipeline_persists_memory_and_reflects(tmp_path: Path) -> None:
+    coordinator = MoneyMirrorCoordinator(tmp_path / "memory.db", "test_user", runtime=FakeRuntime())
+    try:
+        coordinator.add_goal(Goal("travel_fund_2026", "三个月旅行基金", "travel", 10000, 2800, "2026-10-31"))
+        report = coordinator.analyze_csv(ROOT / "data" / "sample_01.csv")
+        assert report.month == "2026-07"
+        assert report.summary["balance"] == 1026
+        assert report.persona["primary"]
+        assert report.quests
+        assert report.gamification["level"] >= 1
+        assert "longest_streak_days" in report.gamification
+        assert report.reflection["has_previous_snapshot"] is True
+        assert report.reflection["next_cycle_month"] == "2026-08"
+        assert report.reflection["next_cycle_budget"]["categories"]
+        assert report.reflection["next_cycle_quests"]
+        assert coordinator.memory.get_budget("2026-08") is not None
+        assert report.goals[0]["required_monthly_amount"] > 0
+        assert coordinator.memory.get_snapshot("2026-07") is not None
+        coordinator.correct_merchant_category("测试奶茶店", "餐饮")
+        assert coordinator.memory.get_merchant_category("测试奶茶店") == "餐饮"
+        json_path, markdown_path = coordinator.write_outputs(
+            report,
+            tmp_path / "outputs",
+            source_csv=ROOT / "data" / "sample_01.csv",
+        )
+        assert json_path.name == "sample_01_money_mirror_report.json"
+        assert markdown_path.name == "sample_01_money_mirror_report.md"
+        assert json_path.exists() and markdown_path.exists()
+    finally:
+        coordinator.close()
+
+
+def test_output_stem_uses_input_csv_basename() -> None:
+    assert MoneyMirrorCoordinator._output_stem("data/sample_01.csv") == "sample_01_money_mirror_report"
+    assert MoneyMirrorCoordinator._output_stem("/tmp/八月账单.csv") == "八月账单_money_mirror_report"

+ 32 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_csv_import.py

@@ -0,0 +1,32 @@
+from io import StringIO
+
+from src.tools.csv_import import CSVImportTool
+
+
+def test_normalizes_chinese_columns_and_signed_amounts() -> None:
+    source = StringIO("日期,商户,金额,备注\n2026/07/01 22:30,测试外卖,-35,夜宵\n2026/07/05,工资,7000,工资到账\n")
+    transactions = CSVImportTool().load(source)
+    assert len(transactions) == 2
+    assert transactions[0].kind == "expense"
+    assert transactions[0].amount == 35
+    assert transactions[0].occurred_at == "2026-07-01T22:30"
+    assert transactions[1].kind == "income"
+
+
+def test_rejects_missing_required_columns() -> None:
+    source = StringIO("金额,备注\n20,hello\n")
+    try:
+        CSVImportTool().load(source)
+    except ValueError as exc:
+        assert "日期和商户" in str(exc)
+    else:
+        raise AssertionError("expected CSV validation error")
+
+
+def test_accepts_binary_gb18030_and_exposes_partial_row_warnings() -> None:
+    raw = "日期,商户,金额,收支\n2026-07-01,工资,7000,收入\n坏日期,外卖,35,支出\n".encode("gb18030")
+    tool = CSVImportTool()
+    transactions = tool.load(__import__("io").BytesIO(raw))
+    assert len(transactions) == 1
+    assert transactions[0].kind == "income"
+    assert tool.last_errors and "第 3 行" in tool.last_errors[0]

+ 74 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_dynamic_quest_orchestration.py

@@ -0,0 +1,74 @@
+"""Tests for signal → LLM JSON → Python-validated Quest orchestration."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from src.agents.quest_agent import QuestAgent
+from src.memory import SQLiteMemory
+from src.models import Transaction
+
+from .fakes import FakeRuntime
+
+
+def test_weekend_signal_can_produce_a_different_llm_orchestrated_quest(tmp_path: Path) -> None:
+    memory = SQLiteMemory(tmp_path / "memory.db")
+    agent = QuestAgent(memory, FakeRuntime())
+    transactions = [
+        Transaction("a", "2026-07-04T12:00", "周末餐馆", 100, "expense", "餐饮"),
+        Transaction("b", "2026-07-05T15:00", "周末展览", 100, "expense", "餐饮"),
+        Transaction("c", "2026-07-11T19:00", "周末聚餐", 100, "expense", "餐饮"),
+    ]
+    try:
+        quests, _, _, trace = agent.run(
+            transactions,
+            "2026-07",
+            {"expense": 300, "balance": 0, "savings_rate": 0},
+            {"餐饮": 300},
+            {
+                "late_night": {"count": 0, "amount": 0, "share": 0},
+                "frequent_small": {"count": 0, "amount": 0},
+                "weekend": {"count": 3, "amount": 300, "share": 100},
+                "payday_window": {"count": 0, "amount": 0, "share": 0},
+            },
+            [],
+            {"categories": {"餐饮": {"recommended": 290}}},
+            [],
+        )
+        assert [quest.quest_id for quest in quests] == ["weekend_wallet_shield"]
+        quest = quests[0]
+        # The LLM supplied the non-numeric title/copy, while Python supplied
+        # the target (300 * 0.85) and current progress evidence.
+        assert quest.title == "周末钱包护盾"
+        assert quest.target == 255
+        assert quest.quest_type == "weekend_spend_limit"
+        assert "已核验目标" in quest.description
+        assert trace["llm_orchestration"]["accepted_signal_ids"] == ["weekend"]
+        assert trace["llm_orchestration"]["numeric_authority"].startswith("Python only")
+    finally:
+        memory.close()
+
+
+def test_quest_candidate_with_model_invented_number_is_rejected(tmp_path: Path) -> None:
+    memory = SQLiteMemory(tmp_path / "memory.db")
+    agent = QuestAgent(memory, FakeRuntime())
+    try:
+        blueprint = agent._discover_signals(
+            {"expense": 100, "balance": 0},
+            {},
+            {
+                "late_night": {"count": 2, "amount": 80, "share": 80},
+                "frequent_small": {"count": 0, "amount": 0},
+                "weekend": {"count": 0, "amount": 0, "share": 0},
+                "payday_window": {"count": 0, "amount": 0, "share": 0},
+            },
+            [],
+            {"categories": {}},
+            [],
+        )
+        raw = '{"quests":[{"signal_id":"late_night","title":"夜航2号结界","narrative":"连续2天不点外卖,冲刺奖励。","action_hint":"先忍十分钟再决定是否下单。"}]}'
+        candidates, problems = agent._parse_and_validate_candidates(raw, blueprint)
+        assert candidates == []
+        assert any("金额、百分比或阿拉伯数字" in item for item in problems)
+    finally:
+        memory.close()

+ 15 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_memory_and_category.py

@@ -0,0 +1,15 @@
+from src.memory import SQLiteMemory
+from src.models import Transaction
+from src.tools.transaction_category import TransactionCategoryTool
+
+
+def test_manual_category_correction_overrides_rule() -> None:
+    memory = SQLiteMemory(":memory:")
+    try:
+        memory.set_merchant_category("星巴克", "学习")
+        transaction = Transaction("id", "2026-07-01T09:00", "星巴克", 35, "expense")
+        result = TransactionCategoryTool().classify(transaction, memory.get_merchant_category)
+        assert result.category == "学习"
+        assert result.source == "memory"
+    finally:
+        memory.close()

+ 156 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_persona_agent.py

@@ -0,0 +1,156 @@
+"""Persona scoring must remain evidence-based and configurable."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from src.agents.persona_agent import PersonaAgent
+
+from .fakes import FakeRuntime
+
+
+def _patterns(**overrides) -> dict:
+    base = {
+        "late_night": {"share": 0.0, "count": 0, "amount": 0.0},
+        "weekend": {"share": 0.0, "count": 0, "amount": 0.0},
+        "payday_window": {"share": 0.0, "count": 0, "amount": 0.0},
+        "frequent_small": {"count": 0, "amount": 0.0, "average": 0.0},
+    }
+    base.update(overrides)
+    return base
+
+
+def test_persona_uses_scoring_and_evidence_validation() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    persona, trace = agent.run(
+        {"expense": 1000.0, "savings_rate": 8.0},
+        {"餐饮": 350.0, "娱乐": 180.0, "购物": 120.0, "订阅": 0.0, "学习": 0.0},
+        _patterns(
+            late_night={"share": 28.0, "count": 5, "amount": 280.0},
+            frequent_small={"count": 7, "amount": 210.0, "average": 30.0},
+        ),
+        [],
+    )
+
+    assert persona["archetype"] == "late_night_focus"
+    assert persona["primary"] == "夜行消费探索者"
+    assert persona["score"] >= 52
+    assert persona["confidence"] == round(persona["score"] / 100, 2)
+    assert any("深夜消费占比" in item for item in persona["evidence"])
+    assert trace["llm_role"].startswith("仅生成")
+    assert trace["candidates"][0]["archetype"] == "late_night_focus"
+
+
+def test_generic_food_and_small_spending_never_claims_coffee_persona() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    persona, _ = agent.run(
+        {"expense": 1000.0, "savings_rate": 5.0},
+        {"餐饮": 400.0, "娱乐": 0.0, "购物": 0.0},
+        _patterns(frequent_small={"count": 9, "amount": 280.0, "average": 31.0}),
+        [],
+    )
+
+    assert persona["archetype"] == "frequent_small_spend"
+    assert persona["primary"] == "高频小额行动派"
+    assert all("咖啡" not in label for label in persona["labels"])
+
+
+def test_learning_persona_requires_history_not_a_single_large_month() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    persona, trace = agent.run(
+        {"expense": 1000.0, "savings_rate": 5.0},
+        {"学习": 300.0},
+        _patterns(),
+        [],
+    )
+
+    learning = next(item for item in trace["candidates"] if item["archetype"] == "learning_investor")
+    assert learning["evidence_valid"] is False
+    assert persona["archetype"] != "learning_investor"
+
+def test_persona_config_rejects_unknown_feature_reference(tmp_path) -> None:
+    config_path = tmp_path / "personas.json"
+    config_path.write_text(
+        json.dumps(
+            {
+                "archetypes": [
+                    {
+                        "id": "typo_guard",
+                        "name": "配置校验测试",
+                        "minimum_score": 50,
+                        "required_features": {"nightt": 40},
+                        "weights": {"nightt": 1.0},
+                        "evidence_metrics": ["late_night_share"],
+                    }
+                ],
+                "fallback": {"id": "balanced", "name": "均衡", "evidence_metrics": []},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    with pytest.raises(ValueError, match="未知特征: nightt"):
+        PersonaAgent(FakeRuntime(), config_path=config_path)
+
+
+
+def test_richer_persona_catalog_exposes_distinct_data_driven_archetypes() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    configured_ids = {item.archetype_id for item in agent.archetypes}
+
+    assert len(configured_ids) >= 12
+    assert {
+        "payday_rhythm",
+        "weekend_social",
+        "food_routine",
+        "savings_sprinter",
+        "digital_lifestyle",
+        "learning_consistent",
+        "flexible_adventurer",
+        "mindful_minimalist",
+    } <= configured_ids
+
+
+def test_payday_persona_is_selected_from_payday_evidence() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    persona, trace = agent.run(
+        {"expense": 1000.0, "savings_rate": 18.0},
+        {"餐饮": 200.0, "娱乐": 80.0},
+        _patterns(
+            payday_window={"share": 68.0, "count": 6, "amount": 680.0},
+            frequent_small={"count": 7, "amount": 210.0, "average": 30.0},
+        ),
+        [],
+    )
+
+    assert persona["archetype"] == "payday_rhythm"
+    assert any(item["archetype"] == "payday_rhythm" and item["evidence_valid"] for item in trace["candidates"])
+    assert "工资到账后消费占比" in ";".join(persona["evidence"])
+
+
+def test_food_routine_persona_requires_repeated_small_food_behavior() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    persona, _ = agent.run(
+        {"expense": 1000.0, "savings_rate": 5.0},
+        {"餐饮": 700.0, "娱乐": 0.0, "购物": 0.0},
+        _patterns(frequent_small={"count": 6, "amount": 50.0, "average": 8.33}),
+        [],
+    )
+
+    assert persona["archetype"] == "food_routine"
+    assert persona["primary"] == "日常餐饮探索家"
+
+
+def test_savings_feature_keeps_the_verified_savings_rate_shape() -> None:
+    agent = PersonaAgent(FakeRuntime())
+    persona, _ = agent.run(
+        {"expense": 1000.0, "savings_rate": 47.5},
+        {"餐饮": 300.0},
+        _patterns(),
+        [],
+    )
+
+    assert persona["feature_vector"]["savings"] == 47.5

+ 10 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_quest_progress.py

@@ -0,0 +1,10 @@
+from src.models import Quest, Transaction
+from src.tools.quest_progress import QuestProgressTool
+
+
+def test_category_budget_quest_uses_real_spending() -> None:
+    transaction = Transaction("1", "2026-07-01T12:00", "电影院", 80, "expense", "娱乐")
+    quest = Quest("q", "娱乐预算", "", "category_limit", 100, 0, "娱乐", 10)
+    updated = QuestProgressTool().update(quest, [transaction], "2026-07")
+    assert updated.progress == 20
+    assert updated.status == "active"

+ 82 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_sample_files.py

@@ -0,0 +1,82 @@
+"""Coverage for bundled fictional bill files and the explicit CSV CLI."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from main import _parse_goal, parser
+from src.agents.coordinator import MoneyMirrorCoordinator
+from src.tools import CSVImportTool
+
+from .fakes import FakeRuntime
+
+ROOT = Path(__file__).resolve().parents[1]
+SAMPLE_FILES = tuple(ROOT / "data" / f"sample_{index:02d}.csv" for index in range(1, 6))
+
+
+def test_all_bundled_sample_files_exist_and_import() -> None:
+    importer = CSVImportTool()
+    assert len(SAMPLE_FILES) == 5
+    for index, path in enumerate(SAMPLE_FILES, start=1):
+        assert path.is_file(), f"sample-{index:02d} is missing its CSV: {path}"
+        transactions = importer.load(path)
+        assert transactions, f"sample-{index:02d} should contain transactions"
+        assert not importer.last_errors, f"sample-{index:02d} has invalid rows: {importer.last_errors}"
+
+
+def test_samples_produce_distinct_data_grounded_quest_signals(tmp_path) -> None:
+    quest_ids: dict[str, set[str]] = {}
+    subscription_merchants: dict[str, set[str]] = {}
+    for index, path in enumerate(SAMPLE_FILES, start=1):
+        sample_id = f"sample-{index:02d}"
+        coordinator = MoneyMirrorCoordinator(tmp_path / f"{sample_id}.db", runtime=FakeRuntime())
+        try:
+            report = coordinator.analyze_csv(path)
+            assert report.summary["income"] > 0
+            assert report.summary["expense"] > 0
+            assert report.quests
+            quest_ids[sample_id] = {quest.quest_id for quest in report.quests}
+            subscription_merchants[sample_id] = {item["merchant"] for item in report.subscriptions}
+        finally:
+            coordinator.close()
+
+    assert "weekend_wallet_shield" in quest_ids["sample-03"]
+    assert "payday_cooldown" in quest_ids["sample-04"]
+    assert "learning_loot_log" in quest_ids["sample-02"]
+    assert "subscription_hunter" in quest_ids["sample-05"]
+    assert "房东-六月房租" not in subscription_merchants["sample-04"]
+    assert "腾讯视频会员" in subscription_merchants["sample-05"]
+    assert len({tuple(sorted(ids)) for ids in quest_ids.values()}) >= 4
+
+
+def test_csv_path_is_required_and_demo_flag_is_removed() -> None:
+    command = parser()
+    args = command.parse_args(["--csv", "bill.csv"])
+    assert args.csv == Path("bill.csv")
+    assert args.interactive is False
+    assert not hasattr(args, "demo")
+
+    interactive_args = command.parse_args(["--interactive", "--csv", "bill.csv"])
+    assert interactive_args.interactive is True
+    with pytest.raises(SystemExit):
+        command.parse_args([])
+    with pytest.raises(SystemExit):
+        command.parse_args(["--demo", "--csv", "bill.csv"])
+
+
+def test_cli_goal_parsing_is_explicit_and_validated() -> None:
+    travel = _parse_goal("三个月旅行基金|travel|10000|2800|2026-10-31")
+    assert travel.goal_type == "travel"
+    assert travel.target_amount == 10000
+    assert travel.current_amount == 2800
+
+    category = _parse_goal("本月娱乐限额|category_limit|800|0|2026-08-31|娱乐|800")
+    assert category.category == "娱乐"
+    assert category.monthly_limit == 800
+
+    with pytest.raises(ValueError, match="格式"):
+        _parse_goal("格式不完整|travel")
+    with pytest.raises(ValueError, match="category_limit"):
+        _parse_goal("娱乐限额|category_limit|800|0|2026-08-31")

+ 54 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_terminal_quests.py

@@ -0,0 +1,54 @@
+"""Terminal-only Quest completion behavior."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from src.agents.coordinator import MoneyMirrorCoordinator
+
+from .fakes import FakeRuntime
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_cli_can_confirm_subscription_quest_and_memory_preserves_it(tmp_path: Path) -> None:
+    coordinator = MoneyMirrorCoordinator(tmp_path / "memory.db", runtime=FakeRuntime())
+    try:
+        report = coordinator.analyze_csv(ROOT / "data" / "sample_01.csv")
+        result = coordinator.complete_quest(report, "subscription_hunter", "已检查三个订阅")
+        quest = next(item for item in report.quests if item.quest_id == "subscription_hunter")
+        assert quest.status == "completed"
+        assert quest.progress == quest.target
+        assert result["gained_exp"] == quest.exp_reward
+
+        # Re-analyzing the same monthly bill must retain the user-confirmed
+        # review rather than letting deterministic transaction parsing erase it.
+        refreshed = coordinator.analyze_csv(ROOT / "data" / "sample_01.csv")
+        refreshed_quest = next(item for item in refreshed.quests if item.quest_id == "subscription_hunter")
+        assert refreshed_quest.status == "completed"
+        assert "CLI" in refreshed_quest.evidence
+    finally:
+        coordinator.close()
+
+
+def test_cli_cannot_override_spending_derived_quest(tmp_path: Path) -> None:
+    coordinator = MoneyMirrorCoordinator(tmp_path / "memory.db", runtime=FakeRuntime())
+    try:
+        report = coordinator.analyze_csv(ROOT / "data" / "sample_01.csv")
+        with pytest.raises(ValueError, match="账单自动计算"):
+            coordinator.complete_quest(report, "late_night_guard")
+    finally:
+        coordinator.close()
+
+
+def test_main_requires_an_explicit_csv_path() -> None:
+    """A bill path is required; no hidden default bill is selected."""
+    import main
+
+    command = main.parser()
+    args = command.parse_args(["--csv", "bill.csv"])
+    assert args.csv.name == "bill.csv"
+    assert not hasattr(args, "demo")
+

+ 64 - 0
Co-creation-projects/CoralGarden52-MoneyMirrorAgent/tests/test_tools.py

@@ -0,0 +1,64 @@
+from pathlib import Path
+
+from src.tools import AnomalyDetectionTool, BudgetCalculatorTool, CSVImportTool, StatisticsTool, SubscriptionDetectorTool
+from src.agents.coordinator import MoneyMirrorCoordinator
+from src.agents.transaction_agent import TransactionAgent
+from src.memory import SQLiteMemory
+
+from .fakes import FakeRuntime
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def classified_transactions():
+    memory = SQLiteMemory(":memory:")
+    imported = CSVImportTool().load(ROOT / "data" / "sample_01.csv")
+    transactions, _ = TransactionAgent(memory, FakeRuntime()).run(imported)
+    return memory, transactions
+
+
+def test_statistics_anomaly_budget_and_subscription_are_data_driven() -> None:
+    memory, transactions = classified_transactions()
+    try:
+        stats = StatisticsTool()
+        summary = stats.summarize(transactions, "2026-07")
+        assert summary["income"] == 7600
+        assert summary["expense"] > 6000
+        assert stats.patterns(transactions, "2026-07")["late_night"]["count"] >= 3
+        anomalies = AnomalyDetectionTool().detect(transactions, "2026-07")
+        assert any(item.merchant == "京东-电脑配件商城" for item in anomalies)
+        budget = BudgetCalculatorTool().calculate(transactions, "2026-07")
+        assert budget["categories"]["住房"]["bucket"] == "fixed"
+        assert budget["categories"]["娱乐"]["bucket"] == "optional"
+        assert budget["categories"]["餐饮"]["bucket"] == "necessary"
+        subscriptions = SubscriptionDetectorTool().detect(transactions)
+        names = {item["merchant"] for item in subscriptions}
+        assert "腾讯视频会员" in names
+        assert "房东-六月房租" not in names
+        assert "星巴克" not in names
+        assert "万达影院" not in names
+        assert "滴滴出行" not in names
+    finally:
+        memory.close()
+
+
+def test_hello_agents_registry_exposes_all_deterministic_tools() -> None:
+    memory, transactions = classified_transactions()
+    try:
+        coordinator = MoneyMirrorCoordinator(":memory:", runtime=FakeRuntime())
+        try:
+            names = set(coordinator.runtime.status_dict()["registered_tools"])
+            assert names == {
+                "CSVImportTool",
+                "TransactionCategoryTool",
+                "StatisticsTool",
+                "AnomalyDetectionTool",
+                "BudgetCalculatorTool",
+                "GoalProjectionTool",
+                "SubscriptionDetectorTool",
+                "QuestProgressTool",
+            }
+        finally:
+            coordinator.close()
+    finally:
+        memory.close()