main.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """CLI entry point for MoneyMirrorAgent."""
  2. from __future__ import annotations
  3. import argparse
  4. import hashlib
  5. import logging
  6. import os
  7. import sys
  8. from datetime import date
  9. from pathlib import Path
  10. from dotenv import load_dotenv
  11. from src.agents.coordinator import MoneyMirrorCoordinator
  12. from src.agents.runtime import LLMCallError, RuntimeConfigurationError
  13. from src.models import Goal
  14. ROOT = Path(__file__).resolve().parent
  15. def _parse_goal(specification: str) -> Goal:
  16. """Parse a repeatable CLI financial goal without inventing user data.
  17. Format: title|type|target_amount|current_amount|deadline
  18. For category_limit, append: |category|monthly_limit
  19. """
  20. fields = [field.strip() for field in specification.split("|")]
  21. if len(fields) not in {5, 7}:
  22. raise ValueError(
  23. "格式为 标题|类型|目标金额|当前金额|截止日期;类别限额目标追加 |类别|月限额"
  24. )
  25. title, goal_type, target_raw, current_raw, deadline = fields[:5]
  26. if not title:
  27. raise ValueError("目标标题不能为空")
  28. if goal_type not in {"savings", "travel", "category_limit"}:
  29. raise ValueError("目标类型必须是 savings、travel 或 category_limit")
  30. try:
  31. target_amount = float(target_raw)
  32. current_amount = float(current_raw)
  33. date.fromisoformat(deadline)
  34. except ValueError as exc:
  35. raise ValueError("目标金额必须是数字,截止日期必须为 YYYY-MM-DD") from exc
  36. if target_amount <= 0 or current_amount < 0:
  37. raise ValueError("目标金额必须大于 0,当前金额不能小于 0")
  38. category: str | None = None
  39. monthly_limit: float | None = None
  40. if goal_type == "category_limit":
  41. if len(fields) != 7 or not fields[5]:
  42. raise ValueError("category_limit 需要追加类别和月限额")
  43. category = fields[5]
  44. try:
  45. monthly_limit = float(fields[6])
  46. except ValueError as exc:
  47. raise ValueError("类别月限额必须是数字") from exc
  48. if monthly_limit <= 0:
  49. raise ValueError("类别月限额必须大于 0")
  50. elif len(fields) != 5:
  51. raise ValueError("只有 category_limit 可以追加类别和月限额")
  52. digest = hashlib.sha256(specification.encode("utf-8")).hexdigest()[:12]
  53. return Goal(
  54. goal_id=f"cli_{digest}",
  55. title=title,
  56. goal_type=goal_type,
  57. target_amount=target_amount,
  58. current_amount=current_amount,
  59. deadline=deadline,
  60. category=category,
  61. monthly_limit=monthly_limit,
  62. )
  63. def parser() -> argparse.ArgumentParser:
  64. command = argparse.ArgumentParser(description="MoneyMirrorAgent: 智能理财助手")
  65. command.add_argument("--interactive", action="store_true", help="分析账单后进入 LLM 引导对话,输入 /done 后生成最终 Markdown")
  66. command.add_argument("--csv", type=Path, required=True, metavar="账单CSV", help="要分析的 CSV 账单路径(必填)")
  67. command.add_argument("--month", help="指定分析月份,例如 2026-07")
  68. command.add_argument("--db", type=Path, default=ROOT / "outputs" / "moneymirror.db", help="SQLite Memory 路径")
  69. command.add_argument("--output-dir", type=Path, default=ROOT / "outputs", help="报告输出目录")
  70. command.add_argument("--reset", action="store_true", help="删除现有 SQLite Memory 后运行")
  71. command.add_argument("--correct", metavar="商户:类别", action="append", default=[], help="写入商户分类纠正,可重复指定")
  72. command.add_argument(
  73. "--goal",
  74. action="append",
  75. default=[],
  76. metavar="标题|类型|目标金额|当前金额|截止日期[|类别|月限额]",
  77. help="添加财务目标;可重复。类型为 savings、travel 或 category_limit",
  78. )
  79. return command
  80. def _print_quest_board(report) -> None:
  81. """Show the RPG task board in terminals without a graphical UI."""
  82. print("\n🎮 当前 Money Quest:")
  83. for quest in report.quests:
  84. progress = f"{quest.progress:g}/{quest.target:g}{quest.unit}"
  85. status = "✅ 已完成" if quest.status == "completed" else "🔄 进行中"
  86. print(f"- [{quest.quest_id}] {quest.title} · {progress} · {status} · +{quest.exp_reward} EXP")
  87. print(f" {quest.description}")
  88. print(f" 证据:{quest.evidence}")
  89. def _interactive(coordinator: MoneyMirrorCoordinator, report, output_dir: Path, csv_path: Path) -> None:
  90. history: list[dict[str, str]] = []
  91. _print_quest_board(report)
  92. opening = coordinator.conversation_agent.opening(report)
  93. history.append({"role": "assistant", "content": opening})
  94. print("\n🧭 MoneyMirrorAgent 引导:")
  95. print(opening)
  96. print(
  97. "\n直接输入你的回答即可;/quests 查看任务;"
  98. "/complete <quest_id> [备注] 确认订阅检查等人工任务;"
  99. "/done、/quit 或 退出 结束对话并生成 Markdown 月报。"
  100. )
  101. while True:
  102. question = input("\n你> ").strip()
  103. lowered = question.lower()
  104. if lowered in {"/done", "/quit", "退出"}:
  105. break
  106. if not question:
  107. continue
  108. if lowered == "/quests":
  109. _print_quest_board(report)
  110. continue
  111. if lowered.startswith("/complete "):
  112. parts = question.split(maxsplit=2)
  113. if len(parts) < 2:
  114. print("用法:/complete <quest_id> [备注]")
  115. continue
  116. try:
  117. result = coordinator.complete_quest(report, parts[1], parts[2] if len(parts) > 2 else "")
  118. print(f"✅ 已确认 Quest,获得 {result['gained_exp']} EXP;当前 Lv.{result['level']} / EXP {result['total_exp']}。")
  119. except ValueError as exc:
  120. print(f"⚠️ {exc}")
  121. continue
  122. history.append({"role": "user", "content": question})
  123. print("\nMoneyMirrorAgent> ", end="", flush=True)
  124. chunks = coordinator.runtime.stream_user_guidance(
  125. question,
  126. coordinator.conversation_agent.payload(report),
  127. history,
  128. )
  129. answer_parts: list[str] = []
  130. for chunk in chunks:
  131. print(chunk, end="", flush=True)
  132. answer_parts.append(chunk)
  133. print()
  134. history.append({"role": "assistant", "content": "".join(answer_parts).strip()})
  135. json_path, markdown_path = coordinator.write_outputs(report, output_dir, history, source_csv=csv_path)
  136. print(f"\n✅ 对话结束,LLM Markdown 报告:{markdown_path}")
  137. print(f"📦 JSON 事实快照:{json_path}")
  138. def main() -> int:
  139. load_dotenv(ROOT / ".env")
  140. logging.basicConfig(level=getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO), format="%(asctime)s %(levelname)s %(name)s: %(message)s")
  141. argument_parser = parser()
  142. args = argument_parser.parse_args()
  143. try:
  144. goals = [_parse_goal(specification) for specification in args.goal]
  145. except ValueError as exc:
  146. argument_parser.error(f"--goal {exc}")
  147. csv_path = args.csv
  148. print(f"账单文件: {csv_path}")
  149. if args.reset and args.db.exists():
  150. args.db.unlink()
  151. coordinator = None
  152. try:
  153. coordinator = MoneyMirrorCoordinator(args.db, os.getenv("MONEYMIRROR_USER_ID", "local_user"))
  154. for correction in args.correct:
  155. if ":" not in correction:
  156. argument_parser.error("--correct 格式必须为 商户:类别,例如 星巴克:餐饮")
  157. merchant, category = correction.split(":", 1)
  158. coordinator.correct_merchant_category(merchant, category)
  159. for goal in goals:
  160. coordinator.add_goal(goal)
  161. report = coordinator.analyze_csv(csv_path, args.month)
  162. if args.interactive:
  163. _interactive(coordinator, report, args.output_dir, csv_path)
  164. else:
  165. json_path, markdown_path = coordinator.write_outputs(report, args.output_dir, source_csv=csv_path)
  166. print("\nMoneyMirrorAgent 完整分析已完成")
  167. print(f"分析月份: {report.month}")
  168. print(f"收入 ¥{report.summary['income']:.2f} | 支出 ¥{report.summary['expense']:.2f} | 结余 ¥{report.summary['balance']:.2f} | 储蓄率 {report.summary['savings_rate']:.2f}%")
  169. print(f"消费人格: {report.persona['primary']}")
  170. print(f"异常消费: {len(report.anomalies)} 笔 | Quest: {len(report.quests)} 个 | 反思: 已生成")
  171. print(f"JSON 报告: {json_path}")
  172. print(f"Markdown 报告(LLM 生成): {markdown_path}")
  173. return 0
  174. except (OSError, ValueError, RuntimeConfigurationError, LLMCallError) as exc:
  175. print(f"MoneyMirrorAgent 运行失败: {exc}", file=sys.stderr)
  176. return 2
  177. finally:
  178. if coordinator is not None:
  179. coordinator.close()
  180. if __name__ == "__main__":
  181. raise SystemExit(main())