butler/tools/habits.py
Rama main · réplica de lectura del repositorio original.
"""Personal habit tracker — daily check-in with streaks and stats.
Habits are tenant-scoped, stored under
``~/.butler/tenants/<tenant>/habits/``:
- ``<id>.json`` — habit definition
- ``checkins/`` — ``<habit_id>_<YYYY-MM-DD>.json`` per check-in
Core value is streak counting and completion rates, not raw data storage.
"""
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._file_cache import read_json_cached
from butler.tools.pim_schema import (
HABIT_FREQUENCIES as _VALID_FREQUENCIES,
HABIT_FREQ_LABELS as _FREQ_LABELS,
MAX_ACTIVE_HABITS as _MAX_HABITS,
)
from butler.tools.tenant_store import TenantStore
logger = logging.getLogger(__name__)
_CN_TZ = timezone(timedelta(hours=8))
_store = TenantStore("habits", env_toggle="BUTLER_HABITS_ENABLED")
_checkin_store = TenantStore("habits/checkins", env_toggle="BUTLER_HABITS_ENABLED")
def _habits_enabled() -> bool:
return bool(_store.enabled())
def _habits_dir() -> Path:
return Path(_store.storage_dir())
def _checkins_dir() -> Path:
d = Path(_checkin_store.storage_dir())
d.mkdir(parents=True, exist_ok=True)
return d
def _today_str() -> str:
return datetime.now(_CN_TZ).strftime("%Y-%m-%d")
def _save_checkin(habit_id: str, date: str, count: int = 1, note: str = "") -> Path:
data: dict[str, Any] = {
"id": f"{habit_id}_{date}",
"habit_id": habit_id,
"date": date,
"count": count,
"note": note,
"created_at": time.time(),
}
return Path(_checkin_store.save(data))
def _load_checkins(habit_id: str, days: int = 30) -> list[dict[str, Any]]:
cd = _checkins_dir()
if not cd.is_dir():
return []
result: list[dict[str, Any]] = []
for f in sorted(cd.glob(f"{habit_id}_*.json"), reverse=True):
data = read_json_cached(f)
if isinstance(data, dict):
result.append(data)
if len(result) >= days:
break
return result
def _get_checkin(habit_id: str, date: str) -> dict[str, Any] | None:
path = _checkins_dir() / f"{habit_id}_{date}.json"
if not path.is_file():
return None
data = read_json_cached(path)
return data if isinstance(data, dict) else None
def _calc_streak(habit_id: str) -> int:
"""Calculate current consecutive-day streak (including today)."""
today = datetime.now(_CN_TZ).date()
streak = 0
for i in range(365):
d = (today - timedelta(days=i)).isoformat()
if _get_checkin(habit_id, d):
streak += 1
else:
if i == 0:
continue
break
return streak
def _calc_weekly_count(habit_id: str) -> int:
"""Count check-ins in the current week (Mon-Sun)."""
today = datetime.now(_CN_TZ).date()
week_start = today - timedelta(days=today.weekday())
count = 0
for i in range(7):
d = (week_start + timedelta(days=i)).isoformat()
ci = _get_checkin(habit_id, d)
if ci:
count += ci.get("count", 1)
return count
def _calc_completion_rate(habit_id: str, days: int = 30) -> float:
today = datetime.now(_CN_TZ).date()
checked = 0
for i in range(days):
d = (today - timedelta(days=i)).isoformat()
if _get_checkin(habit_id, d):
checked += 1
return round(checked / max(days, 1) * 100, 1)
def _find_habit_by_prefix_or_name(identifier: str) -> dict[str, Any] | None:
hid = identifier.strip()
if not hid:
return None
habit = _store.load_one(hid)
if habit:
return cast(dict[str, Any], habit)
for h in _store.load_all():
if h["id"].startswith(hid) or h.get("name", "").lower() == hid.lower():
return cast(dict[str, Any], h)
return None
# ── Tool Handlers ──────────────────────────────────────────────
def tool_habit_create(
name: str,
frequency: str = "daily",
target_count: int = 1,
**_: Any,
) -> str:
if not _habits_enabled():
return json.dumps({"ok": False, "error": "BUTLER_HABITS_ENABLED=0"})
name = (name or "").strip()[:100]
if not name:
return json.dumps({"ok": False, "error": "name is required"})
all_habits = _store.load_all()
active = [h for h in all_habits if h.get("active", True)]
if len(active) >= _MAX_HABITS:
return json.dumps({
"ok": False,
"error": f"Active habit limit reached ({_MAX_HABITS}). Archive some first.",
})
for h in all_habits:
if h.get("name", "").lower() == name.lower() and h.get("active", True):
return json.dumps({
"ok": False,
"error": f"Habit '{name}' already exists [{h['id'][:4]}]",
})
freq = frequency.strip().lower()
if freq not in _VALID_FREQUENCIES:
freq = "daily"
hid = uuid.uuid4().hex[:10]
habit: dict[str, Any] = {
"id": hid,
"name": name,
"frequency": freq,
"target_count": max(1, int(target_count or 1)),
"active": True,
"created_at": time.time(),
}
_store.save(habit)
return json.dumps({
"ok": True,
"habit_id": hid,
"name": name,
"frequency": freq,
"target_count": habit["target_count"],
}, ensure_ascii=False)
def tool_habit_checkin(
habit_id: str,
count: int = 1,
note: str = "",
date: str = "",
**_: Any,
) -> str:
if not _habits_enabled():
return json.dumps({"ok": False, "error": "BUTLER_HABITS_ENABLED=0"})
habit = _find_habit_by_prefix_or_name(habit_id or "")
if habit is None:
return json.dumps({"ok": False, "error": f"Habit '{habit_id}' not found"})
hid = habit["id"]
checkin_date = date.strip() if date else _today_str()
try:
datetime.strptime(checkin_date[:10], "%Y-%m-%d")
checkin_date = checkin_date[:10]
except ValueError:
checkin_date = _today_str()
existing = _get_checkin(hid, checkin_date)
cnt = max(1, int(count or 1))
clean_note = (note or "").strip()[:500]
if existing:
existing["count"] = existing.get("count", 0) + cnt
if clean_note:
existing["note"] = ((existing.get("note") or "") + "; " + clean_note).strip("; ")[:1000]
path = _checkins_dir() / f"{hid}_{checkin_date}.json"
path.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
total_count = existing["count"]
else:
_save_checkin(hid, checkin_date, cnt, clean_note)
total_count = cnt
streak = _calc_streak(hid)
return json.dumps({
"ok": True,
"habit": habit["name"],
"date": checkin_date,
"count": total_count,
"streak": streak,
}, ensure_ascii=False)
def tool_habit_stats(
habit_id: str = "",
**_: Any,
) -> str:
if not _habits_enabled():
return json.dumps({"ok": False, "error": "BUTLER_HABITS_ENABLED=0"})
if habit_id:
habit = _find_habit_by_prefix_or_name(habit_id)
if habit is None:
return json.dumps({"ok": False, "error": f"Habit '{habit_id}' not found"})
return _single_habit_stats(habit)
all_habits = [h for h in _store.load_all() if h.get("active", True)]
if not all_habits:
return json.dumps({"ok": True, "habits": [], "message": "No active habits"})
stats_list = []
for h in all_habits:
hid = h["id"]
streak = _calc_streak(hid)
rate_30 = _calc_completion_rate(hid, 30)
today_done = _get_checkin(hid, _today_str()) is not None
weekly = _calc_weekly_count(hid) if h.get("frequency") == "weekly" else None
entry: dict[str, Any] = {
"habit_id": hid,
"name": h["name"],
"frequency": h.get("frequency", "daily"),
"streak": streak,
"rate_30d": rate_30,
"today_done": today_done,
}
if weekly is not None:
entry["weekly_count"] = weekly
entry["weekly_target"] = h.get("target_count", 1)
stats_list.append(entry)
return json.dumps({"ok": True, "habits": stats_list}, ensure_ascii=False)
def _single_habit_stats(habit: dict[str, Any]) -> str:
hid = habit["id"]
streak = _calc_streak(hid)
rate_7 = _calc_completion_rate(hid, 7)
rate_30 = _calc_completion_rate(hid, 30)
today_done = _get_checkin(hid, _today_str()) is not None
recent = _load_checkins(hid, days=7)
return json.dumps({
"ok": True,
"habit_id": hid,
"name": habit["name"],
"frequency": habit.get("frequency", "daily"),
"target_count": habit.get("target_count", 1),
"streak": streak,
"rate_7d": rate_7,
"rate_30d": rate_30,
"today_done": today_done,
"recent_7_days": recent,
}, ensure_ascii=False)
def tool_habit_list(**_: Any) -> str:
if not _habits_enabled():
return json.dumps({"ok": False, "error": "BUTLER_HABITS_ENABLED=0"})
all_habits = _store.load_all()
active = [h for h in all_habits if h.get("active", True)]
archived = [h for h in all_habits if not h.get("active", True)]
return json.dumps({
"ok": True,
"active_count": len(active),
"archived_count": len(archived),
"habits": active,
}, ensure_ascii=False)
def tool_habit_update(
habit_id: str,
name: str = "",
frequency: str = "",
target_count: Any = None,
**_: Any,
) -> str:
if not _habits_enabled():
return json.dumps({"ok": False, "error": "BUTLER_HABITS_ENABLED=0"})
hid = (habit_id or "").strip()
if not hid:
return json.dumps({"ok": False, "error": "habit_id is required"})
habit = _store.find_by_prefix(hid)
if habit is None:
return json.dumps({"ok": False, "error": f"Habit '{hid}' not found"})
updated_fields: list[str] = []
if name and name.strip():
habit["name"] = name.strip()
updated_fields.append("name")
if frequency and frequency.strip():
freq = frequency.strip().lower()
if freq not in _VALID_FREQUENCIES:
return json.dumps({
"ok": False,
"error": f"Invalid frequency '{frequency}'. Use: {', '.join(sorted(_VALID_FREQUENCIES))}",
})
habit["frequency"] = freq
updated_fields.append("frequency")
if target_count is not None:
habit["target_count"] = max(1, int(target_count))
updated_fields.append("target_count")
if updated_fields:
_store.save(habit)
return json.dumps({
"ok": True,
"habit_id": habit["id"],
"updated_fields": updated_fields,
}, ensure_ascii=False)
def tool_habit_delete(habit_id: str, **_: Any) -> str:
if not _habits_enabled():
return json.dumps({"ok": False, "error": "BUTLER_HABITS_ENABLED=0"})
habit = _find_habit_by_prefix_or_name(habit_id or "")
if habit is None:
return json.dumps({"ok": False, "error": f"Habit '{habit_id}' not found"})
hid = habit["id"]
name = habit.get("name", "")
habit["active"] = False
_store.save(habit)
return json.dumps({
"ok": True,
"archived": hid,
"name": name,
}, ensure_ascii=False)
# ── WeChat Display ─────────────────────────────────────────────
def format_habits_for_wechat(arg: str = "") -> str:
if not _habits_enabled():
return "打卡功能未启用 (BUTLER_HABITS_ENABLED=0)"
arg = (arg or "").strip()
if arg.startswith("创建 ") or arg.startswith("新建 ") or arg.startswith("add "):
name = arg.split(maxsplit=1)[1] if " " in arg else ""
if name:
raw = tool_habit_create(name=name)
data = json.loads(raw)
if data.get("ok"):
return f"✅ 习惯已创建: {name} [{data['habit_id'][:4]}] ({_FREQ_LABELS.get(data['frequency'], '每日')})"
return f"❌ {data.get('error', '创建失败')}"
return "用法: /打卡 创建 <习惯名>"
if arg.startswith("打 ") or arg.startswith("done ") or arg.startswith("完成 "):
name = arg.split(maxsplit=1)[1] if " " in arg else ""
if name:
raw = tool_habit_checkin(habit_id=name)
data = json.loads(raw)
if data.get("ok"):
streak_msg = f"🔥 连续 {data['streak']} 天" if data["streak"] > 1 else ""
return f"✅ {data['habit']} 已打卡 ({data['date']}) {streak_msg}"
return f"❌ {data.get('error', '打卡失败')}"
return "用法: /打卡 打 <习惯名或ID>"
return _format_habit_dashboard()
def _format_habit_dashboard() -> str:
active = [h for h in _store.load_all() if h.get("active", True)]
if not active:
return "📋 暂无习惯\n\n创建: /打卡 创建 <习惯名>\n或对话中说「我要每天跑步」"
today = _today_str()
lines = [f"📋 习惯打卡 ({today})\n"]
done_count = 0
for h in active:
hid = h["id"]
ci = _get_checkin(hid, today)
done = ci is not None
if done:
done_count += 1
streak = _calc_streak(hid)
status = "✅" if done else "⬜"
streak_txt = f"🔥{streak}天" if streak > 1 else ""
freq = _FREQ_LABELS.get(h.get("frequency", "daily"), "每日")
lines.append(f"{status} {h['name']} {streak_txt} ({freq})")
lines.append(f"\n今日完成: {done_count}/{len(active)}")
lines.append("/打卡 打 <习惯名> 快速打卡")
return "\n".join(lines)
# ── Registration ───────────────────────────────────────────────
def register_habit_tools(register: Callable[..., None]) -> None:
if not _habits_enabled():
return
register(
name="habit_create",
description=(
"为主人创建一个日常习惯追踪。如「每天喝8杯水」「每周运动3次」。"
"支持 daily(每日) 和 weekly(每周) 频率。"
),
schema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "习惯名称"},
"frequency": {
"type": "string",
"enum": ["daily", "weekly"],
"description": "频率: daily(每日,默认) / weekly(每周)",
},
"target_count": {
"type": "integer",
"description": "每次目标次数(如喝水8杯),默认1",
},
},
"required": ["name"],
},
handler=tool_habit_create,
toolset="habits",
)
register(
name="habit_checkin",
description=(
"【mutation·append】向 check-in 日志写入一次完成事件(habit_id、count、date)。"
"场景:用户刚做完(「跑了步」「喝水打卡」)。"
"仅返回写入确认;analytics 用 habit_stats。"
),
schema={
"type": "object",
"properties": {
"habit_id": {"type": "string", "description": "习惯 ID 或名称(支持前缀匹配)"},
"count": {"type": "integer", "description": "打卡次数(默认1)"},
"note": {"type": "string", "description": "可选备注(如:跑了5公里)"},
"date": {"type": "string", "description": "日期 YYYY-MM-DD(默认今天)"},
},
"required": ["habit_id"],
},
handler=tool_habit_checkin,
toolset="habits",
)
register(
name="habit_stats",
description=(
"【metrics-only】streak、completion_rate、今日完成 flag。"
"场景:连续几天、完成率。禁止输出名称清单。"
),
schema={
"type": "object",
"properties": {
"habit_id": {"type": "string", "description": "习惯 ID 或名称(可选,空=全部)"},
},
},
handler=tool_habit_stats,
toolset="habits",
)
register(
name="habit_list",
description=(
"【roster-only】活跃习惯名称与 id 列表。"
"场景:「有哪些习惯」。禁止 streak、rate、百分比。"
),
schema={"type": "object", "properties": {}},
handler=tool_habit_list,
toolset="habits",
)
register(
name="habit_update",
description="修改习惯的名称、频率或目标次数。",
schema={
"type": "object",
"properties": {
"habit_id": {"type": "string", "description": "习惯 ID 或名称"},
"name": {"type": "string", "description": "新名称"},
"frequency": {
"type": "string",
"enum": list(_VALID_FREQUENCIES),
"description": "新频率",
},
"target_count": {"type": "integer", "description": "新目标次数"},
},
"required": ["habit_id"],
},
handler=tool_habit_update,
toolset="habits",
)
register(
name="habit_delete",
description="归档(停用)一个习惯,保留历史打卡数据。",
schema={
"type": "object",
"properties": {
"habit_id": {"type": "string", "description": "习惯 ID 或名称"},
},
"required": ["habit_id"],
},
handler=tool_habit_delete,
toolset="habits",
)