init: inspiration collector v1.0
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
74
README.md
Normal file
74
README.md
Normal file
@ -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. 设置自动拉取
|
||||
0
ai-insights/daily/.gitkeep
Normal file
0
ai-insights/daily/.gitkeep
Normal file
32
ai-insights/daily/2026-06-12_digest.md
Normal file
32
ai-insights/daily/2026-06-12_digest.md
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
date: 2026-06-12
|
||||
type: daily-digest
|
||||
tags: [灵感收集器, 每日总结]
|
||||
---
|
||||
|
||||
# 2026-06-12 灵感摘要 · 周五
|
||||
|
||||
## AI 分析(自动生成,请勿编辑)
|
||||
|
||||
今日灵感围绕时间流逝与生活节奏,记录个人感受与部署服务的开始。
|
||||
|
||||
### 分类概览
|
||||
|
||||
- **生活感悟**:4条
|
||||
- **技术记录**:2条
|
||||
|
||||
### 关联发现
|
||||
|
||||
- 时间倒计时的主题贯穿生活与工作,暗示对变化的敏感
|
||||
- 部署服务与记录灵感,体现个人化数字生活的开始
|
||||
|
||||
### 待办
|
||||
|
||||
- [ ] 记录更多关于时间流逝的感悟
|
||||
- [ ] 完善自己部署的服务功能
|
||||
|
||||
---
|
||||
|
||||
## 我的批注
|
||||
|
||||
> *在这里写下你的想法、质疑、补充*
|
||||
0
ai-insights/weekly/.gitkeep
Normal file
0
ai-insights/weekly/.gitkeep
Normal file
0
analyzers/__init__.py
Normal file
0
analyzers/__init__.py
Normal file
168
analyzers/daily_digest.py
Normal file
168
analyzers/daily_digest.py
Normal file
@ -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()
|
||||
159
analyzers/weekly_trend.py
Normal file
159
analyzers/weekly_trend.py
Normal file
@ -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()
|
||||
30
scripts/run_daily_digest.sh
Executable file
30
scripts/run_daily_digest.sh
Executable file
@ -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"
|
||||
28
scripts/run_weekly_trend.sh
Executable file
28
scripts/run_weekly_trend.sh
Executable file
@ -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"
|
||||
4
secrets/.gitignore
vendored
Normal file
4
secrets/.gitignore
vendored
Normal file
@ -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
|
||||
6
secrets/secrets.json.template
Normal file
6
secrets/secrets.json.template
Normal file
@ -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"
|
||||
}
|
||||
0
tools/__init__.py
Normal file
0
tools/__init__.py
Normal file
35
tools/config.py
Normal file
35
tools/config.py
Normal file
@ -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)
|
||||
122
tools/formatter.py
Normal file
122
tools/formatter.py
Normal file
@ -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)
|
||||
71
tools/llm.py
Normal file
71
tools/llm.py
Normal file
@ -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}"
|
||||
)
|
||||
138
tools/memos_client.py
Normal file
138
tools/memos_client.py
Normal file
@ -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)
|
||||
Reference in New Issue
Block a user