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