docs: update README/INTERNALS for import feature, harden .gitignore
This commit is contained in:
282
dehydrator.py
282
dehydrator.py
@@ -67,6 +67,9 @@ DIGEST_PROMPT = """你是一个日记整理专家。用户会发送一段包含
|
||||
3. 去除无意义的口水话和重复信息,保留核心内容
|
||||
4. 同一主题的零散信息应合并为一个条目
|
||||
5. 如果有待办事项,单独提取为一个条目
|
||||
6. 单个条目内容不少于50字,过短的零碎信息合并到最相关的条目中
|
||||
7. 总条目数控制在 2~6 个,避免过度碎片化
|
||||
8. 在 content 中对人名、地名、专有名词用 [[双链]] 标记(如 [[婷易]]、[[Obsidian]]),普通词汇不要加
|
||||
|
||||
输出格式(纯 JSON 数组,无其他内容):
|
||||
[
|
||||
@@ -76,11 +79,13 @@ DIGEST_PROMPT = """你是一个日记整理专家。用户会发送一段包含
|
||||
"domain": ["主题域1"],
|
||||
"valence": 0.7,
|
||||
"arousal": 0.4,
|
||||
"tags": ["标签1", "标签2"],
|
||||
"tags": ["核心词1", "核心词2", "扩展词1", "扩展词2"],
|
||||
"importance": 5
|
||||
}
|
||||
]
|
||||
|
||||
tags 生成规则:先从原文精准提取 3~5 个核心词,再引申扩展 5~8 个语义相关词(近义词、上位词、关联场景词),合并为一个数组。
|
||||
|
||||
主题域可选(选最精确的 1~2 个,只选真正相关的):
|
||||
日常: ["饮食", "穿搭", "出行", "居家", "购物"]
|
||||
人际: ["家庭", "恋爱", "友谊", "社交"]
|
||||
@@ -104,6 +109,7 @@ MERGE_PROMPT = """你是一个信息合并专家。请将旧记忆与新内容
|
||||
2. 去除重复信息
|
||||
3. 保留所有重要事实
|
||||
4. 总长度尽量不超过旧记忆的 120%
|
||||
5. 对出现的人名、地名、专有名词用 [[双链]] 标记(如 [[婷易]]、[[Obsidian]]),普通词汇不要加
|
||||
|
||||
直接输出合并后的文本,不要加额外说明。"""
|
||||
|
||||
@@ -124,15 +130,19 @@ ANALYZE_PROMPT = """你是一个内容分析器。请分析以下文本,输出
|
||||
内心: ["情绪", "回忆", "梦境", "自省"]
|
||||
2. valence(情感效价):0.0~1.0,0=极度消极 → 0.5=中性 → 1.0=极度积极
|
||||
3. arousal(情感唤醒度):0.0~1.0,0=非常平静 → 0.5=普通 → 1.0=非常激动
|
||||
4. tags(关键词标签):3~5 个最能概括内容的关键词
|
||||
4. tags(关键词标签):分两步生成,合并为一个数组:
|
||||
第一步—精准提取:从原文抽取 3~5 个真正的核心词,不泛化、不遗漏
|
||||
第二步—引申扩展:自动补充 8~10 个与当前场景语义相关的词,包括近义词、上位词、关联场景词、用户可能用不同措辞搜索的词
|
||||
两步合并为一个 tags 数组,总计 10~15 个
|
||||
5. suggested_name(建议桶名):10字以内的简短标题
|
||||
6. 在 tags 和 suggested_name 中不要使用 [[]] 双链标记
|
||||
|
||||
输出格式(纯 JSON,无其他内容):
|
||||
{
|
||||
"domain": ["主题域1", "主题域2"],
|
||||
"valence": 0.7,
|
||||
"arousal": 0.4,
|
||||
"tags": ["标签1", "标签2", "标签3"],
|
||||
"tags": ["核心词1", "核心词2", "扩展词1", "扩展词2", "..."],
|
||||
"suggested_name": "简短标题"
|
||||
}"""
|
||||
|
||||
@@ -214,20 +224,18 @@ class Dehydrator:
|
||||
if not new_content:
|
||||
return old_content
|
||||
|
||||
# --- Try API merge first / 优先 API 合并 ---
|
||||
if self.api_available:
|
||||
try:
|
||||
result = await self._api_merge(old_content, new_content)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"API merge failed, degrading to local / "
|
||||
f"API 合并失败,降级到本地合并: {e}"
|
||||
)
|
||||
|
||||
# --- Local merge fallback / 本地合并兜底 ---
|
||||
return self._local_merge(old_content, new_content)
|
||||
# --- API merge (no local fallback) ---
|
||||
if not self.api_available:
|
||||
raise RuntimeError("脱水 API 不可用,请检查 config.yaml 中的 dehydration 配置")
|
||||
try:
|
||||
result = await self._api_merge(old_content, new_content)
|
||||
if result:
|
||||
return result
|
||||
raise RuntimeError("API 合并返回空结果")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"API 合并失败,请检查 API 连接: {e}") from e
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# API call: dehydration
|
||||
@@ -314,24 +322,6 @@ class Dehydrator:
|
||||
summary = summary[:1000] + "…"
|
||||
return summary
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Local merge (simple concatenation + truncation)
|
||||
# 本地合并(简单拼接 + 截断)
|
||||
# ---------------------------------------------------------
|
||||
def _local_merge(self, old_content: str, new_content: str) -> str:
|
||||
"""
|
||||
Simple concatenation merge; truncates if too long.
|
||||
简单拼接合并,超长时截断保留两端。
|
||||
"""
|
||||
merged = f"{old_content.strip()}\n\n--- 更新 ---\n{new_content.strip()}"
|
||||
# Truncate if over 3000 chars / 超过 3000 字符则各取一半
|
||||
if len(merged) > 3000:
|
||||
half = 1400
|
||||
merged = (
|
||||
f"{old_content[:half].strip()}\n\n--- 更新 ---\n{new_content[:half].strip()}"
|
||||
)
|
||||
return merged
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Keyword extraction
|
||||
# 关键词提取
|
||||
@@ -391,6 +381,15 @@ class Dehydrator:
|
||||
if domains:
|
||||
header += f" [主题:{domains}]"
|
||||
header += f" [情感:V{valence:.1f}/A{arousal:.1f}]"
|
||||
# Show model's perspective if available (valence drift)
|
||||
model_v = metadata.get("model_valence")
|
||||
if model_v is not None:
|
||||
try:
|
||||
header += f" [我的视角:V{float(model_v):.1f}]"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if metadata.get("digested"):
|
||||
header += " [已消化]"
|
||||
header += "\n"
|
||||
|
||||
content = re.sub(r'\[\[([^\]]+)\]\]', r'\1', content)
|
||||
@@ -412,20 +411,18 @@ class Dehydrator:
|
||||
if not content or not content.strip():
|
||||
return self._default_analysis()
|
||||
|
||||
# --- Try API first (best quality) / 优先走 API ---
|
||||
if self.api_available:
|
||||
try:
|
||||
result = await self._api_analyze(content)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"API tagging failed, degrading to local / "
|
||||
f"API 打标失败,降级到本地分析: {e}"
|
||||
)
|
||||
|
||||
# --- Local analysis fallback / 本地分析兜底 ---
|
||||
return self._local_analyze(content)
|
||||
# --- API analyze (no local fallback) ---
|
||||
if not self.api_available:
|
||||
raise RuntimeError("脱水 API 不可用,请检查 config.yaml 中的 dehydration 配置")
|
||||
try:
|
||||
result = await self._api_analyze(content)
|
||||
if result:
|
||||
return result
|
||||
raise RuntimeError("API 打标返回空结果")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"API 打标失败,请检查 API 连接: {e}") from e
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# API call: auto-tagging
|
||||
@@ -487,121 +484,10 @@ class Dehydrator:
|
||||
"domain": result.get("domain", ["未分类"])[:3],
|
||||
"valence": valence,
|
||||
"arousal": arousal,
|
||||
"tags": result.get("tags", [])[:5],
|
||||
"tags": result.get("tags", [])[:15],
|
||||
"suggested_name": str(result.get("suggested_name", ""))[:20],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Local analysis (fallback when API is unavailable)
|
||||
# 本地分析(无 API 时的兜底方案)
|
||||
# Keyword matching + simple sentiment dictionary
|
||||
# 基于关键词 + 简单情感词典匹配
|
||||
# ---------------------------------------------------------
|
||||
def _local_analyze(self, content: str) -> dict:
|
||||
"""
|
||||
Local keyword + sentiment dictionary analysis.
|
||||
本地关键词 + 情感词典的简单分析。
|
||||
"""
|
||||
keywords = self._extract_keywords(content)
|
||||
text_lower = content.lower()
|
||||
|
||||
# --- Domain matching by keyword hits ---
|
||||
# --- 主题域匹配:基于关键词命中 ---
|
||||
domain_keywords = {
|
||||
# Daily / 日常
|
||||
"饮食": {"吃", "饭", "做饭", "外卖", "奶茶", "咖啡", "麻辣烫", "面包",
|
||||
"超市", "零食", "水果", "牛奶", "食堂", "减肥", "节食"},
|
||||
"出行": {"旅行", "出发", "航班", "酒店", "地铁", "打车", "高铁", "机票",
|
||||
"景点", "签证", "护照"},
|
||||
"居家": {"打扫", "洗衣", "搬家", "快递", "收纳", "装修", "租房"},
|
||||
"购物": {"买", "下单", "到货", "退货", "优惠", "折扣", "代购"},
|
||||
# Relationships / 人际
|
||||
"家庭": {"爸", "妈", "父亲", "母亲", "家人", "弟弟", "姐姐", "哥哥",
|
||||
"奶奶", "爷爷", "亲戚", "家里"},
|
||||
"恋爱": {"爱人", "男友", "女友", "恋", "约会", "接吻", "分手",
|
||||
"暧昧", "在一起", "想你", "同床"},
|
||||
"友谊": {"朋友", "闺蜜", "兄弟", "聚", "约饭", "聊天", "群"},
|
||||
"社交": {"见面", "被人", "圈子", "消息", "评论", "点赞"},
|
||||
# Growth / 成长
|
||||
"工作": {"会议", "项目", "客户", "汇报", "deadline", "同事",
|
||||
"老板", "薪资", "合同", "需求", "加班", "实习"},
|
||||
"学习": {"课", "考试", "论文", "笔记", "作业", "教授", "讲座",
|
||||
"分数", "选课", "学分"},
|
||||
"求职": {"面试", "简历", "offer", "投递", "薪资", "岗位"},
|
||||
# Health / 身心
|
||||
"健康": {"医院", "复查", "吃药", "抽血", "手术", "心率",
|
||||
"病", "症状", "指标", "体检", "月经"},
|
||||
"心理": {"焦虑", "抑郁", "恐慌", "创伤", "人格", "咨询",
|
||||
"安全感", "自残", "崩溃", "压力"},
|
||||
"睡眠": {"睡", "失眠", "噩梦", "清醒", "熬夜", "早起", "午觉"},
|
||||
# Interests / 兴趣
|
||||
"游戏": {"游戏", "steam", "极乐迪斯科", "存档", "通关", "角色",
|
||||
"mod", "DLC", "剧情"},
|
||||
"影视": {"电影", "番剧", "动漫", "剧", "综艺", "追番", "上映"},
|
||||
"音乐": {"歌", "音乐", "专辑", "live", "演唱会", "耳机"},
|
||||
"阅读": {"书", "小说", "读完", "kindle", "连载", "漫画"},
|
||||
"创作": {"写", "画", "预设", "脚本", "视频", "剪辑", "P图",
|
||||
"SillyTavern", "插件", "正则", "人设"},
|
||||
# Digital / 数字
|
||||
"编程": {"代码", "code", "python", "bug", "api", "docker",
|
||||
"git", "调试", "框架", "部署", "开发", "server"},
|
||||
"AI": {"模型", "GPT", "Claude", "gemini", "LLM", "token",
|
||||
"prompt", "LoRA", "微调", "推理", "MCP"},
|
||||
"网络": {"VPN", "梯子", "代理", "域名", "隧道", "服务器",
|
||||
"cloudflare", "tunnel", "反代"},
|
||||
# Affairs / 事务
|
||||
"财务": {"钱", "转账", "工资", "花了", "欠", "还款", "借",
|
||||
"账单", "余额", "预算", "黄金"},
|
||||
"计划": {"计划", "目标", "deadline", "日程", "清单", "安排"},
|
||||
"待办": {"要做", "记得", "别忘", "提醒", "下次"},
|
||||
# Inner / 内心
|
||||
"情绪": {"开心", "难过", "生气", "哭", "泪", "孤独", "幸福",
|
||||
"伤心", "烦", "委屈", "感动", "温柔"},
|
||||
"回忆": {"以前", "小时候", "那时", "怀念", "曾经", "记得"},
|
||||
"梦境": {"梦", "梦到", "梦见", "噩梦", "清醒梦"},
|
||||
"自省": {"反思", "觉得自己", "问自己", "意识到", "明白了"},
|
||||
}
|
||||
|
||||
matched_domains = []
|
||||
for domain, kws in domain_keywords.items():
|
||||
hits = sum(1 for kw in kws if kw in text_lower)
|
||||
if hits >= 2:
|
||||
matched_domains.append((domain, hits))
|
||||
matched_domains.sort(key=lambda x: x[1], reverse=True)
|
||||
domains = [d for d, _ in matched_domains[:3]] or ["未分类"]
|
||||
|
||||
# --- Emotion estimation via simple sentiment dictionary ---
|
||||
# --- 情感坐标估算:基于简单情感词典 ---
|
||||
positive_words = {"开心", "高兴", "喜欢", "哈哈", "棒", "赞", "爱",
|
||||
"幸福", "成功", "感动", "兴奋", "棒极了",
|
||||
"happy", "love", "great", "awesome", "nice"}
|
||||
negative_words = {"难过", "伤心", "生气", "焦虑", "害怕", "无聊",
|
||||
"烦", "累", "失望", "崩溃", "愤怒", "痛苦",
|
||||
"sad", "angry", "hate", "tired", "afraid"}
|
||||
intense_words = {"太", "非常", "极", "超", "特别", "十分", "炸",
|
||||
"崩溃", "激动", "愤怒", "狂喜", "very", "so", "extremely"}
|
||||
|
||||
pos_count = sum(1 for w in positive_words if w in text_lower)
|
||||
neg_count = sum(1 for w in negative_words if w in text_lower)
|
||||
intense_count = sum(1 for w in intense_words if w in text_lower)
|
||||
|
||||
# valence: positive/negative emotion balance
|
||||
if pos_count + neg_count > 0:
|
||||
valence = 0.5 + 0.4 * (pos_count - neg_count) / (pos_count + neg_count)
|
||||
else:
|
||||
valence = 0.5
|
||||
|
||||
# arousal: intensity level
|
||||
arousal = min(1.0, 0.3 + intense_count * 0.15 + (pos_count + neg_count) * 0.08)
|
||||
|
||||
return {
|
||||
"domain": domains,
|
||||
"valence": round(max(0.0, min(1.0, valence)), 2),
|
||||
"arousal": round(max(0.0, min(1.0, arousal)), 2),
|
||||
"tags": keywords[:5],
|
||||
"suggested_name": "",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Default analysis result (empty content or total failure)
|
||||
# 默认分析结果(内容为空或完全失败时用)
|
||||
@@ -635,21 +521,18 @@ class Dehydrator:
|
||||
if not content or not content.strip():
|
||||
return []
|
||||
|
||||
# --- Try API digest first (best quality, understands semantic splits) ---
|
||||
# --- 优先 API 整理 ---
|
||||
if self.api_available:
|
||||
try:
|
||||
result = await self._api_digest(content)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"API diary digest failed, degrading to local / "
|
||||
f"API 日记整理失败,降级到本地拆分: {e}"
|
||||
)
|
||||
|
||||
# --- Local split fallback / 本地拆分兜底 ---
|
||||
return await self._local_digest(content)
|
||||
# --- API digest (no local fallback) ---
|
||||
if not self.api_available:
|
||||
raise RuntimeError("脱水 API 不可用,请检查 config.yaml 中的 dehydration 配置")
|
||||
try:
|
||||
result = await self._api_digest(content)
|
||||
if result:
|
||||
return result
|
||||
raise RuntimeError("API 日记整理返回空结果")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"API 日记整理失败,请检查 API 连接: {e}") from e
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# API call: diary digest
|
||||
@@ -667,7 +550,7 @@ class Dehydrator:
|
||||
{"role": "user", "content": content[:5000]},
|
||||
],
|
||||
max_tokens=2048,
|
||||
temperature=0.2,
|
||||
temperature=0.0,
|
||||
)
|
||||
if not response.choices:
|
||||
return []
|
||||
@@ -717,50 +600,7 @@ class Dehydrator:
|
||||
"domain": item.get("domain", ["未分类"])[:3],
|
||||
"valence": valence,
|
||||
"arousal": arousal,
|
||||
"tags": item.get("tags", [])[:5],
|
||||
"tags": item.get("tags", [])[:15],
|
||||
"importance": importance,
|
||||
})
|
||||
return validated
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Local diary split (fallback when API is unavailable)
|
||||
# 本地日记拆分(无 API 时的兜底)
|
||||
# Split by blank lines/separators, analyze each segment
|
||||
# 按空行/分隔符拆段,每段独立分析
|
||||
# ---------------------------------------------------------
|
||||
async def _local_digest(self, content: str) -> list[dict]:
|
||||
"""
|
||||
Local paragraph split + per-segment analysis.
|
||||
本地按段落拆分 + 逐段分析。
|
||||
"""
|
||||
# Split by blank lines or separators / 按空行或分隔线拆分
|
||||
segments = re.split(r"\n{2,}|---+|\n-\s", content)
|
||||
segments = [s.strip() for s in segments if len(s.strip()) > 20]
|
||||
|
||||
if not segments:
|
||||
# Content too short, treat as single entry
|
||||
# 内容太短,整个作为一个条目
|
||||
analysis = self._local_analyze(content)
|
||||
return [{
|
||||
"name": analysis.get("suggested_name", "日记"),
|
||||
"content": content.strip(),
|
||||
"domain": analysis["domain"],
|
||||
"valence": analysis["valence"],
|
||||
"arousal": analysis["arousal"],
|
||||
"tags": analysis["tags"],
|
||||
"importance": 5,
|
||||
}]
|
||||
|
||||
items = []
|
||||
for seg in segments[:10]: # Max 10 segments / 最多 10 段
|
||||
analysis = self._local_analyze(seg)
|
||||
items.append({
|
||||
"name": analysis.get("suggested_name", "") or seg[:10],
|
||||
"content": seg,
|
||||
"domain": analysis["domain"],
|
||||
"valence": analysis["valence"],
|
||||
"arousal": analysis["arousal"],
|
||||
"tags": analysis["tags"],
|
||||
"importance": 5,
|
||||
})
|
||||
return items
|
||||
|
||||
Reference in New Issue
Block a user