init: inspiration collector v1.0

This commit is contained in:
2026-06-12 23:30:27 +08:00
commit 5a876e5ecd
17 changed files with 881 additions and 0 deletions

0
tools/__init__.py Normal file
View File

35
tools/config.py Normal file
View 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
View 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
View 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
View 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)