Ctrl K
ComunidadGuías…

butler/tools/expense.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Personal expense tracker — cross-project income/expense logging.

Records are tenant-scoped, stored under
``~/.butler/tenants/<tenant>/expenses/`` as one JSON file per entry.

Core value is numeric aggregation: monthly totals, category breakdowns,
and period comparisons — not individual record browsing.
"""

from __future__ import annotations

import json
import logging
import time
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, cast

from butler.tools.pim_schema import (
    EXPENSE_CATEGORIES as _VALID_CATEGORIES,
    EXPENSE_CATEGORY_LABELS as _CATEGORY_LABELS,
    EXPENSE_DIRECTIONS as _VALID_DIRECTIONS,
    MAX_EXPENSE_DESC_LEN as _MAX_DESC_LEN,
    MAX_EXPENSE_RECORDS as _MAX_RECORDS,
)
from butler.tools.tenant_store import TenantStore

logger = logging.getLogger(__name__)

_CN_TZ = timezone(timedelta(hours=8))

_store = TenantStore("expenses", env_toggle="BUTLER_EXPENSE_ENABLED")


def _expense_enabled() -> bool:
    return bool(_store.enabled())


def _expenses_dir() -> Path:
    return cast(Path, _store.storage_dir())


def _normalize_category(raw: Any) -> str:
    val = str(raw or "").strip().lower()
    return val if val in _VALID_CATEGORIES else "other"


def _normalize_direction(raw: Any) -> str:
    val = str(raw or "").strip().lower()
    return val if val in _VALID_DIRECTIONS else "expense"


def _today_str() -> str:
    return datetime.now(_CN_TZ).strftime("%Y-%m-%d")


def _parse_date(raw: str) -> str:
    """Accept YYYY-MM-DD or return today."""
    s = (raw or "").strip()
    if not s:
        return _today_str()
    try:
        datetime.strptime(s[:10], "%Y-%m-%d")
        return s[:10]
    except ValueError:
        return _today_str()


def _month_range(year: int, month: int) -> tuple[str, str]:
    start = f"{year:04d}-{month:02d}-01"
    if month == 12:
        end = f"{year + 1:04d}-01-01"
    else:
        end = f"{year:04d}-{month + 1:02d}-01"
    return start, end


def _filter_by_period(
    records: list[dict[str, Any]],
    start: str,
    end: str,
) -> list[dict[str, Any]]:
    return [r for r in records if start <= r.get("date", "") < end]


# ── Tool Handlers ──────────────────────────────────────────────


def tool_expense_add(
    amount: Any,
    description: str = "",
    category: str = "",
    direction: str = "expense",
    date: str = "",
    tags: Any = None,
    **_: Any,
) -> str:
    if not _expense_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_EXPENSE_ENABLED=0"})

    try:
        amt = round(float(amount), 2)
    except (TypeError, ValueError):
        return json.dumps({"ok": False, "error": "amount must be a number"})
    if amt <= 0:
        return json.dumps({"ok": False, "error": "amount must be positive"})

    total = len(_store.load_all())
    if total >= _MAX_RECORDS:
        return json.dumps({
            "ok": False,
            "error": f"Record limit reached ({_MAX_RECORDS}). Delete old records first.",
        })

    now = time.time()
    rid = uuid.uuid4().hex[:10]
    record_date = _parse_date(date)
    cat = _normalize_category(category)
    dirn = _normalize_direction(direction)

    tags_list: list[str] = []
    if tags:
        if isinstance(tags, str):
            tags_list = [t.strip() for t in tags.replace(",", ",").split(",") if t.strip()]
        elif isinstance(tags, list):
            tags_list = [str(t).strip() for t in tags if str(t).strip()]

    record: dict[str, Any] = {
        "id": rid,
        "amount": amt,
        "direction": dirn,
        "category": cat,
        "description": (description or "").strip()[:_MAX_DESC_LEN],
        "date": record_date,
        "tags": tags_list[:10],
        "created_at": now,
    }
    _store.save(record)

    return json.dumps({
        "ok": True,
        "record_id": rid,
        "amount": amt,
        "direction": dirn,
        "category": cat,
        "date": record_date,
    }, ensure_ascii=False)


def tool_expense_summary(
    period: str = "month",
    year: int = 0,
    month: int = 0,
    **_: Any,
) -> str:
    if not _expense_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_EXPENSE_ENABLED=0"})

    now_cn = datetime.now(_CN_TZ)
    y = int(year) if year else now_cn.year
    m = int(month) if month else now_cn.month

    all_records = _store.load_all()

    if period == "year":
        start = f"{y:04d}-01-01"
        end = f"{y + 1:04d}-01-01"
        label = f"{y}年"
    elif period == "week":
        today = now_cn.date()
        week_start = today - timedelta(days=today.weekday())
        week_end = week_start + timedelta(days=7)
        start = week_start.isoformat()
        end = week_end.isoformat()
        label = f"本周 ({start} ~ {(week_end - timedelta(days=1)).isoformat()})"
    else:
        start, end = _month_range(y, m)
        label = f"{y}年{m}月"

    filtered = _filter_by_period(all_records, start, end)

    total_income = 0.0
    total_expense = 0.0
    cat_totals: dict[str, float] = {}

    for r in filtered:
        amt = r.get("amount", 0)
        dirn = r.get("direction", "expense")
        cat = r.get("category", "other")
        if dirn == "income":
            total_income += amt
        else:
            total_expense += amt
            cat_totals[cat] = cat_totals.get(cat, 0) + amt

    cat_breakdown = sorted(cat_totals.items(), key=lambda x: -x[1])

    return json.dumps({
        "ok": True,
        "period": label,
        "record_count": len(filtered),
        "total_income": round(total_income, 2),
        "total_expense": round(total_expense, 2),
        "net": round(total_income - total_expense, 2),
        "category_breakdown": [
            {"category": c, "label": _CATEGORY_LABELS.get(c, c), "amount": round(a, 2)}
            for c, a in cat_breakdown
        ],
    }, ensure_ascii=False)


def tool_expense_list(
    limit: int = 20,
    category: str = "",
    direction: str = "",
    **_: Any,
) -> str:
    if not _expense_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_EXPENSE_ENABLED=0"})

    all_records = _store.load_all()
    filtered = list(all_records)

    if category:
        cat = _normalize_category(category)
        filtered = [r for r in filtered if r.get("category") == cat]
    if direction:
        dirn = _normalize_direction(direction)
        filtered = [r for r in filtered if r.get("direction") == dirn]

    filtered.sort(key=lambda r: r.get("date", ""), reverse=True)
    limit = max(1, min(int(limit or 20), 50))

    return json.dumps({
        "ok": True,
        "total": len(all_records),
        "count": len(filtered),
        "records": filtered[:limit],
    }, ensure_ascii=False)


def tool_expense_update(
    expense_id: str,
    amount: Any = None,
    description: str = "",
    category: str = "",
    direction: str = "",
    date: str = "",
    tags: Any = None,
    **_: Any,
) -> str:
    if not _expense_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_EXPENSE_ENABLED=0"})

    eid = (expense_id or "").strip()
    if not eid:
        return json.dumps({"ok": False, "error": "expense_id is required"})

    record = _store.find_by_prefix(eid)
    if record is None:
        return json.dumps({"ok": False, "error": f"Record '{eid}' not found"})

    updated_fields: list[str] = []

    if amount is not None:
        try:
            amt = round(float(amount), 2)
        except (TypeError, ValueError):
            return json.dumps({"ok": False, "error": "amount must be a number"})
        if amt <= 0:
            return json.dumps({"ok": False, "error": "amount must be positive"})
        record["amount"] = amt
        updated_fields.append("amount")

    if description and description.strip():
        record["description"] = description.strip()[:_MAX_DESC_LEN]
        updated_fields.append("description")

    if category and category.strip():
        cat = category.strip().lower()
        if cat not in _VALID_CATEGORIES:
            return json.dumps({
                "ok": False,
                "error": f"Invalid category '{category}'. Use: {', '.join(sorted(_VALID_CATEGORIES))}",
            })
        record["category"] = cat
        updated_fields.append("category")

    if direction and direction.strip():
        dirn = direction.strip().lower()
        if dirn not in _VALID_DIRECTIONS:
            return json.dumps({
                "ok": False,
                "error": f"Invalid direction '{direction}'. Use: {', '.join(sorted(_VALID_DIRECTIONS))}",
            })
        record["direction"] = dirn
        updated_fields.append("direction")

    if date and date.strip():
        record["date"] = _parse_date(date)
        updated_fields.append("date")

    if tags is not None:
        tags_list: list[str] = []
        if isinstance(tags, str):
            tags_list = [t.strip() for t in tags.replace(",", ",").split(",") if t.strip()]
        elif isinstance(tags, list):
            tags_list = [str(t).strip() for t in tags if str(t).strip()]
        record["tags"] = tags_list[:10]
        updated_fields.append("tags")

    if updated_fields:
        _store.save(record)

    return json.dumps({
        "ok": True,
        "expense_id": record["id"],
        "updated_fields": updated_fields,
    }, ensure_ascii=False)


def tool_expense_search(
    query: str,
    limit: int = 20,
    **_: Any,
) -> str:
    if not _expense_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_EXPENSE_ENABLED=0"})

    q = (query or "").strip()
    if not q:
        return json.dumps({"ok": False, "error": "query is required"})

    matched = _store.search(q, fields=["description", "tags"])
    matched.sort(key=lambda r: r.get("date", ""), reverse=True)
    limit = max(1, min(int(limit or 20), 50))

    return json.dumps({
        "ok": True,
        "query": q,
        "count": len(matched),
        "records": matched[:limit],
    }, ensure_ascii=False)


def tool_expense_delete(record_id: str, **_: Any) -> str:
    if not _expense_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_EXPENSE_ENABLED=0"})

    rid = (record_id or "").strip()
    if not rid:
        return json.dumps({"ok": False, "error": "record_id is required"})

    if _store.delete(rid):
        return json.dumps({"ok": True, "deleted": rid})

    record = _store.find_by_prefix(rid)
    if record is not None and _store.delete(record["id"]):
        return json.dumps({"ok": True, "deleted": record["id"]})

    return json.dumps({"ok": False, "error": f"Record '{rid}' not found"})


# ── WeChat Display ─────────────────────────────────────────────


def format_expense_for_wechat(arg: str = "", *, limit: int = 15) -> str:
    if not _expense_enabled():
        return "记账功能未启用 (BUTLER_EXPENSE_ENABLED=0)"

    arg = (arg or "").strip()

    if arg.startswith("本周") or arg == "week":
        return _format_summary("week")
    if arg.startswith("本月") or arg == "month" or not arg:
        return _format_summary("month")
    if arg.startswith("本年") or arg.startswith("年度") or arg == "year":
        return _format_summary("year")

    if arg.startswith("记 ") or arg.startswith("add "):
        return _handle_quick_add(arg)

    if arg.startswith("明细") or arg.startswith("list"):
        return _format_recent(limit=limit)

    return _format_summary("month")


def _handle_quick_add(arg: str) -> str:
    """Parse quick-add: /记账 记 午饭 35 or /记账 记 35 午饭"""
    parts = arg.split(maxsplit=1)
    if len(parts) < 2 or not parts[1].strip():
        return "用法: /记账 记 <描述> <金额>\n示例: /记账 记 午饭 35"

    rest = parts[1].strip()
    tokens = rest.split()
    amount = None
    desc_parts = []

    for t in tokens:
        try:
            amount = float(t.replace("元", "").replace("块", ""))
        except ValueError:
            desc_parts.append(t)

    if amount is None:
        return "❌ 请包含金额,示例: /记账 记 午饭 35"

    desc = " ".join(desc_parts) or "日常支出"
    raw = tool_expense_add(amount=amount, description=desc)
    data = json.loads(raw)
    if data.get("ok"):
        return f"✅ 已记录: {desc} ¥{data['amount']:.2f} ({data['date']})"
    return f"❌ {data.get('error', '记录失败')}"


def _format_summary(period: str) -> str:
    raw = tool_expense_summary(period=period)
    data = json.loads(raw)

    if data.get("record_count", 0) == 0:
        if period == "month":
            return "💰 本月暂无记录\n\n记一笔: /记账 记 <描述> <金额>\n或对话中说「午饭花了35元」"
        return f"💰 {data.get('period', '')} 暂无记录"

    lines = [f"💰 {data['period']} 收支汇总\n"]

    if data.get("total_income", 0) > 0:
        lines.append(f"📈 收入: ¥{data['total_income']:,.2f}")
    lines.append(f"📉 支出: ¥{data['total_expense']:,.2f}")

    net = data.get("net", 0)
    if data.get("total_income", 0) > 0:
        icon = "✅" if net >= 0 else "⚠️"
        lines.append(f"{icon} 结余: ¥{net:,.2f}")

    lines.append(f"📊 共 {data['record_count']} 笔\n")

    breakdown = data.get("category_breakdown", [])
    if breakdown:
        lines.append("分类明细:")
        for item in breakdown[:8]:
            bar_len = min(int(item["amount"] / max(breakdown[0]["amount"], 1) * 10), 10)
            bar = "█" * bar_len
            lines.append(f"  {item['label']}: ¥{item['amount']:,.2f}  {bar}")

    lines.append("\n/记账 明细  查看最近记录")
    return "\n".join(lines)


def _format_recent(*, limit: int = 15) -> str:
    raw = tool_expense_list(limit=limit)
    data = json.loads(raw)
    records = data.get("records", [])

    if not records:
        return "💰 暂无记录"

    lines = [f"💰 最近 {len(records)} 笔记录\n"]
    for r in records:
        dirn_icon = "📈" if r.get("direction") == "income" else "📉"
        cat = _CATEGORY_LABELS.get(r.get("category", "other"), "其他")
        desc = r.get("description", "")[:30]
        lines.append(
            f"{dirn_icon} {r.get('date', '')} ¥{r.get('amount', 0):.2f} "
            f"[{cat}] {desc}"
        )

    lines.append(f"\n共 {data['total']} 条  |  /记账 本月  查看汇总")
    return "\n".join(lines)


# ── Registration ───────────────────────────────────────────────


def register_expense_tools(register: Callable[..., None]) -> None:
    if not _expense_enabled():
        return

    register(
        name="expense_add",
        description=(
            "记一笔收支。支出说「午饭花了35」,收入用 direction='income'。"
            "日期默认今天,可指定 date='2026-05-20'。"
        ),
        schema={
            "type": "object",
            "properties": {
                "amount": {"type": "number", "description": "金额(正数)"},
                "description": {"type": "string", "description": "描述"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": (
                        "分类: food/transport/housing/medical/entertainment/"
                        "shopping/education/social/salary/investment/other"
                    ),
                },
                "direction": {
                    "type": "string",
                    "enum": ["income", "expense"],
                    "description": "收支方向: income(收入) / expense(支出,默认)",
                },
                "date": {
                    "type": "string",
                    "description": "日期 YYYY-MM-DD(默认今天)",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "可选标签",
                },
            },
            "required": ["amount"],
        },
        handler=tool_expense_add,
        toolset="expense",
    )

    register(
        name="expense_summary",
        description=(
            "Period spend aggregates: totals and category_breakdown for month|week|year. "
            "For «how much spent» / budget burn questions. "
            "Never list individual purchases."
        ),
        schema={
            "type": "object",
            "properties": {
                "period": {
                    "type": "string",
                    "enum": ["month", "week", "year"],
                    "description": "统计周期: month(默认)/week/year",
                },
                "year": {"type": "integer", "description": "年份(默认今年)"},
                "month": {"type": "integer", "description": "月份(默认当月)"},
            },
        },
        handler=tool_expense_summary,
        toolset="expense",
    )

    register(
        name="expense_list",
        description=(
            "Chronological purchase ledger: each row with expense_id, amount, category, date. "
            "For «what did I buy» / itemized history. "
            "Never totals or category pie."
        ),
        schema={
            "type": "object",
            "properties": {
                "limit": {"type": "integer", "description": "最多返回条数"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "按分类筛选",
                },
                "direction": {
                    "type": "string",
                    "enum": ["income", "expense"],
                    "description": "按收支方向筛选",
                },
            },
        },
        handler=tool_expense_list,
        toolset="expense",
    )

    register(
        name="expense_update",
        description="修改一条收支记录的金额、描述、分类、方向、日期或标签。",
        schema={
            "type": "object",
            "properties": {
                "expense_id": {"type": "string", "description": "记录 ID(支持前缀匹配)"},
                "amount": {"type": "number", "description": "新金额(正数)"},
                "description": {"type": "string", "description": "新描述"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "新分类",
                },
                "direction": {
                    "type": "string",
                    "enum": list(_VALID_DIRECTIONS),
                    "description": "新收支方向",
                },
                "date": {"type": "string", "description": "新日期 YYYY-MM-DD"},
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "替换标签列表",
                },
            },
            "required": ["expense_id"],
        },
        handler=tool_expense_update,
        toolset="expense",
    )

    register(
        name="expense_search",
        description=(
            "【read-only·keyword】按描述关键词检索收支行,返回 record_id 与摘要。"
            "场景:找某笔消费。不删除、不修改。"
        ),
        schema={
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "搜索关键词"},
                "limit": {"type": "integer", "description": "最多返回条数(默认 20)"},
            },
            "required": ["query"],
        },
        handler=tool_expense_search,
        toolset="expense",
    )

    register(
        name="expense_delete",
        description=(
            "【mutation·purge】按 record_id 永久删除一条收支。"
            "场景:记错账需移除。不执行关键词搜索。"
        ),
        schema={
            "type": "object",
            "properties": {
                "record_id": {"type": "string", "description": "记录 ID(支持前缀匹配)"},
            },
            "required": ["record_id"],
        },
        handler=tool_expense_delete,
        toolset="expense",
    )