From 5a876e5ecd4839c07a96b24e1af869f77a21060e Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 12 Jun 2026 23:30:27 +0800 Subject: [PATCH] init: inspiration collector v1.0 --- .gitignore | 14 +++ README.md | 74 +++++++++++ ai-insights/daily/.gitkeep | 0 ai-insights/daily/2026-06-12_digest.md | 32 +++++ ai-insights/weekly/.gitkeep | 0 analyzers/__init__.py | 0 analyzers/daily_digest.py | 168 +++++++++++++++++++++++++ analyzers/weekly_trend.py | 159 +++++++++++++++++++++++ scripts/run_daily_digest.sh | 30 +++++ scripts/run_weekly_trend.sh | 28 +++++ secrets/.gitignore | 4 + secrets/secrets.json.template | 6 + tools/__init__.py | 0 tools/config.py | 35 ++++++ tools/formatter.py | 122 ++++++++++++++++++ tools/llm.py | 71 +++++++++++ tools/memos_client.py | 138 ++++++++++++++++++++ 17 files changed, 881 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ai-insights/daily/.gitkeep create mode 100644 ai-insights/daily/2026-06-12_digest.md create mode 100644 ai-insights/weekly/.gitkeep create mode 100644 analyzers/__init__.py create mode 100644 analyzers/daily_digest.py create mode 100644 analyzers/weekly_trend.py create mode 100755 scripts/run_daily_digest.sh create mode 100755 scripts/run_weekly_trend.sh create mode 100644 secrets/.gitignore create mode 100644 secrets/secrets.json.template create mode 100644 tools/__init__.py create mode 100644 tools/config.py create mode 100644 tools/formatter.py create mode 100644 tools/llm.py create mode 100644 tools/memos_client.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f03df8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.env +venv/ +.venv/ + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..02337e9 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# 灵感收集器 (Inspiration Collector) + +手机随手记灵感 → AI 自动整理 → Obsidian 自动同步 + +## 数据流向 + +``` +手机 Moe Memos → 服务器 Memos 实例 + ↓ + Python 脚本 + DeepSeek API + ↓ + ai-insights/daily/ 中的 .md 文件 + ↓ + git push → Gitea + ↓ + Obsidian Git 插件 pull → 你看到 +``` + +## 目录结构 + +``` +inspiration-collector/ +├── ai-insights/ # AI 生成的报告(git 追踪) +│ ├── daily/ # 每日灵感摘要 +│ └── weekly/ # 每周灵感趋势 +├── tools/ # 核心工具模块 +│ ├── llm.py # DeepSeek API 客户端 +│ ├── memos_client.py # Memos 数据读取 +│ ├── config.py # 配置加载 +│ └── formatter.py # Markdown 输出格式化 +├── analyzers/ # 分析逻辑 +│ ├── daily_digest.py # 每日分析 +│ └── weekly_trend.py # 每周分析 +├── scripts/ # cron 入口脚本 +│ ├── run_daily_digest.sh +│ └── run_weekly_trend.sh +├── secrets/ # 密钥(不入库) +│ ├── .gitignore +│ └── secrets.json.template +└── README.md +``` + +## 部署步骤 + +### 1. 服务器上 + +```bash +# 克隆仓库 +cd ~ +git clone http://localhost:3000/你的用户名/inspiration-collector.git + +# 配置密钥 +cp secrets/secrets.json.template secrets/secrets.json +# 编辑 secrets.json,填入你的 DeepSeek API Key 和 Memos Token +chmod 600 secrets/secrets.json + +# 测试运行 +python3 analyzers/daily_digest.py + +# 设置 cron +crontab -e +# 添加: +# 每日 22:00 北京时间 +0 14 * * * /home/ubuntu/inspiration-collector/scripts/run_daily_digest.sh +# 每周日 16:00 北京时间 +0 8 * * 0 /home/ubuntu/inspiration-collector/scripts/run_weekly_trend.sh +``` + +### 2. 笔记本 Obsidian + +1. `git clone` 到本地 +2. Obsidian 中打开文件夹 +3. 安装 Obsidian Git 插件 +4. 设置自动拉取 diff --git a/ai-insights/daily/.gitkeep b/ai-insights/daily/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ai-insights/daily/2026-06-12_digest.md b/ai-insights/daily/2026-06-12_digest.md new file mode 100644 index 0000000..dd87354 --- /dev/null +++ b/ai-insights/daily/2026-06-12_digest.md @@ -0,0 +1,32 @@ +--- +date: 2026-06-12 +type: daily-digest +tags: [灵感收集器, 每日总结] +--- + +# 2026-06-12 灵感摘要 · 周五 + +## AI 分析(自动生成,请勿编辑) + +今日灵感围绕时间流逝与生活节奏,记录个人感受与部署服务的开始。 + +### 分类概览 + +- **生活感悟**:4条 +- **技术记录**:2条 + +### 关联发现 + +- 时间倒计时的主题贯穿生活与工作,暗示对变化的敏感 +- 部署服务与记录灵感,体现个人化数字生活的开始 + +### 待办 + +- [ ] 记录更多关于时间流逝的感悟 +- [ ] 完善自己部署的服务功能 + +--- + +## 我的批注 + +> *在这里写下你的想法、质疑、补充* diff --git a/ai-insights/weekly/.gitkeep b/ai-insights/weekly/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/analyzers/__init__.py b/analyzers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analyzers/daily_digest.py b/analyzers/daily_digest.py new file mode 100644 index 0000000..cc3e4de --- /dev/null +++ b/analyzers/daily_digest.py @@ -0,0 +1,168 @@ +"""Daily digest analyzer - fetches today's memos and generates AI summary.""" + +import json +import logging +import os +import sys +from datetime import datetime, timezone + +# Add parent dir to path for local imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from tools.config import load_secrets, get_output_dir +from tools.llm import DeepSeekClient +from tools.memos_client import MemosClient +from tools.formatter import format_daily_digest + +logger = logging.getLogger(__name__) + +# System prompt for daily digest +DAILY_SYSTEM_PROMPT = """你是一个灵感整理助手。你的任务是将用户零散的灵感记录进行智能整理。 + +请严格按照要求整理,输出纯 JSON(不要代码块标记,不要额外说明): + +{ + "summary": "一段简短的今日灵感总结,50字以内", + "categories": { + "分类名1": ["具体灵感项1", "具体灵感项2"], + "分类名2": ["具体灵感项3"] + }, + "connections": ["跨主题关联发现1", "2-3条"], + "todos": ["待办事项1", "2-3条"] +} + +分类原则:根据内容自然归类,分类名不超过4个字。 +关联发现:识别不同灵感之间的关联、冲突或可串联的主题。 +待办:从灵感中提取可执行的事项。""" + + +def parse_ai_response(text): + """Parse AI response, handling both pure JSON and code-block wrapped JSON.""" + text = text.strip() + # Remove markdown code block wrappers if present + if text.startswith("```"): + lines = text.split("\n") + # Remove first and last ``` lines + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines).strip() + + return json.loads(text) + + +def run(memos_client, llm_client, date=None): + """Run daily digest analysis.""" + date = date or datetime.now(timezone.utc) + + logger.info("Daily digest started for %s", date.strftime("%Y-%m-%d")) + + # Step 1: Fetch today's memos + memos = memos_client.list_memos(days=1) + + if not memos: + logger.info("No memos today, skipping") + # Still generate a minimal file + content = format_daily_digest( + date=date, + categories={}, + summary="今天没有记录灵感。", + connections=[], + todos=[] + ) + output_dir = get_output_dir("daily") + filename = f"{date.strftime('%Y-%m-%d')}_digest.md" + filepath = os.path.join(output_dir, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + logger.info("Empty digest written to %s", filepath) + return filepath, 0 + + # Step 2: Prepare prompt + memo_texts = [] + for m in memos: + time_str = m["created_at"][:16].replace("T", " ") + memo_texts.append(f"[{time_str}] {m['content']}") + + user_prompt = f"请分析以下灵感记录:\n\n" + "\n".join(memo_texts) + + # Step 3: Call DeepSeek API + raw_response = llm_client.ask( + system_prompt=DAILY_SYSTEM_PROMPT, + user_prompt=user_prompt, + temperature=0.3 + ) + + # Step 4: Parse and format + try: + data = parse_ai_response(raw_response) + except (json.JSONDecodeError, KeyError) as e: + logger.error("Failed to parse AI response: %s", e) + logger.error("Raw response: %s", raw_response[:200]) + data = { + "summary": f"AI 解析失败,请查看原始 Memos。", + "categories": {"未分类": [m["content"] for m in memos]}, + "connections": [], + "todos": [] + } + + categories = data.get("categories", {}) + connections = data.get("connections", []) + todos = data.get("todos", []) + summary = data.get("summary", "") + + # Step 5: Format and write output + content = format_daily_digest( + date=date, + categories=categories, + summary=summary, + connections=connections, + todos=todos + ) + + output_dir = get_output_dir("daily") + filename = f"{date.strftime('%Y-%m-%d')}_digest.md" + filepath = os.path.join(output_dir, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + logger.info( + "Daily digest written to %s | %d memos | %d categories", + filepath, len(memos), len(categories) + ) + return filepath, len(memos) + + +def main(): + """CLI entry point.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" + ) + + try: + secrets = load_secrets() + except FileNotFoundError as e: + print(e) + sys.exit(1) + + memos_client = MemosClient( + base_url=secrets.get("memos_url", "http://localhost:5230"), + access_token=secrets["memos_token"] + ) + + llm_client = DeepSeekClient( + api_key=secrets["deepseek_api_key"], + model=secrets.get("deepseek_model", "deepseek-chat") + ) + + filepath, count = run(memos_client, llm_client) + print(f"Done: {filepath} ({count} memos)") + + +if __name__ == "__main__": + main() diff --git a/analyzers/weekly_trend.py b/analyzers/weekly_trend.py new file mode 100644 index 0000000..e8e8ceb --- /dev/null +++ b/analyzers/weekly_trend.py @@ -0,0 +1,159 @@ +"""Weekly trend analyzer - identifies patterns across a week of memos.""" + +import json +import logging +import os +import sys +from datetime import datetime, timezone, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from tools.config import load_secrets, get_output_dir +from tools.llm import DeepSeekClient +from tools.memos_client import MemosClient +from tools.formatter import format_weekly_trend + +logger = logging.getLogger(__name__) + +WEEKLY_SYSTEM_PROMPT = """你是一个灵感趋势分析师。你的任务是对用户一周的灵感记录进行趋势分析。 + +请输出纯 JSON(不要代码块标记,不要额外说明): + +{ + "daily_counts": { + "周一": 3, + "周二": 1 + }, + "categories": { + "分类名1": 5, + "分类名2": 3 + }, + "highlights": ["本周最有价值的灵感1", "2-3条"], + "insight": "一段关于用户本周思维模式的洞察,60字以内" +} + +分类原则:根据内容自然归类,分类名不超过4个字。 +亮点:选出本周最有启发性、行动性、或值得继续深挖的灵感。 +洞察:识别用户本周的关注焦点变化、思维模式、或信息缺口。""" + + +def parse_ai_response(text): + """Parse AI JSON response, handling code block wrappers.""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines).strip() + return json.loads(text) + + +def run(memos_client, llm_client, date=None): + """Run weekly trend analysis.""" + date = date or datetime.now(timezone.utc) + # Calculate week start (Monday) and end (Sunday) + weekday = date.weekday() # 0=Monday + week_start = date - timedelta(days=weekday + 7) # Previous Monday + week_end = week_start + timedelta(days=6) # Sunday + + logger.info( + "Weekly trend started for W%s (%s - %s)", + week_start.isocalendar()[1], + week_start.strftime("%Y-%m-%d"), + week_end.strftime("%Y-%m-%d") + ) + + # Fetch week's memos + memos = memos_client.list_all_memos_from_range(week_start, week_end) + + if not memos: + logger.info("No memos this week, skipping") + stats = {"total": 0, "daily_counts": {}, "categories": {}} + content = format_weekly_trend(week_start, week_end, stats, [], "") + output_dir = get_output_dir("weekly") + filename = f"{week_start.strftime('W%Y-%m-%d')}_trend.md" + filepath = os.path.join(output_dir, filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + return filepath, 0 + + # Prepare prompt + memo_texts = [] + for m in memos: + time_str = m["created_at"][:16].replace("T", " ") + memo_texts.append(f"[{time_str}] {m['content']}") + + user_prompt = ( + f"分析以下本周({week_start.strftime('%m/%d')} - {week_end.strftime('%m/%d')})" + f"共 {len(memos)} 条灵感记录的趋势:\n\n" + + "\n".join(memo_texts) + ) + + # Call API + raw_response = llm_client.ask( + system_prompt=WEEKLY_SYSTEM_PROMPT, + user_prompt=user_prompt, + temperature=0.3 + ) + + # Parse + try: + data = parse_ai_response(raw_response) + except (json.JSONDecodeError, KeyError) as e: + logger.error("Failed to parse AI response: %s", e) + data = {"daily_counts": {}, "categories": {}, "highlights": [], "insight": ""} + + stats = { + "total": len(memos), + "daily_counts": data.get("daily_counts", {}), + "categories": data.get("categories", {}), + } + highlights = data.get("highlights", []) + insight = data.get("insight", "") + + # Format output + content = format_weekly_trend(week_start, week_end, stats, highlights, insight) + output_dir = get_output_dir("weekly") + filename = f"{week_start.strftime('W%Y-%m-%d')}_trend.md" + filepath = os.path.join(output_dir, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + logger.info( + "Weekly trend written to %s | %d memos | %d categories", + filepath, len(memos), len(stats["categories"]) + ) + return filepath, len(memos) + + +def main(): + """CLI entry point.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" + ) + + try: + secrets = load_secrets() + except FileNotFoundError as e: + print(e) + sys.exit(1) + + memos_client = MemosClient( + base_url=secrets.get("memos_url", "http://localhost:5230"), + access_token=secrets["memos_token"] + ) + llm_client = DeepSeekClient( + api_key=secrets["deepseek_api_key"], + model=secrets.get("deepseek_model", "deepseek-chat") + ) + + filepath, count = run(memos_client, llm_client) + print(f"Done: {filepath} ({count} memos)") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_daily_digest.sh b/scripts/run_daily_digest.sh new file mode 100755 index 0000000..cddb0e4 --- /dev/null +++ b/scripts/run_daily_digest.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Daily digest cron wrapper +# Run daily at 14:00 UTC (22:00 Beijing) + +set -e + +PROJECT_DIR="$HOME/inspiration-collector" +LOG_FILE="$PROJECT_DIR/ai-insights/logs/daily_digest.log" + +mkdir -p "$(dirname "$LOG_FILE")" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting daily digest..." >> "$LOG_FILE" + +cd "$PROJECT_DIR" +python3 analyzers/daily_digest.py >> "$LOG_FILE" 2>&1 + +# Git push to Gitea +cd "$PROJECT_DIR" + +# Check if there are changes +if [[ -n $(git status --porcelain ai-insights/) ]]; then + git add ai-insights/ + git commit -m "daily digest $(date '+%Y-%m-%d')" + git push origin main 2>&1 | tail -3 >> "$LOG_FILE" + echo "Pushed to Gitea" >> "$LOG_FILE" +else + echo "No changes to push" >> "$LOG_FILE" +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Daily digest done." >> "$LOG_FILE" diff --git a/scripts/run_weekly_trend.sh b/scripts/run_weekly_trend.sh new file mode 100755 index 0000000..ee681e1 --- /dev/null +++ b/scripts/run_weekly_trend.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Weekly trend cron wrapper +# Run every Sunday at 08:00 UTC (16:00 Beijing) + +set -e + +PROJECT_DIR="$HOME/inspiration-collector" +LOG_FILE="$PROJECT_DIR/ai-insights/logs/weekly_trend.log" + +mkdir -p "$(dirname "$LOG_FILE")" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting weekly trend..." >> "$LOG_FILE" + +cd "$PROJECT_DIR" +python3 analyzers/weekly_trend.py >> "$LOG_FILE" 2>&1 + +# Git push to Gitea +cd "$PROJECT_DIR" +if [[ -n $(git status --porcelain ai-insights/) ]]; then + git add ai-insights/ + git commit -m "weekly trend W$(date '+%V')" + git push origin main 2>&1 | tail -3 >> "$LOG_FILE" + echo "Pushed to Gitea" >> "$LOG_FILE" +else + echo "No changes to push" >> "$LOG_FILE" +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Weekly trend done." >> "$LOG_FILE" diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100644 index 0000000..2b478a1 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1,4 @@ +# Secrets directory - never checked into git +# Copy secrets.json.template to secrets.json and fill in your keys +secrets.json +*.json diff --git a/secrets/secrets.json.template b/secrets/secrets.json.template new file mode 100644 index 0000000..fb055a6 --- /dev/null +++ b/secrets/secrets.json.template @@ -0,0 +1,6 @@ +{ + "memos_url": "http://localhost:5230", + "memos_token": "YOUR_MEMOS_TOKEN_HERE", + "deepseek_api_key": "YOUR_DEEPSEEK_API_KEY_HERE", + "deepseek_model": "deepseek-chat" +} diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/config.py b/tools/config.py new file mode 100644 index 0000000..928c095 --- /dev/null +++ b/tools/config.py @@ -0,0 +1,35 @@ +"""Configuration loader - loads secrets from secrets.json""" + +import json +import os +from pathlib import Path + +# Default location: ~/inspiration-collector/secrets/secrets.json on server +SECRETS_PATH = os.environ.get( + "IC_SECRETS_PATH", + str(Path.home() / "inspiration-collector" / "secrets" / "secrets.json") +) + +DEFAULT_OUTPUT_DIR = os.environ.get( + "IC_OUTPUT_DIR", + str(Path.home() / "inspiration-collector" / "ai-insights") +) + + +def load_secrets(path=None): + """Load secrets from JSON file. Returns dict with api_key, memos_token, memos_url.""" + path = path or SECRETS_PATH + if not os.path.exists(path): + raise FileNotFoundError( + f"Secrets file not found: {path}\n" + f"Copy secrets/secrets.json.template to {path} and fill in your keys." + ) + with open(path) as f: + return json.load(f) + + +def get_output_dir(subdir="daily"): + """Get output directory for AI-generated insights.""" + base = DEFAULT_OUTPUT_DIR + os.makedirs(os.path.join(base, subdir), exist_ok=True) + return os.path.join(base, subdir) diff --git a/tools/formatter.py b/tools/formatter.py new file mode 100644 index 0000000..a4515ac --- /dev/null +++ b/tools/formatter.py @@ -0,0 +1,122 @@ +"""Markdown formatter - converts AI analysis results to beautiful .md files.""" + +from datetime import datetime + + +def format_daily_digest(date, categories, summary, connections, todos): + """Format daily digest markdown file.""" + date_str = date.strftime("%Y-%m-%d") + weekday = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][date.weekday()] + + lines = [] + lines.append("---") + lines.append(f"date: {date_str}") + lines.append("type: daily-digest") + lines.append("tags: [灵感收集器, 每日总结]") + lines.append("---") + lines.append("") + lines.append(f"# {date_str} 灵感摘要 · {weekday}") + lines.append("") + + # Summary + lines.append("## AI 分析(自动生成,请勿编辑)") + lines.append("") + lines.append(summary) + lines.append("") + + # Categories + if categories: + lines.append("### 分类概览") + lines.append("") + for cat, items in categories.items(): + count = len(items) if isinstance(items, list) else items + lines.append(f"- **{cat}**:{count}条") + lines.append("") + + # Connections + if connections: + lines.append("### 关联发现") + lines.append("") + for conn in connections: + lines.append(f"- {conn}") + lines.append("") + + # Todos + if todos: + lines.append("### 待办") + lines.append("") + for todo in todos: + lines.append(f"- [ ] {todo}") + lines.append("") + + # User annotation area + lines.append("---") + lines.append("") + lines.append("## 我的批注") + lines.append("") + lines.append("> *在这里写下你的想法、质疑、补充*") + lines.append("") + + return "\n".join(lines) + + +def format_weekly_trend(start_date, end_date, stats, highlights, insight): + """Format weekly trend markdown file.""" + week_start = start_date.strftime("%m/%d") + week_end = end_date.strftime("%m/%d") + week_number = start_date.isocalendar()[1] + + lines = [] + lines.append("---") + lines.append(f"date: {start_date.year}-W{week_number}") + lines.append("type: weekly-trend") + lines.append("tags: [灵感收集器, 每周趋势]") + lines.append("---") + lines.append("") + lines.append(f"# 第 {week_number} 周灵感趋势({week_start} - {week_end})") + lines.append("") + + # Stats + lines.append("## AI 分析(自动生成,请勿编辑)") + lines.append("") + lines.append(f"本周共记录 **{stats.get('total', 0)}** 条灵感。") + lines.append("") + + if stats.get("daily_counts"): + lines.append("### 每日活跃度") + lines.append("") + for day, count in stats["daily_counts"].items(): + bar = "█" * count if count > 0 else "▏" + lines.append(f"- {day}:{bar} {count}条") + lines.append("") + + if stats.get("categories"): + lines.append("### 主题分布") + lines.append("") + for cat, count in stats["categories"].items(): + pct = count / stats["total"] * 100 if stats["total"] else 0 + lines.append(f"- **{cat}**:{count}条({pct:.0f}%)") + lines.append("") + + if highlights: + lines.append("### 本周亮点") + lines.append("") + for h in highlights: + lines.append(f"- {h}") + lines.append("") + + if insight: + lines.append("### 值得关注的模式") + lines.append("") + lines.append(insight) + lines.append("") + + # User annotation + lines.append("---") + lines.append("") + lines.append("## 我的批注") + lines.append("") + lines.append("> *有什么想补充的写在下面*") + lines.append("") + + return "\n".join(lines) diff --git a/tools/llm.py b/tools/llm.py new file mode 100644 index 0000000..a5831b0 --- /dev/null +++ b/tools/llm.py @@ -0,0 +1,71 @@ +"""DeepSeek API client - OpenAI-compatible, zero extra dependencies.""" + +import json +import logging +import time +import requests + +logger = logging.getLogger(__name__) + + +class DeepSeekClient: + """Client for DeepSeek API (OpenAI-compatible format).""" + + BASE_URL = "https://api.deepseek.com/v1/chat/completions" + + def __init__(self, api_key, model="deepseek-chat", temperature=0.7, max_retries=3): + self.api_key = api_key + self.model = model + self.temperature = temperature + self.max_retries = max_retries + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + }) + + def ask(self, system_prompt, user_prompt, temperature=None): + """Send a chat completion request. Returns response text.""" + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + payload = { + "model": self.model, + "messages": messages, + "temperature": temperature or self.temperature + } + + last_error = None + for attempt in range(self.max_retries): + try: + resp = self.session.post(self.BASE_URL, json=payload, timeout=60) + resp.raise_for_status() + data = resp.json() + result = data["choices"][0]["message"]["content"] + logger.info( + "DeepSeek API OK | model=%s | input=%d chars | output=%d chars", + self.model, len(user_prompt), len(result) + ) + return result + + except requests.exceptions.Timeout: + last_error = "Timeout" + logger.warning("Attempt %d/%d timed out", attempt + 1, self.max_retries) + except requests.exceptions.HTTPError as e: + last_error = str(e) + logger.warning("Attempt %d/%d HTTP error: %s", attempt + 1, self.max_retries, e) + if "insufficient_quota" in str(e).lower() or "invalid_api_key" in str(e).lower(): + raise # don't retry these + except (json.JSONDecodeError, KeyError) as e: + last_error = f"Parse error: {e}" + logger.warning("Attempt %d/%d parse error: %s", attempt + 1, self.max_retries, e) + + if attempt < self.max_retries - 1: + sleep_time = 2 ** attempt + logger.info("Retrying in %ds...", sleep_time) + time.sleep(sleep_time) + + raise RuntimeError( + f"DeepSeek API failed after {self.max_retries} retries. Last error: {last_error}" + ) diff --git a/tools/memos_client.py b/tools/memos_client.py new file mode 100644 index 0000000..cb3e244 --- /dev/null +++ b/tools/memos_client.py @@ -0,0 +1,138 @@ +"""Memos API client - fetch memos using Connect RPC protocol.""" + +import logging +from datetime import datetime, timezone, timedelta + +import requests + +logger = logging.getLogger(__name__) + + +class MemosClient: + """Client for self-hosted Memos API (Connect RPC protocol).""" + + def __init__(self, base_url, access_token): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }) + self._user_id = None + + def get_user(self): + """Get current user info via Connect RPC.""" + resp = self.session.post( + f"{self.base_url}/memos.api.v1.AuthService/GetCurrentUser", + json={}, + timeout=10 + ) + resp.raise_for_status() + data = resp.json() + user = data.get("user", data) + self._user_id = user.get("name", "") + logger.info("Memos user: %s (%s)", user.get("username"), self._user_id) + return user + + def get_user_id(self): + """Get current user's resource name (e.g. 'users/FXY').""" + if not self._user_id: + self.get_user() + return self._user_id + + def list_memos(self, days=1, page_size=100): + """Fetch memos from the last N days via Connect RPC.""" + user = self.get_user_id() + since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + + # Connect RPC uses POST with JSON body + payload = { + "pageSize": page_size, + "filter": f"creator == '{user}'", + } + + resp = self.session.post( + f"{self.base_url}/memos.api.v1.MemoService/ListMemos", + json=payload, + timeout=15 + ) + resp.raise_for_status() + data = resp.json() + + memos = data.get("memos", []) + logger.info("Memos API OK | fetched %d memos", len(memos)) + + # Filter by time client-side (Connect RPC filter might not work as expected) + results = [] + since_dt = datetime.now(timezone.utc) - timedelta(days=days) + for m in memos: + created = m.get("createTime", "") + try: + created_dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + except (ValueError, AttributeError): + created_dt = datetime.now(timezone.utc) + + if created_dt < since_dt: + continue + + results.append({ + "id": m.get("name", "").split("/")[-1], + "content": m.get("content", ""), + "created_at": created, + "tags": self._extract_tags(m.get("content", "")), + "visibility": m.get("visibility", ""), + }) + + logger.info( + "Filtered to %d memos since %s", len(results), since[:10] + ) + return results + + def list_all_memos_from_range(self, start_date, end_date, page_size=200): + """Fetch memos within a date range via Connect RPC.""" + user = self.get_user_id() + + payload = {"pageSize": page_size} + + resp = self.session.post( + f"{self.base_url}/memos.api.v1.MemoService/ListMemos", + json=payload, + timeout=15 + ) + resp.raise_for_status() + data = resp.json() + + memos = data.get("memos", []) + logger.info("Memos API OK | fetched %d memos total", len(memos)) + + # Client-side date range filtering + results = [] + for m in memos: + created = m.get("createTime", "") + try: + created_dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + except (ValueError, AttributeError): + continue + + if created_dt < start_date or created_dt > end_date: + continue + + results.append({ + "id": m.get("name", "").split("/")[-1], + "content": m.get("content", ""), + "created_at": created, + "tags": self._extract_tags(m.get("content", "")), + "visibility": m.get("visibility", ""), + }) + + logger.info( + "Filtered to %d memos in range %s - %s", + len(results), start_date.strftime("%m/%d"), end_date.strftime("%m/%d") + ) + return results + + @staticmethod + def _extract_tags(content): + """Extract #tags from memo content.""" + import re + return re.findall(r"#(\w[\w\-]*)", content)