169 lines
5.6 KiB
Python
169 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
||
"""每日复盘 — 本地 LLM 版(替代云 API agent 模式 cron)
|
||
读今日 session/journal/心迹 → 本地 7B 生成复盘 → 推飞书
|
||
用法:
|
||
python3 daily_recap_local.py # 生成并推送
|
||
python3 daily_recap_local.py --dry # 仅生成不推送
|
||
"""
|
||
import json, os, sys, subprocess, urllib.request
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
|
||
for _ in (sys.stdout, sys.stderr):
|
||
try: _.reconfigure(encoding='utf-8', errors='replace')
|
||
except Exception: pass
|
||
|
||
HOME = Path.home()
|
||
|
||
def _load_env_key(name):
|
||
"""从 ~/.hermes/.env 读 key(智谱/商汤 fallback 用)"""
|
||
try:
|
||
with open("/home/muc/.hermes/.env") as _f:
|
||
for _l in _f:
|
||
if _l.startswith(name + "="):
|
||
return _l.strip().split("=", 1)[1].strip().strip('"').strip("'")
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
LLM_URL = "https://apihub.agnes-ai.com/v1/chat/completions" # agnes 云(fallback 2)
|
||
ZHIPU_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions" # 智谱 glm-4-flash
|
||
SENSENOVA_URL = "https://token.sensenova.cn/v1/chat/completions" # 商汤 deepseek-v4-flash
|
||
_AGNES_KEY = "sk-7k9e9KGcoZdDuYt2LSA4YXdBTioczleGJm2zzLWCku072ikW"
|
||
LOCAL_LLM_URL = "http://127.0.0.1:8080/v1/chat/completions" # 本地 MiniCPM5-2B 优先(0成本)
|
||
LOCAL_MODEL = "minicpm5-2b"
|
||
LOCAL_KEY = "local-key"
|
||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/446db983-e392-4d2c-bfb8-f9060e5df3ad"
|
||
|
||
|
||
def send_feishu(title: str, content: str):
|
||
card_obj = {
|
||
"header": {"title": {"tag": "plain_text", "content": title}, "template": "blue"},
|
||
"elements": [{"tag": "markdown", "content": content}],
|
||
}
|
||
payload = json.dumps({"msg_type": "interactive", "card": json.dumps(card_obj, ensure_ascii=False)}).encode()
|
||
req = urllib.request.Request(FEISHU_WEBHOOK, data=payload, headers={"Content-Type": "application/json"})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=10):
|
||
return True
|
||
except Exception as e:
|
||
print(f"[warn] 飞书推送失败: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
|
||
def read_tail(path: Path, max_lines: int = 100):
|
||
"""读文件尾部 N 行(文件不存在返回空)"""
|
||
if not path.exists():
|
||
return ""
|
||
try:
|
||
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||
return "\n".join(lines[-max_lines:])
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def collect_context():
|
||
"""收集今日复盘所需数据"""
|
||
today = date.today().isoformat()
|
||
ctx = []
|
||
ctx.append(f"【日期】{today}")
|
||
|
||
# 1. 今日 session(用 hermes CLI 查最近会话)
|
||
try:
|
||
r = subprocess.run(
|
||
["hermes", "sessions", "today", "--json"],
|
||
capture_output=True, text=True, timeout=15
|
||
)
|
||
if r.stdout.strip():
|
||
ctx.append("【今日会话】\n" + r.stdout.strip()[:1500])
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. daemon journal(最近 30 条)
|
||
j = read_tail(HOME / ".hermes" / "daemon" / "journal.jsonl", 30)
|
||
if j:
|
||
ctx.append("【今日 daemon 日志】\n" + j[:1500])
|
||
|
||
# 3. 心迹(最近 10 条)
|
||
h = read_tail(HOME / ".hermes" / "soulful" / "heart-traces.jsonl", 10)
|
||
if h:
|
||
ctx.append("【今日心迹】\n" + h[:1000])
|
||
|
||
return "\n\n".join(ctx)
|
||
|
||
|
||
def llm_recap(context):
|
||
"""本地 LLM 生成每日复盘"""
|
||
prompt = f"""你是小唯。现在进行每日复盘,综合以下信息输出简短复盘报告。
|
||
|
||
{context}
|
||
|
||
请输出(简洁风格,不要废话):
|
||
📋 每日复盘
|
||
|
||
【今日完成】
|
||
- 2-3 条要点
|
||
|
||
【明日待办】
|
||
- 1-3 条要点
|
||
|
||
【情绪状态】
|
||
- 一句话(基于心迹/日志推断)"""
|
||
|
||
last_err = None
|
||
for _url, _model, _key in (
|
||
(LOCAL_LLM_URL, LOCAL_MODEL, LOCAL_KEY),
|
||
(LLM_URL, "agnes-2.5-flash", _AGNES_KEY),
|
||
(ZHIPU_URL, "glm-4-flash", _load_env_key("ZHIPU_API_KEY")),
|
||
(SENSENOVA_URL, "deepseek-v4-flash", _load_env_key("SENSENOVA_API_KEY")),
|
||
):
|
||
_payload = json.dumps({
|
||
"model": _model,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"temperature": 0.6,
|
||
"max_tokens": 600,
|
||
}).encode("utf-8")
|
||
try:
|
||
_req = urllib.request.Request(_url, data=_payload, headers={
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {_key}",
|
||
}, method="POST")
|
||
with urllib.request.urlopen(_req, timeout=180) as resp:
|
||
_data = json.loads(resp.read().decode("utf-8"))
|
||
_content = _data["choices"][0]["message"]["content"]
|
||
if _content and _content.strip():
|
||
return _content
|
||
last_err = f"{_url}: empty content"
|
||
except Exception as e:
|
||
last_err = f"{_url}: {e}"
|
||
print(f"[warn] LLM {_url} 调用失败: {e}", file=sys.stderr)
|
||
print(f"[error] 本地+agnes 都失败: {last_err}", file=sys.stderr)
|
||
return None
|
||
|
||
|
||
def main():
|
||
dry = "--dry" in sys.argv
|
||
|
||
print("1. 收集数据...")
|
||
context = collect_context()
|
||
print(f" 上下文 {len(context)} 字符")
|
||
|
||
print("2. 本地 LLM 生成复盘...")
|
||
recap = llm_recap(context)
|
||
if not recap:
|
||
print("[error] 生成失败", file=sys.stderr)
|
||
sys.exit(1)
|
||
print(f" 生成 {len(recap)} 字符")
|
||
|
||
if dry:
|
||
print("\n=== 生成的复盘 ===\n")
|
||
print(recap)
|
||
else:
|
||
print("3. 推送飞书...")
|
||
ok = send_feishu("📋 每日复盘", recap)
|
||
print(f" 推送: {'✅' if ok else '❌'}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|