feat: tag-aware daily digest - tags in AI prompt, grouping, frontmatter, cross-day continuity

This commit is contained in:
Beast
2026-06-15 11:27:28 +08:00
parent 01d0aef23f
commit 75586f1fac
2 changed files with 186 additions and 36 deletions

View File

@ -5,6 +5,7 @@ import logging
import os import os
import sys import sys
import subprocess import subprocess
from collections import Counter
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@ -33,7 +34,6 @@ def utc_to_beijing(ts_str):
def read_previous_digest(date): def read_previous_digest(date):
"""Read the most recent previous daily digest (if exists) for continuity.""" """Read the most recent previous daily digest (if exists) for continuity."""
daily_root = get_output_dir("daily") daily_root = get_output_dir("daily")
# Try yesterday first, then go back up to 7 days
for days_back in range(1, 8): for days_back in range(1, 8):
prev_date = date - timedelta(days=days_back) prev_date = date - timedelta(days=days_back)
prev_dir = os.path.join(daily_root, prev_date.strftime("%Y-%m-%d")) prev_dir = os.path.join(daily_root, prev_date.strftime("%Y-%m-%d"))
@ -45,10 +45,8 @@ def read_previous_digest(date):
prev_file = os.path.join(prev_dir, files[0]) prev_file = os.path.join(prev_dir, files[0])
with open(prev_file, "r", encoding="utf-8") as f: with open(prev_file, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
# Extract just the AI analysis section, skip frontmatter
ai_section_start = content.find("## AI 分析") ai_section_start = content.find("## AI 分析")
if ai_section_start != -1: if ai_section_start != -1:
# Find the "我的批注" section boundary
annotation_start = content.find("## 我的批注", ai_section_start) annotation_start = content.find("## 我的批注", ai_section_start)
if annotation_start != -1: if annotation_start != -1:
ai_section = content[ai_section_start:annotation_start].strip() ai_section = content[ai_section_start:annotation_start].strip()
@ -60,7 +58,6 @@ def read_previous_digest(date):
) )
return prev_date, ai_section return prev_date, ai_section
else: else:
# No AI section found, return whole file
logger.info( logger.info(
"Found previous digest from %s (no AI section found, using full)", "Found previous digest from %s (no AI section found, using full)",
prev_date.strftime("%Y-%m-%d") prev_date.strftime("%Y-%m-%d")
@ -71,19 +68,28 @@ def read_previous_digest(date):
# ============================================================ # ============================================================
# System prompt for daily analysis # System prompt for daily analysis (v2 - tag-aware)
# ============================================================ # ============================================================
DAILY_SYSTEM_PROMPT = """你是一个私人思考伙伴。你的任务是认真阅读用户今天的每一条灵感记录,结合之前的分析,写一篇有深度的分析文章。 DAILY_SYSTEM_PROMPT = """你是一个私人思考伙伴。你的任务是认真阅读用户今天的每一条灵感记录,结合之前的分析,写一篇有深度的分析文章。
注意:所有时间戳都是**北京时间**UTC+8不是 UTC。用户在中国记录和活动时间均以北京时间为准。 注意:所有时间戳都是**北京时间**UTC+8不是 UTC。用户在中国记录和活动时间均以北京时间为准。
## 关于标签
用户的每条记录可能带有标签(如 #培训 #交易 #生活 等)。标签是用户自己对记录的分类和上下文标注,对理解记录非常重要:
1. **标签揭示场景**:同一条灵感,标注 #培训 和标注 #交易 的解读角度完全不同。标签告诉你用户在什么情境下产生的这个想法。
2. **标签揭示延续性**:如果多天都有 #90后四级副职培训班 标签,说明这是一个持续性的活动/项目,分析时应体现跨日的连续思考。
3. **同标签分组**:当多条记录共享同一标签时,它们通常是同一主题下的不同侧面,应该融会贯通地分析,而不是孤立看待。
4. **不要忽略无标签记录**:没有标签的记录可能是随想、零散灵感,但也有其价值。
核心原则:不做缩写,不做分类表,不写空话。 核心原则:不做缩写,不做分类表,不写空话。
具体要求: 具体要求:
1. **引用原文**:分析每一条灵感时,必须先引用(或精炼复述)用户的原文,让用户一眼就知道"哦我在说这个"。引用要压缩但不失原意,不能断章取义。 1. **引用原文**:分析每一条灵感时,必须先引用(或精炼复述)用户的原文,让用户一眼就知道"哦我在说这个"。引用要压缩但不失原意,不能断章取义。
2. **融会贯通,而非逐条罗列**:不要"第一条...第二条...第三条..."机械堆砌。要把所有灵感当作一个整体来思考——哪几条在说同一个主题?哪几条看似无关实则互补?把它们串起来写。文章是流淌的整体,不是并列的零件。 2. **融会贯通,而非逐条罗列**:不要"第一条...第二条...第三条..."机械堆砌。要把所有灵感当作一个整体来思考——哪几条在说同一个主题?哪几条看似无关实则互补?把它们串起来写。文章是流淌的整体,不是并列的零件。**注意利用标签来识别主题聚类**。
3. **承接延续之前的分析**:你会看到上一次的分析内容。今天的分析不能只写"今天的事",要把今天的灵感和昨天的分析结合起来——昨天讨论了什么?今天有什么进展?哪些问题有了答案?哪些问题还在延续?要让文章有跨日的时间纵深感。 3. **承接延续之前的分析**:你会看到上一次的分析内容。今天的分析不能只写"今天的事",要把今天的灵感和昨天的分析结合起来——昨天讨论了什么?今天有什么进展?哪些问题有了答案?哪些问题还在延续?要让文章有跨日的时间纵深感。
@ -110,13 +116,61 @@ DAILY_SYSTEM_PROMPT = """你是一个私人思考伙伴。你的任务是认真
--- ---
注意即便只有1条灵感也要写出深度。宁可写长,不可简略""" 注意即便只有1条灵感也要写出深度。篇幅不设上限,内容完整即可"""
def build_user_prompt(memos, date):
"""Build user prompt for AI, with tag-aware formatting and grouping.
Memos are grouped by shared tags so the AI can see thematic clusters.
Each memo includes its tags in the text block.
"""
# Step 1: Collect all tags across today's memos
tag_freq = MemosClient.aggregate_tags(memos)
groups = MemosClient.group_memos_by_tag(memos)
prompt_parts = []
# Header: date + tag overview
date_str = date.strftime("%Y-%m-%d")
prompt_parts.append(f"以下是我 {date_str} 的灵感记录(共 {len(memos)} 条)。")
if tag_freq:
tag_summary = "".join(f"#{t}{c}条)" for t, c in tag_freq.items())
prompt_parts.append(f"\n今天的标签分布:{tag_summary}")
prompt_parts.append("\n标签说明:用户通过标签自行分类记录。同标签的记录通常属于同一主题或场景,分析时应关注标签揭示的上下文。")
# Step 2: Group memos by tags for thematic presentation
prompt_parts.append("\n---\n")
# Priority: present tagged memos first (grouped by tag), then untagged
sorted_tags = sorted(groups.keys(), key=lambda t: (t == "无标签", -len(groups[t])))
for tag in sorted_tags:
group_memos = groups[tag]
if tag == "无标签":
prompt_parts.append(f"\n## 无标签记录({len(group_memos)}条)")
else:
prompt_parts.append(f"\n## 标签 #{tag}{len(group_memos)}条)")
for m in group_memos:
time_bj = utc_to_beijing(m["created_at"])
content = m["content"].strip()
memo_tags = m.get("tags", [])
# Show all tags on this memo (for cross-tagged memos)
tag_line = ""
if memo_tags:
tag_line = " [" + ", ".join("#" + t for t in memo_tags) + "]"
prompt_parts.append(f"\n- **[{time_bj}]**{tag_line}\n{content}")
return "\n".join(prompt_parts)
def git_push(digest_file): def git_push(digest_file):
"""Commit and push the digest file to Gitea.""" """Commit and push the digest file to Gitea."""
try: try:
# git add
result = subprocess.run( result = subprocess.run(
["git", "add", digest_file], ["git", "add", digest_file],
cwd=PROJECT_DIR, cwd=PROJECT_DIR,
@ -126,7 +180,6 @@ def git_push(digest_file):
logger.warning("git add failed: %s", result.stderr.strip()) logger.warning("git add failed: %s", result.stderr.strip())
return False return False
# git commit (check for changes first)
result = subprocess.run( result = subprocess.run(
["git", "status", "--porcelain", "ai-insights/"], ["git", "status", "--porcelain", "ai-insights/"],
cwd=PROJECT_DIR, cwd=PROJECT_DIR,
@ -146,7 +199,6 @@ def git_push(digest_file):
logger.warning("git commit failed: %s", result.stderr.strip()) logger.warning("git commit failed: %s", result.stderr.strip())
return False return False
# git push
result = subprocess.run( result = subprocess.run(
["git", "push", "origin", "main"], ["git", "push", "origin", "main"],
cwd=PROJECT_DIR, cwd=PROJECT_DIR,
@ -156,7 +208,6 @@ def git_push(digest_file):
logger.warning("git push failed: %s", result.stderr.strip()) logger.warning("git push failed: %s", result.stderr.strip())
return False return False
# Extract remote result line
for line in result.stdout.split("\n"): for line in result.stdout.split("\n"):
if "->" in line or "remote:" in line: if "->" in line or "remote:" in line:
logger.info("Gitea push: %s", line.strip()) logger.info("Gitea push: %s", line.strip())
@ -192,17 +243,11 @@ def run(memos_client, llm_client, date=None):
with open(filepath, "w", encoding="utf-8") as f: with open(filepath, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
logger.info("Empty digest written to %s", filepath) logger.info("Empty digest written to %s", filepath)
# Still push
git_push(filepath) git_push(filepath)
return filepath, 0 return filepath, 0
# Step 2: Prepare user prompt # Step 2: Build tag-aware user prompt
memo_texts = [] user_prompt = build_user_prompt(memos, date)
for m in memos:
time_bj = utc_to_beijing(m["created_at"])
memo_texts.append("- **[" + time_bj + "]** " + m["content"].strip())
user_prompt = "以下是我今天的灵感记录:\n\n" + "\n\n".join(memo_texts)
# Step 3: Include previous analysis for continuity # Step 3: Include previous analysis for continuity
prev_date, prev_analysis = read_previous_digest(date) prev_date, prev_analysis = read_previous_digest(date)
@ -215,17 +260,58 @@ def run(memos_client, llm_client, date=None):
+ prev_analysis + prev_analysis
) )
# Step 4: Check for cross-day tag continuity
# If today has tags that appeared in previous digest, note this
tag_freq = MemosClient.aggregate_tags(memos)
today_tags = set(tag_freq.keys())
if prev_date and today_tags:
try:
prev_tags_line = None
# Extract tags from previous digest frontmatter
daily_root = get_output_dir("daily")
prev_dir = os.path.join(daily_root, prev_date.strftime("%Y-%m-%d"))
if os.path.isdir(prev_dir):
prev_files = sorted([f for f in os.listdir(prev_dir) if f.endswith(".md")], reverse=True)
if prev_files:
with open(os.path.join(prev_dir, prev_files[0]), "r", encoding="utf-8") as f:
prev_content = f.read(500)
if "tags:" in prev_content:
prev_tags_line = prev_content.split("tags:")[1].split("\n")[0]
if prev_tags_line:
import re
prev_tags = set(re.findall(r"'([^']+)'", prev_tags_line))
cross_tags = today_tags & prev_tags
if cross_tags:
user_prompt += (
"\n\n---\n\n"
"跨标签延续提示:以下标签与上一次分析("
+ prev_date.strftime("%Y-%m-%d")
+ ")共享:"
+ "".join("#" + t for t in sorted(cross_tags))
+ "。这些标签代表持续性的主题,请特别关注跨日的思维连续性。"
)
except Exception as e:
logger.debug("Cross-tag check failed: %s", e)
user_prompt += "\n\n---\n\n请基于以上所有素材,写一篇有深度的分析文章。" user_prompt += "\n\n---\n\n请基于以上所有素材,写一篇有深度的分析文章。"
# Step 4: Call DeepSeek API # Step 5: Call DeepSeek API
raw_response = llm_client.ask( raw_response = llm_client.ask(
system_prompt=DAILY_SYSTEM_PROMPT, system_prompt=DAILY_SYSTEM_PROMPT,
user_prompt=user_prompt, user_prompt=user_prompt,
temperature=0.5 temperature=0.5
) )
# Step 5: Wrap with frontmatter and write # Step 6: Build frontmatter tags (default tags + today's user tags)
content = format_daily_digest(date, ai_body=raw_response, tags=["灵感收集器", "每日总结", "AI分析"], doc_type="daily-digest") default_tags = ["灵感收集器", "每日总结", "AI分析"]
user_tags = sorted(tag_freq.keys())
all_tags = default_tags + user_tags
content = format_daily_digest(
date, ai_body=raw_response,
tags=all_tags, doc_type="daily-digest"
)
date_str = date.strftime("%Y-%m-%d") date_str = date.strftime("%Y-%m-%d")
daily_dir = os.path.join(get_output_dir("daily"), date_str) daily_dir = os.path.join(get_output_dir("daily"), date_str)
@ -238,11 +324,11 @@ def run(memos_client, llm_client, date=None):
f.write(content) f.write(content)
logger.info( logger.info(
"Daily digest written to %s | %d memos | %d chars analysis", "Daily digest written to %s | %d memos | %d chars analysis | tags: %s",
filepath, len(memos), len(raw_response) filepath, len(memos), len(raw_response), ", ".join(user_tags) if user_tags else "(none)"
) )
# Step 6: Auto-push to Gitea # Step 7: Auto-push to Gitea
git_push(filepath) git_push(filepath)
return filepath, len(memos) return filepath, len(memos)

View File

@ -1,12 +1,14 @@
"""Memos API client - fetch memos using Connect RPC protocol.""" """Memos API client - fetch memos using Connect RPC protocol."""
import logging import logging
from collections import Counter
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from re import findall
TZ_BEIJING = timezone(timedelta(hours=8))
import requests import requests
TZ_BEIJING = timezone(timedelta(hours=8))
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,13 +47,11 @@ class MemosClient:
def list_memos(self, days=1, page_size=100): def list_memos(self, days=1, page_size=100):
"""Fetch memos from the last N days (Beijing time reference) via Connect RPC.""" """Fetch memos from the last N days (Beijing time reference) via Connect RPC."""
user = self.get_user_id() user = self.get_user_id()
# Use Beijing time as reference for "last N days"
now_bj = datetime.now(TZ_BEIJING) now_bj = datetime.now(TZ_BEIJING)
since_bj = now_bj - timedelta(days=days) since_bj = now_bj - timedelta(days=days)
since_utc = since_bj.astimezone(timezone.utc) since_utc = since_bj.astimezone(timezone.utc)
since = since_utc.isoformat() since = since_utc.isoformat()
# Connect RPC uses POST with JSON body
payload = { payload = {
"pageSize": page_size, "pageSize": page_size,
"filter": f"creator == '{user}'", "filter": f"creator == '{user}'",
@ -68,7 +68,6 @@ class MemosClient:
memos = data.get("memos", []) memos = data.get("memos", [])
logger.info("Memos API OK | fetched %d memos", len(memos)) logger.info("Memos API OK | fetched %d memos", len(memos))
# Filter by time client-side (Beijing time reference)
results = [] results = []
for m in memos: for m in memos:
created = m.get("createTime", "") created = m.get("createTime", "")
@ -88,9 +87,7 @@ class MemosClient:
"visibility": m.get("visibility", ""), "visibility": m.get("visibility", ""),
}) })
logger.info( logger.info("Filtered to %d memos since %s", len(results), since[:10])
"Filtered to %d memos since %s", len(results), since[:10]
)
return results return results
def list_all_memos_from_range(self, start_date, end_date, page_size=200): def list_all_memos_from_range(self, start_date, end_date, page_size=200):
@ -110,7 +107,6 @@ class MemosClient:
memos = data.get("memos", []) memos = data.get("memos", [])
logger.info("Memos API OK | fetched %d memos total", len(memos)) logger.info("Memos API OK | fetched %d memos total", len(memos))
# Client-side date range filtering
results = [] results = []
for m in memos: for m in memos:
created = m.get("createTime", "") created = m.get("createTime", "")
@ -136,8 +132,76 @@ class MemosClient:
) )
return results return results
def list_memos_by_tag(self, tag, page_size=200):
"""Fetch all memos containing a specific tag.
Useful for retrieving memos across multiple days related to a specific
topic (e.g. a training course that spans a full week).
"""
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", [])
results = []
for m in memos:
content_text = m.get("content", "")
tags = self._extract_tags(content_text)
if tag not in tags:
continue
results.append({
"id": m.get("name", "").split("/")[-1],
"content": content_text,
"created_at": m.get("createTime", ""),
"tags": tags,
"visibility": m.get("visibility", ""),
})
logger.info("Found %d memos with tag #%s", len(results), tag)
return results
@staticmethod
def aggregate_tags(memos):
"""Count tag frequency across a list of memos.
Returns dict of {tag: count}, sorted by frequency descending.
"""
counter = Counter()
for m in memos:
for tag in m.get("tags", []):
counter[tag] += 1
return dict(counter.most_common())
@staticmethod
def group_memos_by_tag(memos):
"""Group memos by their tags.
Returns dict of {tag: [memo_list]}, with "无标签" key for memos
without any tags. A memo can appear under multiple tags.
"""
groups = {}
untagged = []
for m in memos:
tags = m.get("tags", [])
if not tags:
untagged.append(m)
continue
for tag in tags:
if tag not in groups:
groups[tag] = []
groups[tag].append(m)
if untagged:
groups["无标签"] = untagged
return groups
@staticmethod @staticmethod
def _extract_tags(content): def _extract_tags(content):
"""Extract #tags from memo content.""" """Extract #tags from memo content."""
import re return findall(r"#(\w[\w\-]*)", content)
return re.findall(r"#(\w[\w\-]*)", content)