init: inspiration collector v1.0
This commit is contained in:
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